Skip to content

Commit e4cf27e

Browse files
Merge pull request #2480 from pallets-eco/support-csp-nonces
Support Content-Security-Policies via nonce values
2 parents aaaa32e + fef1357 commit e4cf27e

File tree

28 files changed

+252
-38
lines changed

28 files changed

+252
-38
lines changed

doc/advanced.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,39 @@ SecureForm class in your *ModelView* subclass by specifying the *form_base_class
2020
SecureForm requires WTForms 2 or greater. It uses the WTForms SessionCSRF class
2121
to generate and validate the tokens for you when the forms are submitted.
2222

23+
CSP support
24+
-----------
25+
26+
****
27+
28+
To support `CSP <https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html>`_
29+
in Flask-Admin, you can pass a `csp_nonce_generator` function through to Flask-Admin on
30+
initialisation. This function should return a CSP nonce that will be attached to all
31+
`<script>` and `<style>` resources. You are responsible for making sure that your Flask
32+
responses include an appropriate 'Content-Security-Policy` header that also includes the
33+
same nonce value.
34+
35+
We recommend using `Flask-Talisman <https://pypi.org/project/flask-talisman/>`_. Here's an example
36+
of how to configure Flask-Admin to inject CSP nonce values::
37+
38+
app = Flask(__name__)
39+
40+
talisman = Talisman(
41+
app,
42+
content_security_policy={
43+
"default-src": "'self'",
44+
},
45+
content_security_policy_nonce_in=["script-src", "style-src"]
46+
)
47+
csp_nonce_generator = app.jinja_env.globals["csp_nonce"] # this is talisman's generator function
48+
49+
admin = admin.Admin(app, name="Example", theme=Bootstrap4Theme(), csp_nonce_generator=csp_nonce_generator)
50+
51+
If you decide to use a content security policy, you should pay close attention to the policy you set to
52+
make sure it is appropriate for your project's security needs.
53+
54+
If you create any of your own templates for Flask-Admin pages, you will need to inject the CSP nonces yourself as appropriate.
55+
2356
Adding Custom Javascript and CSS
2457
--------------------------------
2558

examples/csp-nonce/README.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
This example shows how to make Flask-Admin work with a Content-Security-Policy by injecting
2+
a nonce into HTML tags.
3+
4+
To run this example:
5+
6+
1. Clone the repository::
7+
8+
git clone https://github.com/flask-admin/flask-admin.git
9+
cd flask-admin
10+
11+
2. Create and activate a virtual environment::
12+
13+
virtualenv env
14+
source env/bin/activate
15+
16+
3. Install requirements::
17+
18+
pip install -r 'examples/csp-nonce/requirements.txt'
19+
20+
4. Run the application::
21+
22+
python examples/csp-nonce/app.py

examples/csp-nonce/__init__.py

Whitespace-only changes.

examples/csp-nonce/app.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from flask import Flask
2+
3+
import flask_admin as admin
4+
from flask_talisman import Talisman
5+
6+
7+
# Create custom admin view
8+
from flask_admin.theme import Bootstrap4Theme
9+
10+
11+
# Create flask app
12+
app = Flask(__name__, template_folder='templates')
13+
app.debug = True
14+
15+
talisman = Talisman(
16+
app,
17+
content_security_policy={
18+
'default-src': '\'self\'',
19+
'object-src': '\'none\'',
20+
'script-src': '\'self\'',
21+
'style-src': '\'self\'',
22+
},
23+
content_security_policy_nonce_in=['script-src', 'style-src']
24+
)
25+
csp_nonce_generator = app.jinja_env.globals['csp_nonce'] # this is added by talisman
26+
27+
# Flask views
28+
@app.route('/')
29+
def index():
30+
return '<a href="/admin/">Click me to get to Admin!</a>'
31+
32+
# Create admin interface
33+
admin = admin.Admin(name="Example: Simple Views", theme=Bootstrap4Theme(), csp_nonce_generator=csp_nonce_generator)
34+
admin.init_app(app)
35+
36+
if __name__ == '__main__':
37+
38+
# Start app
39+
app.run()

