Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions doc/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,39 @@ SecureForm class in your *ModelView* subclass by specifying the *form_base_class
SecureForm requires WTForms 2 or greater. It uses the WTForms SessionCSRF class
to generate and validate the tokens for you when the forms are submitted.

CSP support
-----------

****

To support `CSP <https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html>`_
in Flask-Admin, you can pass a `csp_nonce_generator` function through to Flask-Admin on
initialisation. This function should return a CSP nonce that will be attached to all
`<script>` and `<style>` resources. You are responsible for making sure that your Flask
responses include an appropriate 'Content-Security-Policy` header that also includes the
same nonce value.

We recommend using `Flask-Talisman <https://pypi.org/project/flask-talisman/>`_. Here's an example
of how to configure Flask-Admin to inject CSP nonce values::

app = Flask(__name__)

talisman = Talisman(
app,
content_security_policy={
"default-src": "'self'",
},
content_security_policy_nonce_in=["script-src", "style-src"]
)
csp_nonce_generator = app.jinja_env.globals["csp_nonce"] # this is talisman's generator function

admin = admin.Admin(app, name="Example", theme=Bootstrap4Theme(), csp_nonce_generator=csp_nonce_generator)

If you decide to use a content security policy, you should pay close attention to the policy you set to
make sure it is appropriate for your project's security needs.

If you create any of your own templates for Flask-Admin pages, you will need to inject the CSP nonces yourself as appropriate.

Adding Custom Javascript and CSS
--------------------------------

Expand Down
22 changes: 22 additions & 0 deletions examples/csp-nonce/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
This example shows how to make Flask-Admin work with a Content-Security-Policy by injecting
a nonce into HTML tags.

To run this example:

1. Clone the repository::

git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin

2. Create and activate a virtual environment::

virtualenv env
source env/bin/activate

3. Install requirements::

pip install -r 'examples/csp-nonce/requirements.txt'

4. Run the application::

python examples/csp-nonce/app.py
Empty file added examples/csp-nonce/__init__.py
Empty file.
39 changes: 39 additions & 0 deletions examples/csp-nonce/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from flask import Flask

import flask_admin as admin
from flask_talisman import Talisman


# Create custom admin view
from flask_admin.theme import Bootstrap4Theme


# Create flask app
app = Flask(__name__, template_folder='templates')
app.debug = True

talisman = Talisman(
app,
content_security_policy={
'default-src': '\'self\'',
'object-src': '\'none\'',
'script-src': '\'self\'',
'style-src': '\'self\'',
},
content_security_policy_nonce_in=['script-src', 'style-src']
)
csp_nonce_generator = app.jinja_env.globals['csp_nonce'] # this is added by talisman

# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'

# Create admin interface
admin = admin.Admin(name="Example: Simple Views", theme=Bootstrap4Theme(), csp_nonce_generator=csp_nonce_generator)
admin.init_app(app)

if __name__ == '__main__':

# Start app
app.run()
4 changes: 4 additions & 0 deletions examples/csp-nonce/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Flask
Flask-Admin

flask-talisman
34 changes: 34 additions & 0 deletions examples/csp-nonce/templates/admin/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{% extends 'admin/master.html' %}

{% block head_tail %}
<style>
.insecure-style {
color: red;
}
</style>
<style {{ admin_csp_nonce_attribute }}>
.secure-style {
color: green;
}
</style>
{% endblock head_tail %}
{% block body %}
{{ super() }}
<div class="container">
<div class="row">
<div class="col-sm-10 col-sm-offset-1">
<h1>Flask-Admin Content-Security-Policy (CSP) example</h1>
<p class="lead">
Simple admin views, not related to models.
</p>
<p class="secure-style">
I have an inline style applied that passes CSP checks because I've injected a nonce value.
</p>
<p class="insecure-style">
But I don't have any styling applied because CSP is protecting me.
</p>
<a class="btn btn-primary" href="/"><i class="glyphicon glyphicon-chevron-left"></i> Back</a>
</div>
</div>
</div>
{% endblock body %}
12 changes: 11 additions & 1 deletion flask_admin/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from flask import current_app, render_template, abort, g, url_for, request
from flask import Blueprint, current_app, render_template, abort, g, url_for
from markupsafe import Markup

