Use flask-talisman for handling backend response headers (#3404)

* Normalize Flask initialization API use.

* Use Flask-Talisman.

* Enable HSTS when HTTPS is enforced.

* More details about how CSP is formatted and write CSP directives as a string.

* Use CSP frame-ancestors directive and not X-Frame-Options for embedable endpoints.

* Add link to flask-talisman docs.

* set remember_token cookie to be HTTP-Only and Secure

* Reorganize secret key configuration to be forward thinking and backward compatible.
This commit is contained in:
Jannis Leidel 2019-03-27 16:24:15 +01:00 committed by Arik Fraimovich
parent 77c53130a4
commit 712fc63f93
20 changed files with 168 additions and 35 deletions

View File

@ -5,7 +5,6 @@ import urllib
import redis
from flask import Flask, current_app
from flask_sslify import SSLify
from werkzeug.contrib.fixers import ProxyFix
from werkzeug.routing import BaseConverter
from statsd import StatsClient
@ -98,11 +97,11 @@ class SlugConverter(BaseConverter):
def create_app():
from redash import authentication, extensions, handlers
from redash import authentication, extensions, handlers, security
from redash.handlers.webpack import configure_webpack
from redash.handlers import chrome_logger
from redash.models import db, users
from redash.metrics.request import provision_app
from redash.metrics import request as request_metrics
from redash.utils import sentry
sentry.init()
@ -116,14 +115,12 @@ def create_app():
app.wsgi_app = ProxyFix(app.wsgi_app, settings.PROXIES_COUNT)
app.url_map.converters['org_slug'] = SlugConverter
if settings.ENFORCE_HTTPS:
SSLify(app, skips=['ping'])
# configure our database
app.config['SQLALCHEMY_DATABASE_URI'] = settings.SQLALCHEMY_DATABASE_URI
app.config.update(settings.all_settings())
provision_app(app)
security.init_app(app)
request_metrics.init_app(app)
db.init_app(app)
migrate.init_app(app, db)
mail.init_app(app)
@ -131,7 +128,7 @@ def create_app():
limiter.init_app(app)
handlers.init_app(app)
configure_webpack(app)
extensions.init_extensions(app)
extensions.init_app(app)
chrome_logger.init_app(app)
users.init_app(app)

View File

@ -244,7 +244,6 @@ def init_app(app):
login_manager.init_app(app)
login_manager.anonymous_user = models.AnonymousUser
app.secret_key = settings.COOKIE_SECRET
app.register_blueprint(google_oauth.blueprint)
app.register_blueprint(saml_auth.blueprint)
app.register_blueprint(remote_user_auth.blueprint)

View File

@ -4,12 +4,11 @@ from flask import render_template
from redash import settings
from redash.tasks import send_mail
from redash.utils import base_url
from redash.models import User
# noinspection PyUnresolvedReferences
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
logger = logging.getLogger(__name__)
serializer = URLSafeTimedSerializer(settings.COOKIE_SECRET)
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)
def invite_token(user):

View File

@ -2,7 +2,7 @@ import os
from pkg_resources import iter_entry_points, resource_isdir, resource_listdir
def init_extensions(app):
def init_app(app):
"""
Load the Redash extensions for the given Redash Flask app.
"""

View File

@ -5,9 +5,11 @@ from redash.handlers.api import api
from redash.handlers.base import routes
from redash.monitor import get_status
from redash.permissions import require_super_admin
from redash.security import talisman
@routes.route('/ping', methods=['GET'])
@talisman(force_https=False)
def ping():
return 'PONG.'

View File

