Skip to main content

How to secure Python Flask applications

著者:
Gourav Singh Bais
Gourav Singh Bais
blog-feature-playwright-tests

2024年5月21日

0 分で読めます

Flask is a powerful, lightweight, and versatile web framework for Python, that's designed to make it easy for developers to develop web applications quickly with minimal boilerplate code. It's a stand-alone microframework that doesn't need any additional libraries or tools and has no database abstraction layer.

Flask includes features like routing, template rendering, and request handling, and follows the Web Server Gateway Interface (WSGI) standard — which is known for its simplicity and flexibility.

Unfortunately, just like any other web framework, Flask is susceptible to vulnerabilities if it's not properly secured. The most common security risks for Flask include cross-site scripting (XSS), cross-site request forgery (CSRF), and SQL injection.

You can easily see on the Snyk Vulnerability Database some of the many security vulnerabilities found for the Python flash library:

 Some of the many security vulnerabilities found for the Python flash library on the Snyk Vulnerability Database.

In this article, you'll learn about some best practices related to securing Python applications built with the Flask web application framework. You'll start by looking at some insecure configuration examples and then learn how to mitigate and fix any issues.

What is Web Server Gateway Interface (WSGI) standard?

WSGI allows for a standardized way for web servers to communicate with web applications written in Python. It's an intermediary layer that enables web servers to forward requests to a web application or framework and then deliver responses back to a client.

Why is WSGI important?

Before WSGI, Python web frameworks and servers often had specific coupling, meaning a particular web framework could only run on certain web servers. WSGI broke this limitation, enabling more flexibility and interoperability.

How does WSGI compare to similar concepts in other languages?

WSGI is similar in purpose to the Java Servlet API in Java or Rack in Ruby. These technologies abstract the details of HTTP requests and responses, allowing developers to focus on writing web application logic rather than dealing with underlying server communication.

Insecure configurations for Python Flask applications

Following are some of the most common insecure configuration issues that impact Python Flask applications and that developers should keenly ensure they aren’t repeating these mistakes in their code bases:

Secret key exposure

Unintentionally disclosing the secret key is a common security lapse involving sensitive information, such as the Flask secret key, API key, and passwords, within the source code. Integrating confidential details directly into the source code poses a substantial security risk, as vicious actors can exploit this vulnerability to gain unauthorized access to various services.

If you've accidentally hard-coded your secret key and API key in the source code, it will look like this:

# Insecure Code
api_key = "my_sensitive_api_key"
api_url = "https://api.example.com"
SEC_KEY = "your_secret_key"

# use of secret key
app.config["SECRET_KEY"] = SEC_KEY
# usage of the API key
response = requests.get(api_url, headers={"Authorization": f"Bearer {api_key}"})

Tip: this is a good time to install the Snyk extension in your IDE so that you get a visual indication of secret key exposure like the blue squiggly line you can see in my editor:

Snyk IDE extension shows code security issues such as sensitive API key exposure and credentials leak.

In this instance, to mitigate secret key exposure, you need to generate the secret key securely. Although there are several ways to generate the secret key, one of the most common ways is to use the uuid module. The uuid module is primarily used for generating universally unique identifiers (UUIDs) based on the principles defined in RFC 4122. This module generates a 32-byte random hexadecimal string that can be used as a secret key.

To generate a secure key, you import the uuid module in your code and use the uuid4().hex functionality:

import uuid

uuid.uuid4().hex

However, generating the secret key is not enough. You also need to use it securely in your Flask app. The recommended secure way is to assign the secret key to an environment variable or use an encrypted configuration file and store the key there. If you choose to assign the secret key to an environment variable, it will look like this:

import os

# Better Code
api_key = os.environ.get("MY_API_KEY")
api_url = "https://api.example.com"
SEC_KEY = os.environ.get("MY_SEC_KEY")

# use of secret key
app.config["SECRET_KEY"] = SEC_KEY

# Usage of the API key
response = requests.get(api_url, headers={"Authorization": f"Bearer {api_key}"})