examples/csp-nonce/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Flask
2+
Flask-Admin
3+
4+
flask-talisman
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{% extends 'admin/master.html' %}
2+
3+
{% block head_tail %}
4+
<style>
5+
.insecure-style {
6+
color: red;
7+
}
8+
</style>
9+
<style {{ admin_csp_nonce_attribute }}>
10+
.secure-style {
11+
color: green;
12+
}
13+
</style>
14+
{% endblock head_tail %}
15+
{% block body %}
16+
{{ super() }}
17+
<div class="container">
18+
<div class="row">
19+
<div class="col-sm-10 col-sm-offset-1">
20+
<h1>Flask-Admin Content-Security-Policy (CSP) example</h1>
21+
<p class="lead">
22+
Simple admin views, not related to models.
23+
</p>
24+
<p class="secure-style">
25+
I have an inline style applied that passes CSP checks because I've injected a nonce value.
26+
</p>
27+
<p class="insecure-style">
28+
But I don't have any styling applied because CSP is protecting me.
29+
</p>
30+
<a class="btn btn-primary" href="/"><i class="glyphicon glyphicon-chevron-left"></i> Back</a>
31+
</div>
32+
</div>
33+
</div>
34+
{% endblock body %}

flask_admin/base.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from flask import current_app, render_template, abort, g, url_for, request
88
from flask import Blueprint, current_app, render_template, abort, g, url_for
9+
from markupsafe import Markup
10+
911
from flask_admin import babel
1012
from flask_admin._compat import as_unicode
1113
from flask_admin import helpers as h
@@ -298,6 +300,9 @@ def render(self, template, **kwargs):
298300
# Store self as admin_view
299301
kwargs['admin_view'] = self
300302
kwargs['admin_base_template'] = self.admin.theme.base_template
303+
kwargs['admin_csp_nonce_attribute'] = (
304+
Markup(f'nonce="{self.admin.csp_nonce_generator()}"') if self.admin.csp_nonce_generator else ''
305+
)
301306

302307
# Provide i18n support even if flask-babel is not installed
303308
# or enabled.
@@ -477,7 +482,8 @@ def __init__(self, app=None, name=None,
477482
static_url_path=None,
478483
theme: t.Optional[Theme] = None,
479484
category_icon_classes=None,
480-
host=None):
485+
host=None,
486+
csp_nonce_generator: t.Optional[t.Callable] = None):
481487
"""
482488
Constructor.
483489
@@ -507,6 +513,8 @@ def __init__(self, app=None, name=None,
507513
Example: {'Favorites': 'glyphicon glyphicon-star'}
508514
:param host:
509515
The host to register all admin views on. Mutually exclusive with `subdomain`
516+
:param csp_nonce_generator:
517+
A callable that returns a nonce to inject into Flask-Admin JS, CSS, etc.
510518
"""
511519
self.app = app
512520

