...
 
Commits (3)
Tue 09 Jan 2018 Yasuhiro Asaka <[email protected]>
* Bump version v0.1.0
* Make configuration as separated by tween
Fri 10 Nov 2017 Yasuhiro Asaka <[email protected]>
* Bump version v0.0.4
* Add Python 2.7 support (Make type hints as comment)
......
This diff is collapsed.
......@@ -5,7 +5,7 @@ __all__ = (
'__name__', # pylint: disable=undefined-all-variable
)
__version__ = '0.0.4'
__version__ = '0.1.0'
def includeme(config):
......
import re
from pyramid_secure_response.util import (
logger,
apply_path_filter,
get_config,
)
HEADER_KEY = 'Content-Security-Policy'
# MIME types
# * https://developer.mozilla.org/en-US/docs/Web/HTTP/\
# Basics_of_HTTP/MIME_types/Complete_list_of_MIME_types
# * https://www.iana.org/assignments/media-types/media-types.xhtml
MIME_TYPE_PATTERN = re.compile(r'\A[-\w]+\/[-\w]+\Z')
# SCHEME
# colon at the end is required
SCHEME_VALUES = (
'blob:',
'data:',
'filesystem:',
'http:',
'https:',
'mediastream:',
)
# sandbox
# this directive is not supported in <meta> element and
# 'Content-Security-Policy-Report-Only'.
SANDBOX_VALUES = (
'allow-forms',
'allow-modals',
'allow-orientation-lock',
'allow-pointer-lock',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-presentation',
'allow-same-origin',
'allow-scripts',
'allow-top-navigation',
)
# directives
NO_QUOTE_DIRECTIVES = (
'report-uri', 'report-to',
'plugin-types'
)
BOOLEAN_DIRECTIVES = (
'block-all-mixed-content', 'upgrade-insecure-requests'
)
def _build_csp_header_value(directive, texts):
"""Creates CSP Header values for directive from texts.
Returns empty string if does not valid directive or texts is given.
"""
if texts:
if directive in BOOLEAN_DIRECTIVES:
# bool value
if texts.lower() == 'true':
return '{0:s}'.format(directive)
else:
values = []
for text in texts.split(' '):
# schemes <scheme-source>, mime type <type>/<subtype> and
# sandbox <value> shouldn't be surrounded with single
# quotes
if not text.startswith(SCHEME_VALUES) and \
text not in SANDBOX_VALUES and \
(directive not in NO_QUOTE_DIRECTIVES and
not MIME_TYPE_PATTERN.match(text)) and \
'\'' not in text:
text = "'{:s}'".format(text)
values.append(text)
return '{0:s} {1:s}'.format(directive, ' '.join(values))
return ''
def build_csp_header(config): # type: (Union[namedtuple, dict]) -> str
"""Returns CSP Header values."""
policy_text = ''
config_dict = config
# namedtuple
if isinstance(config, tuple) and hasattr(config, '_asdict'):
config_dict = config._asdict()
if not isinstance(config_dict, dict):
raise ValueError
for name in config_dict:
if name in ('enabled', 'ignore_paths'): # not directive
continue
value = _build_csp_header_value(
name.replace('_', '-'), str(config_dict[name]))
if value:
policy_text += '; {:s}'.format(value)
return policy_text[2:]
def tween(handler, registry):
r"""Sets Content Security Policy Header as configured.
About details of CSP, see below:
* https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/\
Content-Security-Policy
"""
config = get_config(registry)
csp_coverage = config.csp_coverage
ignore_paths = config.ignore_paths
if csp_coverage.ignore_paths:
ignore_paths = csp_coverage.ignore_paths
tween_name = 'csp_coverage'
def _csp_coverage_tween(req):
if not config.csp_coverage.enabled:
return handler(req)
if apply_path_filter(req, ignore_paths):
logger.info('(%s) Ignore path %s', tween_name, req.path)
return handler(req)
res = handler(req)
if HEADER_KEY not in res.headers:
# ignore if already exists
header_value = build_csp_header(csp_coverage)
if header_value:
res.headers[HEADER_KEY] = header_value
return res
return _csp_coverage_tween
......@@ -64,6 +64,44 @@ def get_config(registry): # type: (Registry) -> namedtuple
('preload', True),
), registry=registry)
# Content Security Policy (csp_coverage.xxx)
csp_coverage = _build_config(prefix='csp_coverage', defaults=(
('enabled', True),
('ignore_paths', tuple()),
# NOTE:
# These directives are appended into header as alphabetical
# order by directive sections.
# [fetch]
('child_src', ''), # (deprecated)
('connect_src', ''),
('default_src', ''),
('font_src', ''),
('frame_src', ''),
('img_src', ''),
('manifest_src', ''),
('media_src', ''),
('object_src', ''),
('script_src', ''),
('style_src', ''),
('worker_src', ''),
# [document]
('base_uri', ''),
('plugin_types', ''),
('sandbox', ''),
# [navigation]
('form_action', ''),
('frame_ancestors', ''),
# [reporting]
('report_uri', ''), # (deprecated)
('report_to', ''),
# [other]
('block_all_mixed_content', False),
('referrer', ''), # (obsolete)
('require_sri_for', ''),
('upgrade_insecure_requests', False),
), registry=registry)
# Shared
return _build_config(prefix='', defaults=(
('proto_header', ''), # e.g. X-Forwarded-Proto
......@@ -71,6 +109,7 @@ def get_config(registry): # type: (Registry) -> namedtuple
('ssl_redirect', ssl_redirect),
('hsts_support', hsts_support),
('csp_coverage', csp_coverage),
), registry=registry)
......
import pytest
from pyramid_secure_response.csp_coverage import tween
@pytest.fixture(autouse=True)
def setup():
import logging
from pyramid_secure_response.csp_coverage import logger
logger.setLevel(logging.ERROR)
@pytest.mark.parametrize('directives,header', [
# [fetch directive]
({},
""),
({'child_src': 'self', 'default_src': 'none'},
"child-src 'self'; default-src 'none'"),
({'connect_src': 'self', 'default_src': 'none'},
"connect-src 'self'; default-src 'none'"),
({'default_src': ''},
""),
({'default_src': 'https:'},
"default-src https:"),
({'default_src': "self https://example.org/"},
"default-src 'self' https://example.org/"),
({'default_src': 'none'},
"default-src 'none'"),
({'font_src': 'unsafe-inline'},
"font-src 'unsafe-inline'"),
({'frame_src': 'unsafe-eval'},
"frame-src 'unsafe-eval'"),
({'img_src': 'strict-dynamic'},
"img-src 'strict-dynamic'"),
({'manifest_src': 'nonce-2726c7f26c'},
"manifest-src 'nonce-2726c7f26c'"),
({'media_src': (
'sha256-076c8f1ca6979ef156b510a121b69b626'
'5011597557ca2971db5ad5a2743545f')},
"media-src '{:s}'".format(
'sha256-076c8f1ca6979ef156b510a121b69b626'
'5011597557ca2971db5ad5a2743545f')),
({'object_src': '*'},
"object-src '*'"),
({'script_src': 'https://example.org/'},
"script-src https://example.org/"),
({'style_src': 'self'},
"style-src 'self'"),
({'worker_src': 'self https://example.org/ https://example.com/'},
"worker-src 'self' https://example.org/ https://example.com/"),
# [document directive]
({'base_uri': 'self', 'default_src': 'none'},
"default-src 'none'; base-uri 'self'"),
({'plugin_types': 'application/x-schockwave-flash', 'object_src': ''},
"plugin-types application/x-schockwave-flash"),
({'plugin_types': 'application/xhtml+xml', 'object_src': ''},
"plugin-types application/xhtml+xml"),
({'plugin_types': 'application/vnd.mozilla.xul+xml', 'object_src': ''},
"plugin-types application/vnd.mozilla.xul+xml"),
({'plugin_types': 'video/3gpp2', 'object_src': ''},
"plugin-types video/3gpp2"),
({'sandbox': 'allow-forms'},
"sandbox allow-forms"),
# [navigation directive]
({'form_action': 'self'},
"form-action 'self'"),
({'frame_ancestors': 'self'},
"frame-ancestors 'self'"),
({'default_src': 'https:',
'report_uri': '/csp-violation-report-endpoint/'},
"default-src https:; report-uri /csp-violation-report-endpoint/"),
({'default_src': 'https:',
'report_to': '/csp-violation-report-endpoint/'},
"default-src https:; report-to /csp-violation-report-endpoint/"),
# - block_all_mixed_content
# - referrer
# - require_sri_for
# - upgrade_insecure_requests
])
def test_build_csp_header(directives, header, dummy_request):
from pyramid_secure_response.util import get_config
from pyramid_secure_response.csp_coverage import build_csp_header
dummy_request.url = 'http://example.org/'
settings = {
'pyramid_secure_response.csp_coverage.enabled': 'True',
}
for key, value in directives.items():
settings['pyramid_secure_response.csp_coverage.{:s}'.format(
key)] = value
dummy_request.registry.settings = settings
config = get_config(dummy_request.registry)
# NOTE:
# [fetch]
# - connect_src
# - default_src
# - font_src
# - frame_src
# - img_src
# - manifest_src
# - media_src
# - object_src
# - script_src
# - style_src
# - worker_src
# [document]
# - base_uri
# - plugin_types
# - sandbox
# [navigation]
# - form_action
# - frame_ancestors
# [reporting]
# - report_uri
# - report_to
# [other]
# - block_all_mixed_content
# - referrer
# - require_sri_for
# - upgrade_insecure_requests
assert header == build_csp_header(config.csp_coverage)
def test_build_csp_header_config_argument():
from collections import namedtuple
from pyramid_secure_response.csp_coverage import build_csp_header
with pytest.raises(ValueError):
build_csp_header('not dict or namedtuple')
with pytest.raises(ValueError):
build_csp_header(tuple())
assert '' == build_csp_header({})
# pylint: disable=invalid-name
Config = namedtuple('Config', 'enabled')
assert '' == build_csp_header(Config(True))
def test_csp_coverage_tween_with_disabled(mocker, dummy_request):
mocker.patch('pyramid_secure_response.csp_coverage.apply_path_filter',
return_value=True)
from pyramid.response import Response
from pyramid_secure_response.csp_coverage import (
apply_path_filter,
)
dummy_request.registry.settings = {
'pyramid_secure_response.csp_coverage.enabled': 'False'
}
handler_stub = mocker.stub(name='handler_stub')
handler_stub.return_value = Response(status=200)
csp_coverage_tween = tween(handler_stub, dummy_request.registry)
res = csp_coverage_tween(dummy_request)
# pylint: disable=no-member
assert 1 == handler_stub.call_count
assert 0 == apply_path_filter.call_count
assert 'Content-Security-Policy' not in res.headers
def test_csp_coverage_tween_with_ignored_path(mocker, dummy_request):
mocker.patch('pyramid_secure_response.csp_coverage.apply_path_filter',
return_value=True)
from pyramid.response import Response
from pyramid_secure_response.csp_coverage import (
apply_path_filter,
)
dummy_request.path = '/humans.txt'
dummy_request.registry.settings = {
'pyramid_secure_response.csp_coverage.enabled': 'True',
'pyramid_secure_response.csp_coverage.ignore_paths': '\n/humans.txt\n'
}
handler_stub = mocker.stub(name='handler_stub')
handler_stub.return_value = Response(status=200)
csp_coverage_tween = tween(handler_stub, dummy_request.registry)
res = csp_coverage_tween(dummy_request)
# pylint: disable=no-member
assert 1 == handler_stub.call_count
assert 1 == apply_path_filter.call_count
apply_path_filter.assert_called_once_with(
dummy_request, ('/humans.txt',))
assert 'Content-Security-Policy' not in res.headers
def test_csp_coverage_with_default_values(mocker, dummy_request):
from pyramid_secure_response import csp_coverage
mocker.spy(csp_coverage, 'apply_path_filter')
from pyramid.response import Response
from pyramid_secure_response.csp_coverage import (
apply_path_filter,
)
dummy_request.url = 'http://example.org/'
dummy_request.registry.settings = {
'pyramid_secure_response.csp_coverage.enabled': 'True',
'pyramid_secure_response.csp_coverage.ignore_paths': '\n',
}
handler_stub = mocker.stub(name='handler_stub')
handler_stub.return_value = Response(status=200)
csp_coverage_tween = tween(handler_stub, dummy_request.registry)
res = csp_coverage_tween(dummy_request)
# pylint: disable=no-member
assert 1 == handler_stub.call_count
assert 1 == apply_path_filter.call_count
apply_path_filter.assert_called_once_with(dummy_request, tuple())
# does not set if header is empty
assert 'Content-Security-Policy' not in res.headers
def test_csp_coverage_tween_default_src_with_host_source(
mocker, dummy_request):
from pyramid_secure_response import csp_coverage
mocker.spy(csp_coverage, 'apply_path_filter')
from pyramid.response import Response
from pyramid_secure_response.csp_coverage import (
apply_path_filter,
)
dummy_request.url = 'https://example.org/'
dummy_request.registry.settings = {
'pyramid_secure_response.csp_coverage.enabled': 'True',
'pyramid_secure_response.csp_coverage.ignore_paths': '\n',
'pyramid_secure_response.csp_coverage.default_src':
'https://example.org/',
}
handler_stub = mocker.stub(name='handler_stub')
handler_stub.return_value = Response(status=200)
csp_coverage_tween = tween(handler_stub, dummy_request.registry)
res = csp_coverage_tween(dummy_request)
# pylint: disable=no-member
assert 1 == handler_stub.call_count
assert 1 == apply_path_filter.call_count
apply_path_filter.assert_called_once_with(dummy_request, tuple())
assert 'Content-Security-Policy' in res.headers
assert 'default-src https://example.org/' == \
res.headers['Content-Security-Policy']
def test_csp_coverage_tween_default_src_with_scheme_source(
mocker, dummy_request):
from pyramid_secure_response import csp_coverage
mocker.spy(csp_coverage, 'apply_path_filter')
from pyramid.response import Response
from pyramid_secure_response.csp_coverage import (
apply_path_filter,
)
dummy_request.url = 'https://example.org/'
dummy_request.registry.settings = {
'pyramid_secure_response.csp_coverage.enabled': 'True',
'pyramid_secure_response.csp_coverage.ignore_paths': '\n',
'pyramid_secure_response.csp_coverage.default_src': 'https:',
}
handler_stub = mocker.stub(name='handler_stub')
handler_stub.return_value = Response(status=200)
csp_coverage_tween = tween(handler_stub, dummy_request.registry)
res = csp_coverage_tween(dummy_request)
# pylint: disable=no-member
assert 1 == handler_stub.call_count
assert 1 == apply_path_filter.call_count
apply_path_filter.assert_called_once_with(dummy_request, tuple())
assert 'Content-Security-Policy' in res.headers
assert 'default-src https:' == \
res.headers['Content-Security-Policy']
......@@ -12,8 +12,10 @@ def test_get_config_keys(dummy_request):
expected_keys = (
'proto_header',
'ignore_paths',
# tweens
'ssl_redirect',
'hsts_support',
'csp_coverage',
)
assert expected_keys == tuple(config._asdict().keys())
......