Developers should follow configuration conventions such as those recommended in the 12 factor app to use environment variables for storing their credentials to avoid data leak, unauthorized access and potential misuse.

Debug Mode Enabled

The debug mode is generally intended for development environments, providing detailed error messages and comprehensive information about the application's state. However, enabling this mode in a production environment exposes sensitive information and the internal workings of the system.

As an example, in this Flask web application, the debug mode is enabled in production:

# Insecure code
from flask import Flask
appFlask = Flask(__name__)

@appFlask.route('/home')
def home():
    result = "Testing Snyk"
    return 'example of debug mode enabled'

if __name__ == "__main__":
    appFlask.run(debug=True)

Here, the debug mode is set to True, which means if any error occurs in production, the sensitive information, including usernames and passwords, can be exposed in the debug console.

In this scenario, it's very easy to forget that you set the debug mode to True and forget to change it before deploying your application to production. That's why it's best to use a platform like Snyk that can help you find and fix the vulnerabilities in your code and applications. Snyk supports a wide range of programming languages, including Python, Go, PHP, JavaScript and others.

In addition, Snyk can be easily integrated with various IDEs, including Visual Studio Code and PyCharm, as well as CI pipelines, such as Jenkins, CircleCI, and Maven, and workflows.

For instance, to use Snyk in this debugging scenario, all you need to do is search for "Snyk" in Visual Studio Code and install it:

Snyk Security extension for VS Code.

Once installed, Snyk will appear in the panel on the left. Go ahead and log in or create a Snyk account, if you don't already have one, by clicking the Connect button. Then a browser window will appear that looks like this:

Connect your IDE extension to the Snyk application.

Once connected, navigate back to Visual Studio Code and open the folder where your Flask script is stored. You should now be able to see any vulnerabilities in your code:

A Flask python application showing vulnerabilities and code security issues in the VS Code IDE with the Snyk extension.

As you can see, Snyk found that the debug mode was still enabled. To mitigate this issue, you need to secure your Flask application by setting the debug mode to False:

# Secure Code
from flask import Flask
appFlask = Flask(__name__)

@appFlask.route('/home')
def home():
    result = "Testing Snyk"
    return 'example of debug mode enabled'

if __name__ == "__main__":
    appFlask.run(debug=False)

In addition to disabling the debug mode in production, you should also use the logging mechanism to collect the logs from the application to check for possible issues in the code.

Unprotected sensitive data in configuration files

Storing critical information (e.g, database credentials, API keys, and encryption keys) in configuration files is a common practice in Flask applications. However, the data can be compromised if you leave these files unprotected.

Consider the following scenario where your configuration file (usually config.ini) holds the credentials to connect to the database:

# Insecure Config
[database]
username = admin
password = my_secure_password

Here, the username and password are mentioned in plaintext, so if an attacker gains access to this file, they can use the credentials to gain access to sensitive information.

The best way to resolve this issue is to avoid configuration files and use environment variables to save your credentials. Since environment variables collect all the required information in a hidden file, like .bash_profile, you can simply gitignore this file to avoid accidentally pushing it to version control platforms, like Git or Bitbucket, which are easily accessed by attackers.

If you're in a scenario where you need to use the configuration files in your Flask app, you need to make sure that sensitive data is encrypted:

# Secure Config
[database]
username = admin
password = $encrypted_password

Here, a placeholder or an encrypted value is used to represent the password. In this scenario, sensitive data can be accessed at runtime using an external secure key management system or a safe decryption technique.

Flask application vulnerabilities

Now that you know a little bit more about some insecure configurations for Python Flask applications, it's time to look at some common web security vulnerabilities that can also affect Flask applications.

Cross-site scripting (XSS)

An XSS is a web security vulnerability where an attacker tries to inject arbitrary JavaScript or HTML code into the web page. This allows attackers to execute malicious scripts in a user's browser.