from flask_admin import babel
from flask_admin._compat import as_unicode
from flask_admin import helpers as h
Expand Down Expand Up @@ -298,6 +300,9 @@ def render(self, template, **kwargs):
# Store self as admin_view
kwargs['admin_view'] = self
kwargs['admin_base_template'] = self.admin.theme.base_template
kwargs['admin_csp_nonce_attribute'] = (
Markup(f'nonce="{self.admin.csp_nonce_generator()}"') if self.admin.csp_nonce_generator else ''
)

# Provide i18n support even if flask-babel is not installed
# or enabled.
Expand Down Expand Up @@ -477,7 +482,8 @@ def __init__(self, app=None, name=None,
static_url_path=None,
theme: t.Optional[Theme] = None,
category_icon_classes=None,
host=None):
host=None,
csp_nonce_generator: t.Optional[t.Callable] = None):
"""
Constructor.

Expand Down Expand Up @@ -507,6 +513,8 @@ def __init__(self, app=None, name=None,
Example: {'Favorites': 'glyphicon glyphicon-star'}
:param host:
The host to register all admin views on. Mutually exclusive with `subdomain`
:param csp_nonce_generator:
A callable that returns a nonce to inject into Flask-Admin JS, CSS, etc.
"""
self.app = app

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

self._validate_admin_host_and_subdomain()

self.csp_nonce_generator = csp_nonce_generator

# Add index view
self._set_admin_index_view(index_view=index_view, endpoint=endpoint, url=url)

Expand Down
2 changes: 2 additions & 0 deletions flask_admin/helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from re import sub, compile
from typing import Callable, Optional
from urllib.parse import urljoin, urlparse

from flask import g, request, url_for, flash
from markupsafe import Markup
from wtforms.validators import DataRequired, InputRequired

from flask_admin._compat import iteritems, pass_context
Expand Down
2 changes: 1 addition & 1 deletion flask_admin/templates/bootstrap4/admin/actions.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@
{% if actions %}
<div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
<div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
{% endif %}
{% endmacro %}
32 changes: 16 additions & 16 deletions flask_admin/templates/bootstrap4/admin/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@
{% endblock %}
{% block head_css %}
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/swatch/{swatch}/bootstrap.min.css'.format(swatch=theme.swatch), v='4.2.1') }}"
rel="stylesheet">
rel="stylesheet" {{ admin_csp_nonce_attribute }}>
{% if theme.swatch == 'default' %}
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/bootstrap.min.css', v='4.2.1') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/bootstrap.min.css', v='4.2.1') }}" rel="stylesheet" {{ admin_csp_nonce_attribute }}>
{% endif %}
<link href="{{ admin_static.url(filename='admin/css/bootstrap4/admin.css', v='1.1.1') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/font-awesome.min.css', v='4.7.0') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='admin/css/bootstrap4/admin.css', v='1.1.1') }}" rel="stylesheet" {{ admin_csp_nonce_attribute }}>
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/font-awesome.min.css', v='4.7.0') }}" rel="stylesheet" {{ admin_csp_nonce_attribute }}>
{% if admin_view.extra_css %}
{% for css_url in admin_view.extra_css %}
<link href="{{ css_url }}" rel="stylesheet">
<link href="{{ css_url }}" rel="stylesheet" {{ admin_csp_nonce_attribute }}>
{% endfor %}
{% endif %}
<style>
<style {{ admin_csp_nonce_attribute }}>
.hide {
display: none;
}
Expand Down Expand Up @@ -77,20 +77,20 @@
{% endblock %}