@ -6,10 +6,11 @@ from redash import models, serializers
from redash.handlers.base import (BaseResource, get_object_or_404, paginate,
filter_by_tags,
order_results as _order_results)
from redash.serializers import serialize_dashboard
from redash.permissions import (can_modify, require_admin_or_owner,
require_object_modify_permission,
require_permission)
from redash.security import csp_allows_embeding
from redash.serializers import serialize_dashboard
from sqlalchemy.orm.exc import StaleDataError
@ -235,6 +236,8 @@ class DashboardResource(BaseResource):
class PublicDashboardResource(BaseResource):
decorators = BaseResource.decorators + [csp_allows_embeding]
def get(self, token):
"""
Retrieve a public dashboard.

View File

@ -9,10 +9,12 @@ from redash.handlers import routes
from redash.handlers.base import (get_object_or_404, org_scoped_rule,
record_event)
from redash.handlers.static import render_index
from redash.security import csp_allows_embeding
@routes.route(org_scoped_rule('/embed/query/<query_id>/visualization/<visualization_id>'), methods=['GET'])
@login_required
@csp_allows_embeding
def embed(query_id, visualization_id, org_slug=None):
record_event(current_org, current_user._get_current_object(), {
'action': 'view',
@ -22,12 +24,12 @@ def embed(query_id, visualization_id, org_slug=None):
'embed': True,
'referer': request.headers.get('Referer')
})
return render_index()
@routes.route(org_scoped_rule('/public/dashboards/<token>'), methods=['GET'])
@login_required
@csp_allows_embeding
def public_dashboard(token, org_slug=None):
if current_user.is_api_user():
dashboard = current_user.object

View File

@ -1,7 +1,4 @@
import os
from flask import current_app, render_template, safe_join, send_file
from werkzeug.exceptions import NotFound
from flask import render_template, safe_join, send_file
from flask_login import login_required
from redash import settings

View File

@ -45,7 +45,7 @@ def calculate_metrics_on_exception(error):
calculate_metrics(MockResponse(500, '?', -1))
def provision_app(app):
def init_app(app):
app.before_request(record_requets_start_time)
app.after_request(calculate_metrics)
app.teardown_request(calculate_metrics_on_exception)

View File

@ -74,7 +74,7 @@ class DataSource(BelongsToOrgMixin, db.Model):
name = Column(db.String(255))
type = Column(db.String(255))
options = Column('encrypted_options', ConfigurationContainer.as_mutable(EncryptedConfiguration(db.Text, settings.SECRET_KEY, FernetEngine)))
options = Column('encrypted_options', ConfigurationContainer.as_mutable(EncryptedConfiguration(db.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine)))
queue_name = Column(db.String(255), default="queries")
scheduled_queue_name = Column(db.String(255), default="scheduled_queries")
created_at = Column(db.DateTime(True), default=db.func.now())

43
redash/security.py Normal file
View File

@ -0,0 +1,43 @@
import functools
from flask_talisman import talisman
from redash import settings
talisman = talisman.Talisman()
def csp_allows_embeding(fn):
@functools.wraps(fn)
def decorated(*args, **kwargs):
return fn(*args, **kwargs)
embedable_csp = talisman.content_security_policy + "frame-ancestors *;"
return talisman(
content_security_policy=embedable_csp,
frame_options=None,
)(decorated)
def init_app(app):
talisman.init_app(
app,
feature_policy=settings.FEATURE_POLICY,
force_https=settings.ENFORCE_HTTPS,
force_https_permanent=settings.ENFORCE_HTTPS_PERMANENT,
force_file_save=settings.ENFORCE_FILE_SAVE,
frame_options=settings.FRAME_OPTIONS,
frame_options_allow_from=settings.FRAME_OPTIONS_ALLOW_FROM,
strict_transport_security=settings.HSTS_ENABLED,
strict_transport_security_preload=settings.HSTS_PRELOAD,
strict_transport_security_max_age=settings.HSTS_MAX_AGE,
strict_transport_security_include_subdomains=settings.HSTS_INCLUDE_SUBDOMAINS,
content_security_policy=settings.CONTENT_SECURITY_POLICY,
content_security_policy_report_uri=settings.CONTENT_SECURITY_POLICY_REPORT_URI,
content_security_policy_report_only=settings.CONTENT_SECURITY_POLICY_REPORT_ONLY,
content_security_policy_nonce_in=settings.CONTENT_SECURITY_POLICY_NONCE_IN,
referrer_policy=settings.REFERRER_POLICY,
session_cookie_secure=settings.SESSION_COOKIE_SECURE,
session_cookie_http_only=settings.SESSION_COOKIE_HTTPONLY,
)

View File

@ -1,5 +1,6 @@
import os
from funcy import distinct, remove
from flask_talisman import talisman
from .helpers import fix_assets_path, array_from_string, parse_boolean, int_or_none, set_from_string
from .organization import DATE_FORMAT
@ -15,7 +16,6 @@ def all_settings():
return settings
REDIS_URL = os.environ.get('REDASH_REDIS_URL', os.environ.get('REDIS_URL', "redis://localhost:6379/0"))
PROXIES_COUNT = int(os.environ.get('REDASH_PROXIES_COUNT', "1"))
@ -50,9 +50,86 @@ SCHEMAS_REFRESH_SCHEDULE = int(os.environ.get("REDASH_SCHEMAS_REFRESH_SCHEDULE",
SCHEMAS_REFRESH_QUEUE = os.environ.get("REDASH_SCHEMAS_REFRESH_QUEUE", "celery")
AUTH_TYPE = os.environ.get("REDASH_AUTH_TYPE", "api_key")
ENFORCE_HTTPS = parse_boolean(os.environ.get("REDASH_ENFORCE_HTTPS", "false"))
INVITATION_TOKEN_MAX_AGE = int(os.environ.get("REDASH_INVITATION_TOKEN_MAX_AGE", 60 * 60 * 24 * 7))
# The secret key to use in the Flask app for various cryptographic features
SECRET_KEY = os.environ.get("REDASH_COOKIE_SECRET", "c292a0a3aa32397cdb050e233733900f")
# The secret key to use when encrypting data source options
DATASOURCE_SECRET_KEY = os.environ.get('REDASH_SECRET_KEY', SECRET_KEY)
# Whether and how to redirect non-HTTP requests to HTTPS. Disabled by default.
ENFORCE_HTTPS = parse_boolean(os.environ.get("REDASH_ENFORCE_HTTPS", "false"))
ENFORCE_HTTPS_PERMANENT = parse_boolean(
os.environ.get("REDASH_ENFORCE_HTTPS_PERMANENT", "false"))
# Whether file downloads are enforced or not.
ENFORCE_FILE_SAVE = parse_boolean(
os.environ.get("REDASH_ENFORCE_FILE_SAVE", "true"))
# Whether to use secure cookies by default.
COOKIES_SECURE = parse_boolean(
os.environ.get("REDASH_COOKIES_SECURE", str(ENFORCE_HTTPS)))
# Whether the session cookie is set to secure.
SESSION_COOKIE_SECURE = parse_boolean(
os.environ.get("REDASH_SESSION_COOKIE_SECURE") or str(COOKIES_SECURE))
# Whether the session cookie is set HttpOnly.
SESSION_COOKIE_HTTPONLY = parse_boolean(
os.environ.get("REDASH_SESSION_COOKIE_HTTPONLY", "true"))
# Whether the session cookie is set to secure.
REMEMBER_COOKIE_SECURE = parse_boolean(
os.environ.get("REDASH_REMEMBER_COOKIE_SECURE") or str(COOKIES_SECURE))
# Whether the remember cookie is set HttpOnly.
REMEMBER_COOKIE_HTTPONLY = parse_boolean(
os.environ.get("REDASH_REMEMBER_COOKIE_HTTPONLY", "true"))
# Doesn't set X-Frame-Options by default since it's highly dependent
# on the specific deployment.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
# for more information.
FRAME_OPTIONS = os.environ.get("REDASH_FRAME_OPTIONS", "deny")
FRAME_OPTIONS_ALLOW_FROM = os.environ.get(
"REDASH_FRAME_OPTIONS_ALLOW_FROM", "")
# Whether and how to send Strict-Transport-Security response headers.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
# for more information.
HSTS_ENABLED = parse_boolean(
os.environ.get("REDASH_HSTS_ENABLED") or str(ENFORCE_HTTPS))
HSTS_PRELOAD = parse_boolean(os.environ.get("REDASH_HSTS_PRELOAD", "false"))
HSTS_MAX_AGE = int(
os.environ.get("REDASH_HSTS_MAX_AGE", talisman.ONE_YEAR_IN_SECS))
HSTS_INCLUDE_SUBDOMAINS = parse_boolean(
os.environ.get("REDASH_HSTS_INCLUDE_SUBDOMAINS", "false"))
# Whether and how to send Content-Security-Policy response headers.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
# for more information.
# Overriding this value via an environment variables requires setting it
# as a string in the general CSP format of a semicolon separated list of
# individual CSP directives, see https://github.com/GoogleCloudPlatform/flask-talisman#example-7
# for more information. E.g.:
CONTENT_SECURITY_POLICY = os.environ.get(
"REDASH_CONTENT_SECURITY_POLICY",
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; font-src 'self' data:; img-src 'self' http: https: data:; object-src 'none'; frame-ancestors 'none';"
)
CONTENT_SECURITY_POLICY_REPORT_URI = os.environ.get(
"REDASH_CONTENT_SECURITY_POLICY_REPORT_URI", "")
CONTENT_SECURITY_POLICY_REPORT_ONLY = parse_boolean(
os.environ.get("REDASH_CONTENT_SECURITY_POLICY_REPORT_ONLY", "false"))
CONTENT_SECURITY_POLICY_NONCE_IN = array_from_string(
os.environ.get("REDASH_CONTENT_SECURITY_POLICY_NONCE_IN", ""))
# Whether and how to send Referrer-Policy response headers. Defaults to
# 'strict-origin-when-cross-origin'.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
# for more information.
REFERRER_POLICY = os.environ.get(
"REDASH_REFERRER_POLICY", "strict-origin-when-cross-origin")
# Whether and how to send Feature-Policy response headers. Defaults to
# an empty value.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy
# for more information.
FEATURE_POLICY = os.environ.get("REDASH_REFERRER_POLICY", "")
MULTI_ORG = parse_boolean(os.environ.get("REDASH_MULTI_ORG", "false"))
GOOGLE_CLIENT_ID = os.environ.get("REDASH_GOOGLE_CLIENT_ID", "")
@ -111,9 +188,6 @@ LDAP_SEARCH_DN = os.environ.get('REDASH_LDAP_SEARCH_DN', os.environ.get('REDASH_
STATIC_ASSETS_PATH = fix_assets_path(os.environ.get("REDASH_STATIC_ASSETS_PATH", "../client/dist/"))
JOB_EXPIRY_TIME = int(os.environ.get("REDASH_JOB_EXPIRY_TIME", 3600 * 12))
COOKIE_SECRET = os.environ.get("REDASH_COOKIE_SECRET", "c292a0a3aa32397cdb050e233733900f")
SESSION_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_SECURE") or str(ENFORCE_HTTPS))
SECRET_KEY = os.environ.get('REDASH_SECRET_KEY', COOKIE_SECRET)
LOG_LEVEL = os.environ.get("REDASH_LOG_LEVEL", "INFO")
LOG_STDOUT = parse_boolean(os.environ.get('REDASH_LOG_STDOUT', 'false'))

View File

@ -15,7 +15,7 @@ requests-oauthlib>=0.6.2,<1.2.0
Flask-SQLAlchemy==2.3.2
Flask-Migrate==2.0.1
flask-mail==0.9.1
flask-sslify==0.1.5
flask-talisman==0.6.0
Flask-Limiter==0.9.3
passlib==1.6.2
aniso8601==1.1.0

View File

@ -3,10 +3,12 @@
FLAG="/var/log/generate_secrets.log"
if [ ! -f $FLAG ]; then
COOKIE_SECRET=$(pwgen -1s 32)
SECRET_KEY=$(pwgen -1s 32)
POSTGRES_PASSWORD=$(pwgen -1s 32)
REDASH_DATABASE_URL="postgresql:\/\/postgres:$POSTGRES_PASSWORD@postgres\/postgres"
sed -i "s/REDASH_COOKIE_SECRET=.*/REDASH_COOKIE_SECRET=$COOKIE_SECRET/g" /opt/redash/env
sed -i "s/REDASH_SECRET_KEY=.*/REDASH_SECRET_KEY=$SECRET_KEY/g" /opt/redash/env
sed -i "s/POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$POSTGRES_PASSWORD/g" /opt/redash/env
sed -i "s/REDASH_DATABASE_URL=.*/REDASH_DATABASE_URL=$REDASH_DATABASE_URL/g" /opt/redash/env

View File

@ -6,7 +6,7 @@ REDASH_BASE_PATH=/opt/redash
install_docker(){
# Install Docker
sudo apt-get update
sudo apt-get update
sudo apt-get -yy install apt-transport-https ca-certificates curl software-properties-common wget pwgen
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
@ -38,6 +38,7 @@ create_config() {
fi
COOKIE_SECRET=$(pwgen -1s 32)
SECRET_KEY=$(pwgen -1s 32)
POSTGRES_PASSWORD=$(pwgen -1s 32)
REDASH_DATABASE_URL="postgresql://postgres:${POSTGRES_PASSWORD}@postgres/postgres"
@ -46,6 +47,7 @@ create_config() {
echo "REDASH_REDIS_URL=redis://redis:6379/0" >> $REDASH_BASE_PATH/env
echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> $REDASH_BASE_PATH/env
echo "REDASH_COOKIE_SECRET=$COOKIE_SECRET" >> $REDASH_BASE_PATH/env
echo "REDASH_SECRET_KEY=$SECRET_KEY" >> $REDASH_BASE_PATH/env
echo "REDASH_DATABASE_URL=$REDASH_DATABASE_URL" >> $REDASH_BASE_PATH/env
}

View File

@ -1,7 +1,7 @@
from funcy import pairwise
from tests import BaseTestCase
from redash.models import DataSource, Query
from redash.models import DataSource
class TestDataSourceGetSchema(BaseTestCase):

View File

@ -2,6 +2,15 @@ from tests import BaseTestCase
from redash.models import db
class TestUnembedables(BaseTestCase):
def test_not_embedable(self):
query = self.factory.create_query()
res = self.make_request('get', '/api/queries/{0}'.format(query.id))
self.assertEquals(res.status_code, 200)
self.assertIn("frame-ancestors 'none'", res.headers['Content-Security-Policy'])
self.assertEqual(res.headers['X-Frame-Options'], 'deny')
class TestEmbedVisualization(BaseTestCase):
def test_sucesss(self):
vis = self.factory.create_visualization()
@ -10,6 +19,8 @@ class TestEmbedVisualization(BaseTestCase):
res = self.make_request("get", "/embed/query/{}/visualization/{}".format(vis.query_rel.id, vis.id), is_json=False)
self.assertEqual(res.status_code, 200)
self.assertIn('frame-ancestors *', res.headers['Content-Security-Policy'])
self.assertNotIn("X-Frame-Options", res.headers)
# TODO: bring back?
# def test_parameters_on_embeds(self):
@ -49,6 +60,8 @@ class TestPublicDashboard(BaseTestCase):
res = self.make_request('get', '/public/dashboards/{}'.format(api_key.api_key), user=False, is_json=False)
self.assertEqual(res.status_code, 200)
self.assertIn('frame-ancestors *', res.headers['Content-Security-Policy'])
self.assertNotIn("X-Frame-Options", res.headers)
def test_works_for_logged_in_user(self):
dashboard = self.factory.create_dashboard()
@ -72,6 +85,7 @@ class TestPublicDashboard(BaseTestCase):
# def test_token_doesnt_belong_to_dashboard(self):
# pass
class TestAPIPublicDashboard(BaseTestCase):
def test_success(self):
dashboard = self.factory.create_dashboard()
@ -79,6 +93,8 @@ class TestAPIPublicDashboard(BaseTestCase):
res = self.make_request('get', '/api/dashboards/public/{}'.format(api_key.api_key), user=False, is_json=False)
self.assertEqual(res.status_code, 200)
self.assertIn('frame-ancestors *', res.headers['Content-Security-Policy'])
self.assertNotIn("X-Frame-Options", res.headers)
def test_works_for_logged_in_user(self):
dashboard = self.factory.create_dashboard()

View File

@ -1,6 +1,5 @@
from tests import BaseTestCase
from redash import models
from redash.models import db
class TestQueryFavoriteResource(BaseTestCase):
def test_favorite(self):

View File

@ -19,8 +19,7 @@ class TestQueryResourceGet(BaseTestCase):
self.assertResponseEqual(expected, rv.json)
def test_get_all_queries(self):
queries = [self.factory.create_query() for _ in range(10)]
[self.factory.create_query() for _ in range(10)]
rv = self.make_request('get', '/api/queries')
self.assertEquals(rv.status_code, 200)

View File

@ -13,14 +13,14 @@ class TestOrganizationSettings(BaseTestCase):
updated_org = Organization.get_by_slug(self.factory.org.slug)
self.assertEqual(rv.json['settings']['auth_password_login_enabled'], True)
self.assertEqual(updated_org.settings['settings']['auth_password_login_enabled'], True)
def test_updates_google_apps_domains(self):
admin = self.factory.create_admin()
domains = ['example.com']
rv = self.make_request('post', '/api/settings/organization', data={'auth_google_apps_domains': domains}, user=admin)
updated_org = Organization.get_by_slug(self.factory.org.slug)
self.assertEqual(updated_org.google_apps_domains, domains)
def test_get_returns_google_appas_domains(self):
admin = self.factory.create_admin()
domains = ['example.com']
@ -28,4 +28,3 @@ class TestOrganizationSettings(BaseTestCase):
rv = self.make_request('get', '/api/settings/organization', user=admin)
self.assertEqual(rv.json['settings']['auth_google_apps_domains'], domains)