Jinja2, a template used by Flask, provides an Auto Escape feature to handle XSS vulnerabilities. By default, all the variables are auto-escaped (ie they don't contain any HTML component until told otherwise). This means any input passed by the user containing HTML or JavaScript code is safely escaped by Jinja2, preventing the execution of malicious scripts.

If auto-escaping is set to False, the Flask application is prone to XSS vulnerabilities. For instance, here, you have a web application that requires input from the user to render a page:

from flask import Flask, render_template, request

app = Flask(__name__)

@app.route('/')
def index():
    user_input = request.args.get('input')
    return render_template('index.html', input=user_input)

The corresponding index.html page where auto-escaping is set to false would look like this:

<!DOCTYPE html>
{% autoescape false %}
<html>
<head>
    <title>XSS Example</title>
</head>
<body>
    <p>User Input: {{ input }}</p>
</body>
{% endautoescape %}
</html>

If an attacker injects the malicious script through the input parameter, it executes when the page is rendered, leading to potential security risks, such as data theft, account takeover, and phishing attacks.

To mitigate XSS, developers need to configure their website so that it can properly escape the text and does not include any HTML component. All you need to do is set the auto-escaping to true to prevent the Flask application from an XSS attack:

<!DOCTYPE html>
{% autoescape true %}
<html>
<head>
    <title>XSS Example</title>
</head>
<body>
    <p>User Input: {{ input }}</p>
</body>
{% endautoescape %}
</html>

Another method to prevent XSS is to use explicit markup escaping. To implement explicit markup escaping, you need to use the |safe filter on a web page, like this:

<!DOCTYPE html>
<html>
<head>
    <title>XSS Example</title>
</head>
<body>
    <p>User Input: {{ input|safe }}</p>
</body>
</html>

However, using |safe should be done with great care as it assumes that the content being marked as safe is free from any malicious script.

To learn more about cross-site scripting vulnerabilities in Python, head over to a byte-sized security lesson at Snyk Learn.

Cross-site request forgery (CSRF)

CSRF is a malicious attack that deceives a victim into submitting a malicious request. In a web application, CSRF involves an attacker making an unauthorized request to a web page on behalf of the authorized user. You often see this type of attack on platforms like Facebook, where someone sends an image to a group, and if users in that group click on the image, their account gets deleted.

To better understand CSRF, consider a simple state-changing application that works without CSRF protection, like this:

from flask import Flask, render_template, request, redirect, url_for

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/change_password', methods=['POST'])
def change_password():
    new_password = request.form.get('new_password')
    # Change password logic here
    return redirect(url_for('index'))

This code submits a form to change the password with a POST request. Since there's no CSRF security, an attacker can trick users into changing their password by directing them to a different website that contains a crafted HTML or script. A form that doesn't contain CSRF protection looks something like this:

<!DOCTYPE html>
<html>
<head>
    <title>CSRF Example</title>
</head>
<body>
    <form method="post" action="{{ url_for('change_password') }}">
   	 <label for="new_password">New Password:</label>
   	 <input type="password" name="new_password" required>
   	 <button type="submit">Change Password</button>
    </form>
</body>
</html>

To mitigate CSRF vulnerabilities, you need to use the Flask-WTF extension, along with the CSRFProtect middleware. Flask-WTF is a Flask plugin that incorporates the WTForms library, which offers practical functionality for easily generating and managing forms for a Flask web application. 

You can install it using pip as follows:

pip install Flask-WTF

Once installed, you can update your Flask application with Flask-WTF for CSRF protection:

from flask import Flask, render_template, request, redirect, url_for
from flask_wtf.csrf import CSRFProtect

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your_secret_key'  # Set a secret key for CSRF protection
csrf = CSRFProtect(app)

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/change_password', methods=['POST'])
def change_password():
    new_password = request.form.get('new_password')
    # Change password logic here
    return redirect(url_for('index'))

Here, CSRFProtect is initialized with the Flask app, and the SECRET_KEY is set. This key is specifically used to create and validate CSRF tokens.

Don't forget to update the template (index.html) to include the CSRF token, like this:

<!DOCTYPE html>
<html>
<head>
    <title>CSRF Example</title>
</head>
<body>
    <form method="post" action="{{ url_for('change_password') }}">
   	 {{ csrf_token() }}
   	 <label for="new_password">New Password:</label>
   	 <input type="password" name="new_password" required>
   	 <button type="submit">Change Password</button>
    </form>
</body>
</html>

This csrf_token() function generates a hidden input field that contains the CSRF token. When the form is submitted, the token is validated by Flask-WTF to ensure that the request is not a CSRF attack.

FSQL injection and database security

Now, look at a few different Flask issues related to database security:

SQL Injection

SQL injection is a serious issue where an attacker executes arbitrary SQL code on a database by manipulating the parameters used in an SQL query. This issue can lead to unauthorized access, data manipulation, and disclosure of sensitive information.

For Flask applications, if the inputs are not properly sanitized before being used in an SQL query, an SQL injection attack can occur.

To better understand this attack, consider a sample Flask code that selects the data from a database, like this:

1from flask import Flask, request, render_template
2import sqlite3
3
4app = Flask(__name__)
5
6@app.route('/login', methods=['POST'])
7def login():
8    username = request.form['username']
9    password = request.form['password']
10
11    # Vulnerable SQL query
12    query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
13
14    # Execute the query
15    conn = sqlite3.connect('database.db')
16    cursor = conn.cursor()
17    cursor.execute(query)
18    user = cursor.fetchone()
19    conn.close()
20
21    if user:
22   	 return 'Login successful'
23    else:
24   	 return 'Login failed'

This query expects the username and password in the query string. If an attacker injects inputs, like ' OR '1'='1'; -- , the query becomes like this:

SELECT * FROM users WHERE username = '' OR '1'='1'; --' AND password = '';

Since this query always results in True, the attacker would be logged in and can have unauthorized access to the data.

Identifying code that is prone to SQL injection is difficult, but thankfully, Snyk can help. Since you already configured Snyk in Visual Studio Code, all you need to do is open the script, and Snyk will auto-detect the SQL injection issue:

Snyk warns of SQL injection vulnerability due to concatenated user input with an SQL query.

An easy way to mitigate this SQL injection attack is to use parameterized queries or statements. In 

Flask, you can also use the execute method of the cursor object to bind parameters securely. A parametrized query looks like this:

from flask import Flask, request, render_template
import sqlite3

app = Flask(__name__)

@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']

    # Secure SQL query using parameterized query
    query = "SELECT * FROM users WHERE username = ? AND password = ?"

    # Execute the query with parameters
    conn = sqlite3.connect('database.db')
    cursor = conn.cursor()
    cursor.execute(query, (username, password))
    user = cursor.fetchone()
    conn.close()

    if user:
   	 return 'Login successful'
    else:
   	 return 'Login failed'

In this code, ? placeholders are used, whose actual values are provided as the tuple in the execute method, ensuring that user inputs are treated as data and not as executable SQL code.

Make sure you always validate and sanitize user inputs to avoid this type of attack. You can sanitize the input with the help of techniques like escape characters, third-party libraries, and regular expressions.

For an indepth informative article about SQL injections I recommend reading SQL injection cheat sheet: 8 best practices to prevent SQL injection attacks.

Authentication and authorization

Authentication and authorization are critical components of web security. A breach in authentication and authorization can lead to several security issues, which you'll learn about next.

Authentication security issues

One of the most common authentication security issues is the brute-force attack, where an attacker repeatedly tries to guess a username and password to gain access to a certain website or service. Another common issue is session fixation, where the attacker sets the session ID to a known value, possibly through a phishing attack.

To mitigate brute-force attacks, you need to implement a session lockout mechanism. In this approach, the user is locked out after a certain number of failed login attempts:

from flask import Flask, request, session

app = Flask(__name__)
app.config['MAX_LOGIN_ATTEMPTS'] = 3

@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']

    # Check if the user is locked out
    if 'login_attempts' in session and session['login_attempts'] >= app.config['MAX_LOGIN_ATTEMPTS']:
        return 'Account locked. Please try again later.'

    # Validate the username and password
    # ...

    # Update login attempts
    if 'login_attempts' in session:
        session['login_attempts'] += 1
    else:
        session['login_attempts'] = 1

    # Authenticate the user
    # ...

In this code, you define the MAX_LOGIN_ATTEMPTS as 3, which means if someone tries to enter the wrong credentials more than three times, they'll be locked out.

In the login() method, you get the username and password and check if the user is locked out. If not, you go ahead and check the login attempts. If the credentials are correct, then you're good to go. But if not, the login attempt increases by one, and the process repeats until the user is successfully logged in or locked out.

In addition to a session lockout mechanism, you should also use strong password policies and consider using multifactor authentication (MFA).

To mitigate the session fixation issues, you can regenerate session IDs after a user logs in or after a certain period (as auto-expiry) even if the user continues to stay logged in:

from flask import Flask, request, session

app = Flask(__name__)
app.secret_key = 'your_secret_key'

@app.route('/login', methods=['POST'])
def login():
    # Authenticate the user
    # ...

    # Regenerate session ID after successful login
    session.regenerate()
    # ...

In this code, the login() method regenerates the session ID using the regenerate() method once the user is successfully logged in.

Authorization security issues

When a web app is developed, every feature isn't intended for every user (ie a specific user should have access to only a select set of functionalities). When the roles are not properly assigned to users, unauthorized access can occur, which is why you need role-based access control (RBAC).

It's also possible for authenticated users to access unauthorized sources by manipulating parameters or URLs, which is often referred to as insecure direct object references (IDOR). I highly recommended reading this post on finding and fixing insecure direct object references in Python for more information.

To resolve RBAC and IDOR issues, you need to assign specific user roles and enforce access controls based on those roles, like this:

from flask import Flask, request, session

app = Flask(__name__)

@app.route('/admin_dashboard')
def admin_dashboard():
    if 'role' in session and session['role'] == 'admin':
   	 # User has admin role and is authorized to access the admin dashboard
   	 # ...
   	 return 'Admin dashboard page'
    else:
   	 # User is not authorized
   	 return 'Unauthorized access'

In this code, the admin_dashboard() method implements the functionalities that should be accessed if the access role is admin. Otherwise, it returns an Unauthorized access warning.

You also need to manage access control so that users can access only the resources they're authorized to:

from flask import Flask, request, session

app = Flask(__name__)

@app.route('/user_profile/<int:user_id>')
def user_profile(user_id):
    if 'user_id' in session and session['user_id'] == user_id:
   	 # User is authorized to access their own profile
   	 # ...
   	 return 'User profile page'
    else:
   	 # User is not authorized
   	 return 'Unauthorized access'

You shouldn't store credentials in plaintext because they can be easily accessed by attackers for malicious intent. To prevent this, you should use hashing and salting to store the user credentials.

Hashing transforms a password into a fixed-length string of characters, making it computationally infeasible to reverse the process and obtain the original password. Salting involves adding a unique random value (the salt) to each password before hashing, which helps prevent attacks such as rainbow table attacks.

Outdated libraries and security updates

If you're using outdated Flask libraries, they may contain vulnerabilities that could be exploited by attackers. Libraries often release security updates and patches to address vulnerabilities. You should always make sure you're using the most up-to-date package.

To ensure you're using the latest security patches, you can use tools like pip or conda. Additionally, you can use tools like Snyk to scan your entire code for dependencies and infrastructure-as-code (IaC) configurations.

Another best practice is to pin the dependencies in the requirements.txt file. This ensures that the project uses specific versions, giving developers control over updates and the ability to test for compatibility.

Secure file uploads

If a Flask website gives anyone the ability to upload any kind of file, several issues can occur. For instance, if an attacker uploads malicious files to the server, a denial-of-service (DoS) attack can occur, with the possibility of overwriting existing files. Some executable files can also produce different types of computer viruses.

Following is an example of code that allows any user to upload any files:

from flask import Flask, request, flash, redirect, url_for
from werkzeug.utils import secure_filename
import os

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'uploads'

@app.route('/upload', methods=['POST'])
def upload_file():
    file = request.files['file']
    filename = secure_filename(file.filename)
    file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
    flash('File successfully uploaded')
    return redirect(url_for('uploaded_file', filename=filename))

In this code, the upload_file() method reads the file provided by the user as input and stores it in the uploads folder.

The first solution to avoid insecure files is to define a set of approved file-type extensions. You should never allow executable or script file types in your Flask app.

Additionally, make sure you store the files in a secure location to avoid unauthorized access:

from flask import Flask, request, flash, redirect, url_for
from werkzeug.utils import secure_filename
import os

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'uploads'
app.config['ALLOWED_EXTENSIONS'] = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'}

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
   	 flash('No file part')
   	 return redirect(request.url)

    file = request.files['file']

    if file.filename == '':
   	 flash('No selected file')
   	 return redirect(request.url)

    if file and allowed_file(file.filename):
   	 filename = secure_filename(file.filename)
   	 file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
   	 flash('File successfully uploaded')
   	 return redirect(url_for('uploaded_file', filename=filename))
    else:
   	 flash('Invalid file type')
   	 return redirect(request.url)

Here, multiple conditions have been applied to check the authenticity of the file types.

Another best practice is to randomize the file names to prevent overwriting and minimize the risk of predictable naming conventions:

from flask import Flask, request, flash, redirect, url_for
import os
import uuid

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'uploads'

def generate_random_filename():
    return str(uuid.uuid4())

@app.route('/upload', methods=['POST'])
def upload_file():
    # ... (other code)
    if file and allowed_file(file.filename):
   	 filename = generate_random_filename()
   	 file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
   	 # ... (other code)

Error handling and information disclosure

In any kind of application, error handling is important. In production, using the print statements and enabling the debug mode is not advisable. If you use an improper log mechanism, it can reveal several important details about your application.

For instance, displaying the detailed error message in production can reveal sensitive information about the application's internals, making it easier for attackers to identify potential vulnerabilities:

OperationalError: (1045, "Access denied for user 'root'@'localhost' (using password: YES)")

This example reveals the most important information about the database associated with the Flask app.

Additionally, displaying the full trackbacks in error responses can reveal the application's code structure and potentially expose vulnerabilities like this:

File "/path/to/app.py", line 42, in some_function
	result = 1 / 0
ZeroDivisionError: division by zero

To mitigate information disclosure through error handling, you can create custom error pages for generic error codes (eg 404 Not Found and 500 Internal Server Error) without exposing sensitive details. To do so, you can use the errorhandler decorator to define custom error handlers:

from flask import Flask, render_template

app = Flask(__name__)

@app.errorhandler(404)
def page_not_found(error):
    return render_template('404.html'), 404

@app.errorhandler(500)
def internal_server_error(error):
    return render_template('500.html'), 500

Moreover, you should implement proper logging for exceptions and errors. Log messages should be detailed enough for debugging but not expose sensitive information:

import logging
from flask import Flask

app = Flask(__name__)

logging.basicConfig(filename='error.log', level=logging.ERROR)

@app.route('/divide')
def divide_by_zero():
    try:
   	 result = 1 / 0
   	 return str(result)
    except Exception as e:
   	 app.logger.error(f"An error occurred: {str(e)}")
   	 return "Internal Server Error", 500

By following these best practices, you can ensure that your Flask application is robust and secure.

Conclusion

Flask is a popular web framework for small- and medium-scale web applications. However, like any other web development tool or framework, Flask can encounter issues if not handled properly. It's important that you're not only aware of these issues but also able to mitigate them to prevent malicious actors from taking advantage.

You are invited to follow this code repository on GitHub for full working examples of some of the Flask application security topics we discussed.

Snyk can help you easily find and mitigate any vulnerabilities in your code. Snyk supports various programming languages and seamlessly integrates with your tools, pipelines, and workflows. Get started with Synk for free today.

Finally, be sure to follow Snyk’s Python Security Best Practices Cheat Sheet and acquire application security skills in Python through the free Snyk Learn website.