{% block tail_js %}
<script src="{{ admin_static.url(filename='vendor/jquery.min.js', v='3.5.1') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/popper.min.js') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/bootstrap.min.js', v='4.2.1') }}"
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/jquery.min.js', v='3.5.1') }}" type="text/javascript"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/popper.min.js') }}" type="text/javascript"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/bootstrap.min.js', v='4.2.1') }}"
type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/moment.min.js', v='2.9.4') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/bootstrap4/util.js', v='4.3.1') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/bootstrap4/dropdown.js', v='4.3.1') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/select2/select2.min.js', v='4.2.1') }}"
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/moment.min.js', v='2.9.4') }}" type="text/javascript"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/bootstrap4/util.js', v='4.3.1') }}" type="text/javascript"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/bootstrap4/dropdown.js', v='4.3.1') }}" type="text/javascript"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/select2/select2.min.js', v='4.2.1') }}"
type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/multi-level-dropdowns-bootstrap/bootstrap4-dropdown-ml-hack.js') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='admin/js/helpers.js', v='1.0.0') }}" type="text/javascript"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/multi-level-dropdowns-bootstrap/bootstrap4-dropdown-ml-hack.js') }}" type="text/javascript"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/helpers.js', v='1.0.0') }}" type="text/javascript"></script>
{% if admin_view.extra_js %}
{% for js_url in admin_view.extra_js %}
<script src="{{ js_url }}" type="text/javascript"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ js_url }}" type="text/javascript"></script>
{% endfor %}
{% endif %}
{% endblock %}
Expand Down
2 changes: 1 addition & 1 deletion flask_admin/templates/bootstrap4/admin/file/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -187,5 +187,5 @@
{{ actionslib.script(_gettext('Please select at least one file.'),
actions,
actions_confirmation) }}
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
{% endblock %}

{% block tail %}
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
{% endblock %}
16 changes: 8 additions & 8 deletions flask_admin/templates/bootstrap4/admin/lib.html
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ <h3>{{ text }}</h3>

{% macro form_js() %}
{% if config.FLASK_ADMIN_MAPS %}
<script>
<script {{ admin_csp_nonce_attribute }}>
window.FLASK_ADMIN_MAPS = true;
window.FLASK_ADMIN_MAPBOX_MAP_ID = "{{ config.FLASK_ADMIN_MAPBOX_MAP_ID }}";
{% if config.FLASK_ADMIN_MAPBOX_ACCESS_TOKEN %}
Expand All @@ -269,20 +269,20 @@ <h3>{{ text }}</h3>
window.FLASK_ADMIN_DEFAULT_CENTER_LONG = "{{ config.FLASK_ADMIN_DEFAULT_CENTER_LONG }}";
{% endif %}
</script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.2') }}"></script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.2') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
{% if config.FLASK_ADMIN_MAPS_SEARCH %}
<script>
<script {{ admin_csp_nonce_attribute }}>
window.FLASK_ADMIN_MAPS_SEARCH = "{{ config.FLASK_ADMIN_MAPS_SEARCH }}";
</script>
<script src="https://maps.googleapis.com/maps/api/js?v=3&libraries=places&key={{ config.get('FLASK_ADMIN_GOOGLE_MAPS_API_KEY') }}"></script>
<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>
{% endif %}
{% endif %}
<script src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js', v='1.3.22') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js', v='1.3.22') }}"></script>
{% if editable_columns %}
<script src="{{ admin_static.url(filename='vendor/x-editable/js/bootstrap4-editable.min.js', v='1.5.1.1') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='vendor/x-editable/js/bootstrap4-editable.min.js', v='1.5.1.1') }}"></script>
{% endif %}
<script src="{{ admin_static.url(filename='admin/js/form.js', v='1.0.1') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/form.js', v='1.0.1') }}"></script>
{% endmacro %}

{% macro extra() %}
Expand Down
2 changes: 1 addition & 1 deletion flask_admin/templates/bootstrap4/admin/model/details.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,5 @@

{% block tail %}
{{ super() }}
<script src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
{% endblock %}
4 changes: 2 additions & 2 deletions flask_admin/templates/bootstrap4/admin/model/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
{% endif %}
{{ lib.form_js() }}
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
<script src="{{ admin_static.url(filename='admin/js/bs4_filters.js', v='1.0.0') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/bs4_filters.js', v='1.0.0') }}"></script>


{{ actionlib.script(_gettext('Please select at least one record.'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@
{% endblock %}

{% block tail %}
<script>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
<script {{ admin_csp_nonce_attribute }}>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@ <h3>{{ _gettext('View Record') + ' #' + request.args.get('id') }}</h3>
{% endblock %}

{% block tail %}
<script src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
<script>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
<script {{ admin_csp_nonce_attribute }} src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
<script {{ admin_csp_nonce_attribute }}>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ <h5 class="modal-title">{{ _gettext('Edit Record') + ' #' + request.args.get('id
{% endblock %}

{% block tail %}
<script>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
<script {{ admin_csp_nonce_attribute }}>window.faForm.applyGlobalStyles(document.getElementsByClassName('modal-content'));</script>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@
{{ super() }}

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