@@ -532,6 +540,8 @@ def __init__(self, app=None, name=None,
532540

533541
self._validate_admin_host_and_subdomain()
534542

543+
self.csp_nonce_generator = csp_nonce_generator
544+
535545
# Add index view
536546
self._set_admin_index_view(index_view=index_view, endpoint=endpoint, url=url)
537547

flask_admin/helpers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from re import sub, compile
2+
from typing import Callable, Optional
23
from urllib.parse import urljoin, urlparse
34

45
from flask import g, request, url_for, flash
6+
from markupsafe import Markup
57
from wtforms.validators import DataRequired, InputRequired
68

79
from flask_admin._compat import iteritems, pass_context

flask_admin/templates/bootstrap4/admin/actions.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,6 @@
2929
{% if actions %}
3030
<div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
3131
<div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
32-
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
32+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
3333
{% endif %}
3434
{% endmacro %}

flask_admin/templates/bootstrap4/admin/base.html

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,18 @@
1313
{% endblock %}
1414
{% block head_css %}
1515
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/swatch/{swatch}/bootstrap.min.css'.format(swatch=theme.swatch), v='4.2.1') }}"
16-
rel="stylesheet">
16+
rel="stylesheet" {{ admin_csp_nonce_attribute }}>
1717
{% if theme.swatch == 'default' %}
18-
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/bootstrap.min.css', v='4.2.1') }}" rel="stylesheet">
18+
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/bootstrap.min.css', v='4.2.1') }}" rel="stylesheet" {{ admin_csp_nonce_attribute }}>
1919
{% endif %}
20-
<link href="{{ admin_static.url(filename='admin/css/bootstrap4/admin.css', v='1.1.1') }}" rel="stylesheet">
21-
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/font-awesome.min.css', v='4.7.0') }}" rel="stylesheet">
20+
<link href="{{ admin_static.url(filename='admin/css/bootstrap4/admin.css', v='1.1.1') }}" rel="stylesheet" {{ admin_csp_nonce_attribute }}>
21+
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/font-awesome.min.css', v='4.7.0') }}" rel="stylesheet" {{ admin_csp_nonce_attribute }}>
2222
{% if admin_view.extra_css %}
2323
{% for css_url in admin_view.extra_css %}
24-
<link href="{{ css_url }}" rel="stylesheet">
24+
<link href="{{ css_url }}" rel="stylesheet" {{ admin_csp_nonce_attribute }}>
2525
{% endfor %}
2626
{% endif %}
27-
<style>
27+
<style {{ admin_csp_nonce_attribute }}>
2828
.hide {
2929
display: none;
3030
}
@@ -77,20 +77,20 @@
7777
{% endblock %}
7878

7979
{% block tail_js %}
80-
<script src="{{ admin_static.url(filename='vendor/jquery.min.js', v='3.5.1') }}" type="text/javascript"></script>
81-
<script src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/popper.min.js') }}" type="text/javascript"></script>
82-
<script src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/bootstrap.min.js', v='4.2.1') }}"
80+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/jquery.min.js', v='3.5.1') }}" type="text/javascript"></script>
81+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/popper.min.js') }}" type="text/javascript"></script>
82+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/bootstrap.min.js', v='4.2.1') }}"
8383
type="text/javascript"></script>
84-
<script src="{{ admin_static.url(filename='vendor/moment.min.js', v='2.9.4') }}" type="text/javascript"></script>
85-
<script src="{{ admin_static.url(filename='vendor/bootstrap4/util.js', v='4.3.1') }}" type="text/javascript"></script>
86-
<script src="{{ admin_static.url(filename='vendor/bootstrap4/dropdown.js', v='4.3.1') }}" type="text/javascript"></script>
87-
<script src="{{ admin_static.url(filename='vendor/select2/select2.min.js', v='4.2.1') }}"
84+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/moment.min.js', v='2.9.4') }}" type="text/javascript"></script>
85+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/bootstrap4/util.js', v='4.3.1') }}" type="text/javascript"></script>
86+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/bootstrap4/dropdown.js', v='4.3.1') }}" type="text/javascript"></script>
87+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/select2/select2.min.js', v='4.2.1') }}"
8888
type="text/javascript"></script>
89-
<script src="{{ admin_static.url(filename='vendor/multi-level-dropdowns-bootstrap/bootstrap4-dropdown-ml-hack.js') }}" type="text/javascript"></script>
90-
<script src="{{ admin_static.url(filename='admin/js/helpers.js', v='1.0.0') }}" type="text/javascript"></script>
89+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/multi-level-dropdowns-bootstrap/bootstrap4-dropdown-ml-hack.js') }}" type="text/javascript"></script>
90+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/helpers.js', v='1.0.0') }}" type="text/javascript"></script>
9191
{% if admin_view.extra_js %}
9292
{% for js_url in admin_view.extra_js %}
93-
<script src="{{ js_url }}" type="text/javascript"></script>
93+
<script {{ admin_csp_nonce_attribute }} src="{{ js_url }}" type="text/javascript"></script>
9494
{% endfor %}
9595
{% endif %}
9696
{% endblock %}

flask_admin/templates/bootstrap4/admin/file/list.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,5 +187,5 @@
187187
{{ actionslib.script(_gettext('Please select at least one file.'),
188188
actions,
189189
actions_confirmation) }}
190-
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
190+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
191191
{% endblock %}

flask_admin/templates/bootstrap4/admin/file/modals/form.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@
1515
{% endblock %}
1616

1717
{% block tail %}
18-
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
18+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
1919
{% endblock %}

flask_admin/templates/bootstrap4/admin/lib.html

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ <h3>{{ text }}</h3>
258258

259259
{% macro form_js() %}
260260
{% if config.FLASK_ADMIN_MAPS %}
261-
<script>
261+
<script {{ admin_csp_nonce_attribute }}>
262262
window.FLASK_ADMIN_MAPS = true;
263263
window.FLASK_ADMIN_MAPBOX_MAP_ID = "{{ config.FLASK_ADMIN_MAPBOX_MAP_ID }}";
264264
{% if config.FLASK_ADMIN_MAPBOX_ACCESS_TOKEN %}
@@ -269,20 +269,20 @@ <h3>{{ text }}</h3>
269269
window.FLASK_ADMIN_DEFAULT_CENTER_LONG = "{{ config.FLASK_ADMIN_DEFAULT_CENTER_LONG }}";
270270
{% endif %}
271271
</script>
272-
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.2') }}"></script>
273-
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
272+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.2') }}"></script>
273+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
274274
{% if config.FLASK_ADMIN_MAPS_SEARCH %}
275-
<script>
275+
<script {{ admin_csp_nonce_attribute }}>
276276
window.FLASK_ADMIN_MAPS_SEARCH = "{{ config.FLASK_ADMIN_MAPS_SEARCH }}";
277277
</script>
278-
<script src="https://maps.googleapis.com/maps/api/js?v=3&libraries=places&key={{ config.get('FLASK_ADMIN_GOOGLE_MAPS_API_KEY') }}"></script>
278+
<script {{ admin_csp_nonce_attribute }} src="https://maps.googleapis.com/maps/api/js?v=3&libraries=places&key={{ config.get('FLASK_ADMIN_GOOGLE_MAPS_API_KEY') }}"></script>
279279
{% endif %}
280280
{% endif %}
281-
<script src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js', v='1.3.22') }}"></script>
281+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js', v='1.3.22') }}"></script>
282282
{% if editable_columns %}
283-
<script src="{{ admin_static.url(filename='vendor/x-editable/js/bootstrap4-editable.min.js', v='1.5.1.1') }}"></script>
283+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/x-editable/js/bootstrap4-editable.min.js', v='1.5.1.1') }}"></script>
284284
{% endif %}
285-
<script src="{{ admin_static.url(filename='admin/js/form.js', v='1.0.1') }}"></script>
285+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/form.js', v='1.0.1') }}"></script>
286286
{% endmacro %}
287287

288288
{% macro extra() %}

flask_admin/templates/bootstrap4/admin/model/details.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,5 @@
4848

4949
{% block tail %}
5050
{{ super() }}
51-
<script src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
51+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
5252
{% endblock %}

flask_admin/templates/bootstrap4/admin/model/list.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@
188188
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
189189
{% endif %}
190190
{{ lib.form_js() }}
191-
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
192-
<script src="{{ admin_static.url(filename='admin/js/bs4_filters.js', v='1.0.0') }}"></script>
191+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
192+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/bs4_filters.js', v='1.0.0') }}"></script>
193193

194194

195195
{{ actionlib.script(_gettext('Please select at least one record.'),

flask_admin/templates/bootstrap4/admin/model/modals/create.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,5 @@
3232
{% endblock %}
3333

3434
{% block tail %}
35-
<script>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
35+
<script {{ admin_csp_nonce_attribute }}>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
3636
{% endblock %}

flask_admin/templates/bootstrap4/admin/model/modals/details.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,6 @@ <h3>{{ _gettext('View Record') + ' #' + request.args.get('id') }}</h3>
3535
{% endblock %}
3636

3737
{% block tail %}
38-
<script src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
39-
<script>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
38+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
39+
<script {{ admin_csp_nonce_attribute }}>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
4040
{% endblock %}

flask_admin/templates/bootstrap4/admin/model/modals/edit.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ <h5 class="modal-title">{{ _gettext('Edit Record') + ' #' + request.args.get('id
2626
{% endblock %}
2727

2828
{% block tail %}
29-
<script>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
29+
<script {{ admin_csp_nonce_attribute }}>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
3030
{% endblock %}

flask_admin/templates/bootstrap4/admin/rediscli/console.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,5 @@
2323
{{ super() }}
2424

2525
<div id="execute-view-data" style="display:none;">{{ admin_view.get_url('.execute_view')|tojson|safe }}</div>
26-
<script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script>
26+
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script>
2727
{% endblock %}

0 commit comments

Comments
 (0)