- Securing your Flask application can be a complex task that requires careful attention. The difficulty primarily arises from the challenge of determining what exactly needs to be secured.
Use HTTP encryption
To ensure your web traffic is always encrypted, use HTTPS. It’s simple and free to obtain an SSL certificate for your domain. There’s no excuse not to have one on your production server.
- Let’s start out with injection (this also includes Cross site scripting XSS) and since It’s the most common and brutal type of attacks.
Protect against Injection
Tip: Flask-WTF has many methods to validate diffrent user input … (Click to read more)
Flask has some very useful extensions like Flask-WTF Which has built-in modules to sanitize and validate user input.
You have to install it first!
pip install Flask-WTF
So Instead of writing your own functions to add regex for Emails or allowed characters for the username you can use something like this:
from flask_wtf import FlaskForm from wtforms import (HiddenField, StringField) from wtforms.validators import (InputRequired, Length, Email, ValidationError, Regexp, UUID) class SomeForm(FlaskForm): username = StringField('Name', [InputRequired(), Length(NAME_LEN_MIN, NAME_LEN_MAX), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, 'Usernames must have only letters, ' 'numbers, dots or underscores') ]) email = StringField('Email', [InputRequired(), Email()]) activation_key = HiddenField([UUID()]) # and maybe try to validate based on your own fuction def validate_email(self, email): if email_exists(self, email.data): raise ValidationError('This email is taken')
So in short Flask-wtf is a handy extension to have which already has many methods you can use to validate forms.
A simple yet very effective and important security measure against injection would be to secure your HTTP headers.
HTTP Headers
- Content security policy (CSP) header:
For that we first need some strict CSP headers in our application.
Here is an example:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; require-trusted-types-for 'script';
You want to allow scripts from a cdn ? Sure!
Add script-src 'self' cdn.example.org
Content-Security-Policy: default-src 'self'; script-src 'self' cdn.example.org; object-src 'none'; frame-ancestors 'none'; require-trusted-types-for 'script';
Always make sure you validate your CSP !!!!
- For that you can use https://csp-evaluator.withgoogle.com
Note: Csp is not something you want to copy/paste! You have to understand what you are allowing/deying so I highly recommend you to read more about it …
Other HTTP security headers:
Strict-Transport-Security: 'max-age=31536000; includeSubDomains'
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: 'max-age=31536000; includeSubDomains'
: enforces secure communication over HTTPS for the specified domain and its subdomains for a duration of one year.X-Frame-Options: SAMEORIGIN:
restricts the loading of a webpage in a frame or iframe to only those that originate from the same domain. It helps mitigate clickjacking attacks.X-XSS-Protection: 1; mode=block
: enables the browser’s built-in Cross-Site Scripting (XSS) protection. It instructs the browser to block any detected XSS attacks.X-Content-Type-Options: nosniff
: prevents the browser from automatically detecting the content type of a response and forces it to adhere to the declared content type. It helps mitigate MIME sniffing attacks.Referrer-Policy: strict-origin-when-cross-origin
: controls the information sent by the browser in the Referrer header when navigating from one origin to another. It ensures that the full URL is sent as the referrer when the request is from the same origin, but only the origin is sent when the request is from a different origin. This helps protect sensitive information.
Now let’s put it all together!
In your flask config (Or your main application file) You can define a dictionary like this:
HEADERS = {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Frame-Options': 'SAMEORIGIN',
'X-XSS-Protection': '1; mode=block',
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'self'; object-src 'none'; script-src; base-uri 'self'; require-trusted-types-for 'script',
'Referrer-Policy': 'strict-origin-when-cross-origin'
}
And configure the app headers:
Make sure you import the HEADERS
from the config with:
from .config import HEADERS
def configure_app_headers(app):
@app.after_request
def _add_security_headers(resp):
resp.headers.update(HEADERS)
Subresource Integrity
Subresource Integrity (SRI) is a security feature that enables browsers to verify that resources they fetch (for example, from a CDN) are delivered without unexpected manipulation. It works by allowing you to provide a cryptographic hash that a fetched resource must match.
Long story short: Always make sure you add integrity tags to your JavaScript (Or <link>
html tags)
For example:
<script
src="https://example.com/example-framework.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
Alright how do I calculate that hash ??
- 1 - Local files:
# This will generate a hash
cat FILENAME.js | openssl dgst -sha384 -binary | openssl base64 -A
also make sure you add this sha384
‘sha384-the-hash-generated-by-the-command-above’
- 2 - Remote files:
# -L to follow links just incase ...
curl -L -s https://example.org/FILENAME.js | openssl dgst -sha384 -binary | openssl base64 -A
Nonces
Sometimes you really need some inline JavaScript and you also don’t want to use 'unsafe-inline'
(Yeah u shouldn’t …)
You can use nonces intead!
Here is a python function that generates nonces:
# the token_urlsafe method gives us url safe strings which is nice since we will be including that in our CSP.
import secrets
def get_random_urlsafe_strings(length):
return secrets.token_urlsafe(length)[:length]
get_random_urlsafe_strings(16)
# give this: 'T84i4_ILysKM7GgYZe'
Also after adding a nonce to our csp header we need to make that available in our jinja
template so we can use it!
Note: If you add a nonce you also need to make sure you add it to your JavaScript files:
<script nonce="{{ nonce }}" integrity="sha386-..." src="/static/my-good-script.js">
And ofc our inline javascript
Or for inline scripts
<script nonce="{{ nonce }}">
function greet() {
alert('Hello, world!');
}
</script>
Note: Nonce and integrity serve different purposes here! Read more …
Now let’s add that to our configure_app_headers()
function
def configure_app_headers(app):
def _make_nonce():
if not getattr(request, 'csp_nonce', None):
request.csp_nonce = get_random_urlsafe_string(18)
def _add_security_headers(resp):
resp.headers.update(HEADERS)
csp_header = resp.headers.get('Content-Security-Policy')
if csp_header and 'nonce' not in csp_header:
resp.headers['Content-Security-Policy'] = \
csp_header.replace('script-src', f"script-src 'nonce-{request.csp_nonce}'")
return resp
app.before_request(_make_nonce)
app.after_request(_add_security_headers)
# we still need the HEADERS dic
# get_random_url_safe_string should be already defined somewhere (maybe in utils.py ?)
resp
is our dictionary HEADERS
(resp.headers.update(HEADERS)
)
We only want to change the part that has the csp in it …
resp.headers.get('Content-Security-Policy')
# and
# replace('script-src', f"script-src 'nonce-{request.csp_nonce}'")
Let’s check if everything worked as expected:
curl -I http://127.0.0.1:5000 # assuming u dind't change the default port
-I
to see the headers.
The response should look something like this
HTTP/1.1 200 OK
Server: Werkzeug/3.0.1 Python/3.11.6
Date: Sun, 24 Dec 2023 13:25:53 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 200
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'self'; object-src 'none'; script-src 'nonce-agvNFPWnaO_d258C'; base-uri 'self'; require-trusted-types-for 'script'
Referrer-Policy: strict-origin-when-cross-origin
Connection: close
All good!
We still have to make that available in our jinja
template:
An easy way would be to just use request.csp_nonce
variable we defined
<script nonce="{{ request.csp_nonce }}">
// …
</script>
Until now our app security is much better! But there is more to it!
Malicious requests
Manipulating the request is as simple as making curl request:
curl -H "Referer: http://other-referer.com" \
-H "User-Agent: MyCustomUserAgent" \
"http://localhost:5000/path?next=https://my-Evil-next.com"
We changed the referer
, user agent
and the next
variable we is often used to redirect after a (403: Non Authorized) request.
Sor for example a common code like this:
# Form using Flask-WTF
@app.route('/login', methods=['GET', 'POST'])
def login():
next=request.args.get('next', url_for('main.home'))
form = LoginForm(login=request.args.get('login', None), next=next)
if form.validate_on_submit():
# Do stuff
return redirect(next)
return render_template('login.html', form=form)
Is vulnerable to arbitrary HTTP redirects!
next
should always be verified (And anything that is user dependent) .. make sure it’s an allowed url.
So a fix for that would be:
@app.route('/login', methods=['GET', 'POST'])
def login():
next=request.args.get('next', url_for('main.home'))
if not url_has_allowed_host_and_scheme(next, request.host):
return abort(400)
form = LoginForm(login=request.args.get('login', None), next=next)
if form.validate_on_submit():
# Do stuff
return redirect(next)
return render_template('login.html', form=form)
- The function
url_has_allowed_host_and_scheme()
This is taking from Django http module.
Which you find a tweaked version of it that works with flask .. here
So now only valid urls are allowed
- Allowed:
next='/home' or f"https://{request.host}/home"
- Not Allowed:
next='https://someothersite.org' or 'file:///'
Or a code like this:
request.headers.get('X-Forwarded-For', request.remote_addr)
# X-Forwarded-For can be easily spoofed ...
Anything about a request can be changed! So keep that in mind.
Cookies Security
Cookies are signed by our flask secret key app.config['SECRET_KEY']
by default and NOT encrypted.
This simply means any change to it will change the signature of the cookie and there for they will be rejected!
So Cookies in flask are tamper proof but NOT private! Everyone can see the content of the cookie including the user! So Never ever store passwords or anything sensitive in the cookie.
Cookies are simply base64 encoded and when a cookie is too large they are compressed … So using something like
- For encoded base64
import base64
base64.urlsafe_b64decode(cookie)
base64.urlsafe_b64encode(cookie)
- For encoded base64 + compresse cookies
import zlib
zlib.decompress(cookie)
zlib.decompress(base64.urlsafe_b64decode(cookie))
Will show you the actual content of the cookie … So Keep that in mind!
Let’s see some solid configurations to have for your cookies!
config.py
SESSION_COOKIE_SAMESITE = "Strict"
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Strict"
sets the SameSite attribute of the session cookie to “Strict,” which means the cookie will only be sent in a first-party context and not on cross-site requests.SESSION_COOKIE_SECURE = True
enables the secure flag for the session cookie, ensuring it is only transmitted over encrypted HTTPS connections.SESSION_COOKIE_HTTPONLY = True
sets the HTTPOnly flag for the session cookie, preventing client-side JavaScript from accessing the cookie and enhancing security against certain types of attacks.
In addition to that if you are using Flask-Login
you’re mostly using the Remember me argument in login_user()
if login_user(user, remember=remember):
flash("Logged in", 'success')
If remember is True then this will set a cookie in the client browser! So this too needs to be protected!
REMEMBER_COOKIE_SAMESITE = "Strict"
REMEMBER_COOKIE_SECURE = True
REMEMBER_COOKIE_HTTPONLY = True
Note: you can change the duration of the cookie with
REMEMBER_COOKIE_DURATION = timedelta(days=30)
you need (from datetime import timedelta
)
Perfect! We are almost there!
Other things to consider
Jinja2
automatically escape all values unless explicitly told otherwise.
However you should be careful if you are:
generating HTML without the help of Jinja2
calling Markup on data submitted by users
sending out HTML from uploaded files, never do that, use the
Content-Disposition: attachment
header to prevent that problem.sending out textfiles from uploaded files. Some browsers are using content-type guessing based on the first few bytes so users could trick a browser to execute HTML.
SQL Injection
Use parameterized queries or prepared statements rather than executable SQL code.
- Vulnerable:
query = "SELECT * FROM users WHERE username = '{}' AND password = '{}'".format(username, password)
cursor.execute(query)
result = cursor.fetchone()
- Not Vulnerable
query = "SELECT * FROM users WHERE username = ? AND password = ?"
cursor.execute(query, (username, password))
result = cursor.fetchone()
Note:
FLask-SQLAlchemy
SQLAlchemy’s ORM provides a high-level abstraction that helps prevent SQL injection by automatically sanitizing and escaping user input.
Let me know if I forgot something! Have a question ? You can leave a comment below.