├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── flask_secure_headers ├── __init__.py ├── core.py ├── headers.py └── tests │ ├── __init__.py │ ├── core_test.py │ └── headers_test.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.pyc 3 | *.log 4 | .*.swo 5 | *.egg-info 6 | build 7 | dist 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Tristan Waldear 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include flask_secure_headers * 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flask-secure-headers 2 | Secure Header Wrapper for Flask Applications. This is intended to be a simplified version of the [Twitter SecureHeaders Ruby Gem](https://github.com/twitter/secureheaders) 3 | 4 | ## Installation 5 | Install the extension with using pip, or easy_install. [Pypi Link](https://pypi.python.org/pypi/flask-secure-headers) 6 | ```bash 7 | $ pip install flask-secure-headers 8 | ``` 9 | 10 | ## Included Headers 11 | Header | Purpose | Default Policy 12 | --- | --- | --- 13 | [Content-Security-Policy (CSP)](http://www.w3.org/TR/CSP2/) | Restrict rescources to prevent XSS/other attacks | *default-src 'self'; report-uri /csp_report* 14 | [Strict-Transport-Security (HSTS)](https://tools.ietf.org/html/rfc6797) | Prevent downgrade attacks (https to http) | *max-age=31536000; includeSubDomains* 15 | [X-Permitted-Cross-Domain-Policies](https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html) | Restrict content loaded by flash | *master-only* 16 | [X-Frame-Options](https://tools.ietf.org/html/draft-ietf-websec-x-frame-options-02) | Prevent content from being framed and clickjacked | *sameorigin* 17 | [X-XSS-Protection](http://msdn.microsoft.com/en-us/library/dd565647(v=vs.85).aspx) | IE 8+ XSS protection header | *1; mode=block* 18 | [X-Content-Type-Options](http://msdn.microsoft.com/en-us/library/ie/gg622941(v=vs.85).aspx) | IE 9+ MIME-type verification | *nosniff* 19 | [X-Download-Options](http://msdn.microsoft.com/en-us/library/ie/jj542450(v=vs.85).aspx) | IE 10+ Prevent downloads from opening | *noopen* 20 | [Public-Key-Pins (HPKP)]() | Associate host with expected CA or public key | *max-age=5184000; includeSubDomains; report-uri=/hpkp_report [... no default pins]* 21 | 22 | 23 | ## Usage 24 | 25 | Each header policy is represented by a dict of paramaters. [View default policies](/flask_secure_headers/core.py). 26 | * Policies with a key/value pair are represented as {key:value} 27 | * Ex: *{'mode':'block'}* becomes *'mode=block'* 28 | * Policies with just a string value are represented as {'value':parameter} 29 | * Ex: *{'value':'noopen'}* becomes *'noopen'* 30 | * Policies with additional string values are represented as {value:Bool} 31 | * Ex: *{'max-age':1,'includeSubDomains':True,'preload':False}* becomes *'max-age=1 includeSubDomains'* 32 | * CSP is represented as a list inside the dict {cspPolicy:[param,param]}. 33 | * Ex: *{'script-src':['self']}* becomes *"script-src 'self'"* 34 | * self, none, nonce-* ,sha*, unsafe-inline, etc are automatically encapsulated 35 | * HPKP pins are represented by a list of dicts under the 'pins' paramter {'pins':[{hashType:hash}]} 36 | * Ex: {'pins':[{'sha256':'1234'},{'sha256':'ABCD'}]} becomes 'pin-sha256=1234; pin-sha256=ABCD' 37 | 38 | ### Configuration 39 | 40 | ##### Import 41 | To load the headers into your flask app, import the function: 42 | ```python 43 | from flask_secure_headers.core import Secure_Headers 44 | ... 45 | sh = Secure_Headers() 46 | ``` 47 | 48 | ##### Policy Changes 49 | There are two methods to change the default policies that will persist throughout the application: update(), rewrite() 50 | * Update will add to an existing policy 51 | * Rewrite will replace a policy 52 | 53 | To update/rewrite, pass a dict in of the desired values into the desired method: 54 | ```python 55 | """ update """ 56 | sh.update({'CSP':{'script-src':['self','code.jquery.com']}}) 57 | # Content-Security-Policy: script-src 'self' code.jquery.com; report-uri /csp_report; default-src 'self 58 | sh.update( 59 | {'X_Permitted_Cross_Domain_Policies':{'value':'all'}}, 60 | {'HPKP':{'pins':[{'sha256':'1234'}]}} 61 | ) 62 | # X-Permitted-Cross-Domain-Policies: all 63 | # Public-Key-Pins: max-age=5184000; includeSubDomains; report-uri=/hpkp_report; pin-sha256=1234 64 | 65 | """ rewrite """ 66 | sh.rewrite({'CSP':{'default-src':['none']}}) 67 | # Content-Security-Policy: default-src 'none' 68 | ``` 69 | 70 | ##### Policy Removal 71 | A policy can also be removed by passing None as the value: 72 | ```python 73 | sh.rewrite({'CSP':None}) 74 | # there will be no CSP header 75 | ``` 76 | 77 | ##### Policy parameter removal 78 | For non-CSP headers that contain multiple paramaters (HSTS and X-XSS-Protection), any paramter other than the first can be removed by passing a value of False: 79 | ```python 80 | sh.update({'X-XSS-Protection':{'value':1,'mode':False}}) 81 | # will produce X-XSS-Protection: 1 82 | 83 | sh.update({'HSTS':{'max-age':1,'includeSubDomains':True,'preload':False}}) 84 | # will produce Strict-Transport-Security: max-age=1; includeSubDomains 85 | ``` 86 | 87 | ##### Read Only 88 | The HPKP and CSP Headers can be set to "-Read-Only" by passing "'read-only':True" into the policy dict. Examples: 89 | ```python 90 | sh.update({'CSP':{'script-src':['self','code.jquery.com']},'read-only':True}) 91 | sh.update({'HPKP':{'pins':[{'sha256':'1234'}]},'read-only':True}) 92 | ``` 93 | 94 | ##### Notes 95 | * Header keys can be written using either '_' or '-', but are case sensitive 96 | * Acceptable: 'X-XSS-Protection','X_XSS_Protection' 97 | * Unacceptable: 'x-xss-protection' 98 | * 3 headers are abreviated 99 | * CSP = Content-Security-Policy 100 | * HSTS = Strict-Transport-Security 101 | * HPKP = Public-Key-Pins 102 | 103 | ### Creating the Wrapper 104 | 105 | ##### No Policy Updates 106 | Add the @sh.wrapper() decorator after your app.route(...) decorators for each route to create the headers based on the policy you have created using the update/remove methods (or the default policy if those were not used) 107 | ```python 108 | @app.route('/') 109 | @sh.wrapper() 110 | def index(): 111 | ... 112 | ``` 113 | 114 | ##### With Policy Updates 115 | 116 | The wrapper() method can also be passed a dict in the same format as update/remove to change policies. These policy changes will only effect that specific route. 117 | 118 | A couple notes: 119 | * Changes here will always update the policy instead of rewrite 120 | * CSP policy and HPKP pin lists will be merged, not overwritten. See comment below for example. 121 | ```python 122 | @app.route('/') 123 | @sh.wrapper({ 124 | 'CSP':{'script-src':['sha1-klsdjfkl232']}, 125 | 'HPKP':{'pins':[{'sha256':'ABCD'}]} 126 | }) 127 | def index(): 128 | ... 129 | # CSP will contain "script-src 'self' 'sha1-klsdjfkl232'" 130 | # HPKP will contain "pins-sha256=1234; pins-sha256=ABCD;" 131 | ``` 132 | 133 | Policies can also be removed from a wrapper: 134 | ```python 135 | @app.route('/') 136 | @sh.wrapper({'CSP':None,'X-XSS-Protection':None}) 137 | def index(): 138 | ... 139 | # this route will not include Content-Security-Policy or X-XSS-Protection Headers 140 | ``` 141 | 142 | ## Contact 143 | * Author: [@twaldear](https://github.com/twaldear) 144 | * Idea/Review: [@gaurabb](https://github.com/gaurabb) 145 | * Contributors: [@houqp](https://github.com/houqp) 146 | -------------------------------------------------------------------------------- /flask_secure_headers/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | -------------------------------------------------------------------------------- /flask_secure_headers/core.py: -------------------------------------------------------------------------------- 1 | from flask import make_response 2 | from functools import wraps 3 | from headers import * 4 | 5 | class Secure_Headers: 6 | def __init__(self): 7 | """ default policies for secure headers """ 8 | self.defaultPolicies = { 9 | 'CSP':{ 10 | 'default-src':['self'], 11 | 'script-src':[], 12 | 'img-src':[], 13 | 'object-src':[], 14 | 'plugin-src':[], 15 | 'style-src':[], 16 | 'media-src':[], 17 | 'child-src':[], 18 | 'connect-src':[], 19 | 'base-uri':[], 20 | 'font-src':[], 21 | 'form-action':[], 22 | 'frame-ancestors':[], 23 | 'plugin-types':[], 24 | 'referrer':[], 25 | 'reflected-xss':[], 26 | 'sandbox':[], 27 | 'report-uri':['/csp_report'], 28 | }, 29 | 'HSTS':{ 30 | 'max-age':31536000, 31 | 'includeSubDomains':True, 32 | 'preload':False 33 | }, 34 | 'HPKP':{ 35 | 'max-age':5184000, 36 | 'includeSubDomains':True, 37 | 'report-uri':'/hpkp_report', 38 | 'pins':[], 39 | }, 40 | 'X_Frame_Options':{ 41 | 'value':'sameorigin' 42 | }, 43 | 'X_XSS_Protection':{ 44 | 'value':1, 45 | 'mode':'block' 46 | }, 47 | 'X_Content_Type_Options':{ 48 | 'value':'nosniff' 49 | }, 50 | 'X_Download_Options':{ 51 | 'value':'noopen' 52 | }, 53 | 'X_Permitted_Cross_Domain_Policies':{ 54 | 'value':'none' 55 | }, 56 | 57 | } 58 | 59 | def _getHeaders(self, updateParams=None): 60 | """ create headers list for flask wrapper """ 61 | if not updateParams: 62 | updateParams = {} 63 | policies = self.defaultPolicies 64 | if len(updateParams) > 0: 65 | for k,v in updateParams.items(): 66 | k = k.replace('-','_') 67 | c = globals()[k](v) 68 | try: 69 | policies[k] = c.update_policy(self.defaultPolicies[k]) 70 | except Exception, e: 71 | raise 72 | 73 | return [globals()[k](v).create_header() 74 | for k,v in policies.items() if v is not None] 75 | 76 | def _setRespHeader(self, resp, headers): 77 | for hdr in headers: 78 | for k,v in hdr.items(): 79 | resp.headers[k] = v 80 | 81 | def policyChange(self, updateParams, func): 82 | """ update defaultPolicy dict """ 83 | for k,v in updateParams.items(): 84 | k = k.replace('-','_') 85 | c = globals()[k](v) 86 | try: 87 | self.defaultPolicies[k] = getattr(c,func)(self.defaultPolicies[k]) 88 | except Exception, e: 89 | raise 90 | 91 | def update(self, updateParams): 92 | """ add changes to existing policy """ 93 | self.policyChange(updateParams,'update_policy') 94 | 95 | def rewrite(self, rewriteParams): 96 | """ rewrite existing policy to changes """ 97 | self.policyChange(rewriteParams,'rewrite_policy') 98 | 99 | def wrapper(self, updateParams=None): 100 | """ create wrapper for flask app route """ 101 | def decorator(f): 102 | _headers = self._getHeaders(updateParams) 103 | """ flask decorator to include headers """ 104 | @wraps(f) 105 | def decorated_function(*args, **kwargs): 106 | resp = make_response(f(*args, **kwargs)) 107 | self._setRespHeader(resp, _headers) 108 | resp.has_secure_headers = True 109 | return resp 110 | return decorated_function 111 | return decorator 112 | 113 | def init_app(self, app, updateParams=None): 114 | _headers = self._getHeaders(updateParams) 115 | def add_sec_hdr(resp): 116 | if not hasattr(resp, 'has_secure_headers'): 117 | self._setRespHeader(resp, _headers) 118 | return resp 119 | app.after_request(add_sec_hdr) 120 | -------------------------------------------------------------------------------- /flask_secure_headers/headers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | class Simple_Header: 4 | """ base class for all headers except CSP """ 5 | def check_valid(self): 6 | """ check if input is valid """ 7 | for k,input in self.inputs.items(): 8 | if k in self.valid_opts: 9 | for param in self.valid_opts[k]: 10 | if param is None or input is None: 11 | return True 12 | elif type(param) is str and '+' in param: 13 | if re.search(r'^'+param,str(input)): 14 | return True 15 | elif type(param) is bool and type(input) is bool: 16 | return True 17 | elif type(param) is list and type(input) is list: 18 | return True 19 | else: 20 | if str(input).lower() == str(param): 21 | return True 22 | raise ValueError("Invalid input for '%s' parameter. Options are: %s" % (k,' '.join(["'%s'," % str(o) for o in self.valid_opts[k]]) )) 23 | else: 24 | raise ValueError("Invalid parameter for '%s'. Params are: %s" % (self.__class__.__name__,', '.join(["'%s'" % p for p in self.valid_opts.keys()]) )) 25 | 26 | def update_policy(self,defaultHeaders): 27 | """ if policy in default but not input still return """ 28 | if self.inputs is not None: 29 | for k,v in defaultHeaders.items(): 30 | if k not in self.inputs: 31 | self.inputs[k] = v 32 | return self.inputs 33 | else: 34 | return self.inputs 35 | 36 | def rewrite_policy(self,defaultHeaders): 37 | """ return submitted policy """ 38 | return self.inputs 39 | 40 | def create_header(self): 41 | """ return header dict """ 42 | try: 43 | self.check_valid() 44 | _header_list = [] 45 | for k,v in self.inputs.items(): 46 | if v is None: 47 | return {self.__class__.__name__.replace('_','-'):None} 48 | elif k == 'value': 49 | _header_list.insert(0,str(v)) 50 | elif isinstance(v,bool): 51 | if v is True: 52 | _header_list.append(k) 53 | else: 54 | _header_list.append('%s=%s' % (k,str(v))) 55 | return {self.__class__.__name__.replace('_','-'):'; '.join(_header_list)} 56 | except Exception, e: 57 | raise 58 | 59 | class X_Frame_Options(Simple_Header): 60 | """ X_Frame_Options """ 61 | def __init__(self,inputs,overide=None): 62 | self.valid_opts = {'value':['deny','sameorigin','allow-from .+']} 63 | self.inputs = inputs 64 | 65 | 66 | class X_Content_Type_Options(Simple_Header): 67 | """ X_Content_Type_Options """ 68 | def __init__(self,inputs,overide=None): 69 | self.valid_opts = {'value':['nosniff']} 70 | self.inputs = inputs 71 | 72 | 73 | class X_Download_Options(Simple_Header): 74 | """ X_Download_Options """ 75 | def __init__(self,inputs,overide=None): 76 | self.valid_opts = {'value':['noopen']} 77 | self.inputs = inputs 78 | 79 | class X_Permitted_Cross_Domain_Policies(Simple_Header): 80 | """ X_Permitted_Cross_Domain_Policies """ 81 | def __init__(self,inputs,overide=None): 82 | self.valid_opts = {'value':['all', 'none', 'master-only', 'by-content-type', 'by-ftp-filename']} 83 | self.inputs = inputs 84 | 85 | class X_XSS_Protection(Simple_Header): 86 | """ X_XSS_Protection """ 87 | def __init__(self,inputs,overide=None): 88 | self.valid_opts = {'value':[0,1],'mode':['block',False]} 89 | self.inputs = inputs 90 | 91 | class HSTS(Simple_Header): 92 | """ HSTS """ 93 | def __init__(self,inputs,overide=None): 94 | self.valid_opts = {'max-age':['[0-9]+'],'includeSubDomains':[True,False],'preload':[True,False]} 95 | self.inputs = inputs 96 | self.__class__.__name__ = 'Strict-Transport-Security' 97 | 98 | class HPKP(Simple_Header): 99 | """ HPKP """ 100 | def __init__(self,inputs,overide=None): 101 | self.valid_opts = {'max-age':['[0-9]+'],'includeSubDomains':[True,False],'report-uri':['*'],'pins':[[]]} 102 | self.inputs = inputs 103 | self.__class__.__name__ = 'Public-Key-Pins' 104 | if self.inputs is not None and 'report-only' in self.inputs: 105 | if self.inputs['report-only'] is True: 106 | self.__class__.__name__ += '-Report-Only' 107 | del self.inputs['report-only'] 108 | def update_policy(self,defaultHeaders): 109 | """ rewrite update policy so that additional pins are added and not overwritten """ 110 | if self.inputs is not None: 111 | for k,v in defaultHeaders.items(): 112 | if k not in self.inputs: 113 | self.inputs[k] = v 114 | if k == 'pins': 115 | self.inputs[k] = self.inputs[k] + defaultHeaders[k] 116 | return self.inputs 117 | else: 118 | return self.inputs 119 | 120 | def create_header(self): 121 | """ rewrite return header dict for HPKP """ 122 | try: 123 | self.check_valid() 124 | _header_list = [] 125 | for k,v in self.inputs.items(): 126 | if v is None: 127 | return {self.__class__.__name__.replace('_','-'):None} 128 | elif k == 'value': 129 | _header_list.insert(0,str(v)) 130 | elif isinstance(v,bool): 131 | if v is True: _header_list.append(k) 132 | elif type(v) is list: 133 | lambda v: len(v)>0, [_header_list.append(''.join(['pin-%s=%s' % (pink,pinv) for pink, pinv in pin.items()])) for pin in v] 134 | else: 135 | _header_list.append('%s=%s' % (k,str(v))) 136 | return {self.__class__.__name__.replace('_','-'):'; '.join(_header_list)} 137 | except Exception, e: 138 | raise 139 | 140 | class CSP: 141 | def __init__(self, inputs): 142 | self.inputs = inputs 143 | self.header = 'Content-Security-Policy' 144 | if self.inputs is not None and 'report-only' in self.inputs: 145 | if self.inputs['report-only'] is True: 146 | self.header += '-Report-Only' 147 | del self.inputs['report-only'] 148 | 149 | def check_valid(self,cspDefaultHeaders): 150 | if self.inputs is not None: 151 | for p,l in self.inputs.items(): 152 | if p not in cspDefaultHeaders.keys() and p is not 'rewrite': 153 | raise ValueError("Invalid parameter '%s'. Params are: %s" % (p,', '.join(["'%s'" % p for p in cspDefaultHeaders.keys()]) )) 154 | 155 | def update_policy(self,cspDefaultHeaders): 156 | """ add items to existing csp policies """ 157 | try: 158 | self.check_valid(cspDefaultHeaders) 159 | if self.inputs is not None: 160 | for p,l in self.inputs.items(): 161 | cspDefaultHeaders[p] = cspDefaultHeaders[p]+ list(set(self.inputs[p]) - set(cspDefaultHeaders[p])) 162 | return cspDefaultHeaders 163 | else: 164 | return self.inputs 165 | except Exception, e: 166 | raise 167 | 168 | def rewrite_policy(self,cspDefaultHeaders): 169 | """ fresh csp policy """ 170 | try: 171 | self.check_valid(cspDefaultHeaders) 172 | if self.inputs is not None: 173 | for p,l in cspDefaultHeaders.items(): 174 | if p in self.inputs: 175 | cspDefaultHeaders[p] = self.inputs[p] 176 | else: 177 | cspDefaultHeaders[p] = [] 178 | return cspDefaultHeaders 179 | else: 180 | return self.inputs 181 | except Exception, e: 182 | raise 183 | 184 | def create_header(self): 185 | """ return CSP header dict """ 186 | encapsulate = re.compile("|".join(['^self','^none','^unsafe-inline','^unsafe-eval','^sha[\d]+-[\w=-]+','^nonce-[\w=-]+'])) 187 | csp = {} 188 | for p,array in self.inputs.items(): 189 | csp[p] = ' '.join(["'%s'" % l if encapsulate.match(l) else l for l in array]) 190 | 191 | return {self.header:'; '.join(['%s %s' % (k, v) for k, v in csp.items() if v != ''])} 192 | -------------------------------------------------------------------------------- /flask_secure_headers/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twaldear/flask-secure-headers/3eca972b369608a7669b67cbe66679570a6505ce/flask_secure_headers/tests/__init__.py -------------------------------------------------------------------------------- /flask_secure_headers/tests/core_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from flask import Flask 3 | from flask_secure_headers.core import Secure_Headers 4 | from flask_secure_headers.headers import CSP 5 | 6 | 7 | class TestCSPHeaderCreation(unittest.TestCase): 8 | def test_CSP_pass(self): 9 | sh = Secure_Headers() 10 | defaultCSP = sh.defaultPolicies['CSP'] 11 | """ test CSP policy update """ 12 | h = CSP({'script-src':['self','code.jquery.com']}).update_policy(defaultCSP) 13 | self.assertEquals(h['script-src'],['self', 'code.jquery.com']) 14 | self.assertEquals(h['default-src'],['self']) 15 | self.assertEquals(h['img-src'],[]) 16 | """ test CSP policy rewrite """ 17 | h = CSP({'default-src':['none']}).rewrite_policy(defaultCSP) 18 | self.assertEquals(h['script-src'],[]) 19 | self.assertEquals(h['default-src'],['none']) 20 | self.assertEquals(h['report-uri'],[]) 21 | """ test CSP header creation """ 22 | h = CSP({'default-src':['none']}).create_header() 23 | self.assertEquals(h['Content-Security-Policy'],"default-src 'none'") 24 | """ test CSP -report-only header creation """ 25 | h = CSP({'default-src':['none'],'report-only':True}).create_header() 26 | self.assertEquals(h['Content-Security-Policy-Report-Only'],"default-src 'none'") 27 | 28 | def test_CSP_fail(self): 29 | """ test invalid paramter for CSP update """ 30 | with self.assertRaises(Exception): 31 | h = CSP({'test-src':['self','code.jquery.com']}).update_policy() 32 | 33 | class TestAppUseCase(unittest.TestCase): 34 | """ test header creation in flask app """ 35 | 36 | def setUp(self): 37 | self.app = Flask(__name__) 38 | self.sh = Secure_Headers() 39 | 40 | def test_defaults(self): 41 | """ test header wrapper with default headers """ 42 | @self.app.route('/') 43 | @self.sh.wrapper() 44 | def index(): return "hi" 45 | with self.app.test_client() as c: 46 | result = c.get('/') 47 | self.assertEquals(result.headers.get('X-XSS-Protection'),'1; mode=block') 48 | self.assertEquals(result.headers.get('Strict-Transport-Security'),'includeSubDomains; max-age=31536000') 49 | self.assertEquals(result.headers.get('Public-Key-Pins'),'includeSubDomains; report-uri=/hpkp_report; max-age=5184000') 50 | self.assertEquals(result.headers.get('X-Content-Type-Options'),'nosniff') 51 | self.assertEquals(result.headers.get('X-Permitted-Cross-Domain-Policies'),'none') 52 | self.assertEquals(result.headers.get('X-Download-Options'),'noopen') 53 | self.assertEquals(result.headers.get('X-Frame-Options'),'sameorigin') 54 | self.assertEquals(result.headers.get('Content-Security-Policy'),"report-uri /csp_report; default-src 'self'") 55 | 56 | def test_update_function(self): 57 | """ test config update function """ 58 | self.sh.update( 59 | { 60 | 'X_Permitted_Cross_Domain_Policies':{'value':'all'}, 61 | 'CSP':{'script-src':['self','code.jquery.com']}, 62 | 'HPKP':{'pins':[{'sha256':'test123'},{'sha256':'test2256'}]} 63 | } 64 | ) 65 | @self.app.route('/') 66 | @self.sh.wrapper() 67 | def index(): return "hi" 68 | with self.app.test_client() as c: 69 | result = c.get('/') 70 | self.assertEquals(result.headers.get('X-Permitted-Cross-Domain-Policies'),'all') 71 | self.assertEquals(result.headers.get('Content-Security-Policy'),"script-src 'self' code.jquery.com; report-uri /csp_report; default-src 'self'") 72 | self.assertEquals(result.headers.get('Public-Key-Pins'),"pin-sha256=test123; pin-sha256=test2256; includeSubDomains; report-uri=/hpkp_report; max-age=5184000") 73 | 74 | def test_rewrite_function(self): 75 | """ test config rewrite function """ 76 | self.sh.rewrite( 77 | { 78 | 'CSP':{'default-src':['none']}, 79 | 'HPKP':{'pins':[{'sha256':'test123'}]} 80 | } 81 | ) 82 | @self.app.route('/') 83 | @self.sh.wrapper() 84 | def index(): return "hi" 85 | with self.app.test_client() as c: 86 | result = c.get('/') 87 | self.assertEquals(result.headers.get('Content-Security-Policy'),"default-src 'none'") 88 | self.assertEquals(result.headers.get('Public-Key-Pins'),"pin-sha256=test123") 89 | 90 | def test_wrapper_update_function(self): 91 | """ test updating policies from wrapper """ 92 | self.sh.rewrite( 93 | { 94 | 'CSP':{'default-src':['none']}, 95 | 'HPKP':{'pins':[{'sha256':'test123'}]} 96 | } 97 | ) 98 | @self.app.route('/') 99 | @self.sh.wrapper( 100 | { 101 | 'CSP':{'script-src':['self','code.jquery.com']}, 102 | 'X_Permitted_Cross_Domain_Policies':{'value':'none'}, 103 | 'X-XSS-Protection':{'value':1,'mode':False}, 104 | 'HPKP':{'pins':[{'sha256':'test2256'}]}, 105 | } 106 | ) 107 | def index(): return "hi" 108 | with self.app.test_client() as c: 109 | result = c.get('/') 110 | self.assertEquals(result.headers.get('X-Permitted-Cross-Domain-Policies'),'none') 111 | self.assertEquals(result.headers.get('Content-Security-Policy'),"script-src 'self' code.jquery.com; default-src 'none'") 112 | self.assertEquals(result.headers.get('X-XSS-Protection'),'1') 113 | self.assertEquals(result.headers.get('Public-Key-Pins'),"pin-sha256=test2256; pin-sha256=test123") 114 | @self.app.route('/test') 115 | @self.sh.wrapper({'CSP':{'script-src':['nonce-1234']}}) 116 | def test(): return "hi" 117 | with self.app.test_client() as c: 118 | result = c.get('/test') 119 | self.assertEquals(result.headers.get('Content-Security-Policy'),"script-src 'self' code.jquery.com 'nonce-1234'; default-src 'none'") 120 | 121 | def test_passing_none_value_rewrite(self): 122 | """ test removing header from update/rewrite """ 123 | self.sh.rewrite({'CSP':None,'X_XSS_Protection':None}) 124 | @self.app.route('/') 125 | @self.sh.wrapper() 126 | def index(): return "hi" 127 | with self.app.test_client() as c: 128 | result = c.get('/') 129 | self.assertEquals(result.headers.get('X-Permitted-Cross-Domain-Policies'),'none') 130 | self.assertEquals(result.headers.get('CSP'),None) 131 | self.assertEquals(result.headers.get('X-XSS-Protection'),None) 132 | 133 | def test_passing_none_value_wrapper(self): 134 | """ test removing policy from wrapper """ 135 | @self.app.route('/') 136 | @self.sh.wrapper({'CSP':None,'X-XSS-Protection':None}) 137 | def index(): return "hi" 138 | with self.app.test_client() as c: 139 | result = c.get('/') 140 | self.assertEquals(result.headers.get('X-Permitted-Cross-Domain-Policies'),'none') 141 | self.assertEquals(result.headers.get('CSP'),None) 142 | self.assertEquals(result.headers.get('X-XSS-Protection'),None) 143 | 144 | if __name__ == '__main__': 145 | unittest.main() 146 | -------------------------------------------------------------------------------- /flask_secure_headers/tests/headers_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from flask import Flask 3 | from flask_secure_headers.headers import * 4 | 5 | 6 | class TestPolicyCreation(unittest.TestCase): 7 | """ Test policy creation """ 8 | def test_X_Frame_Options_pass(self): 9 | """ test valid X_Frame_Options""" 10 | h = X_Frame_Options({'value':'allow-from example.com'}) 11 | r = h.create_header() 12 | self.assertEquals(r['X-Frame-Options'],'allow-from example.com') 13 | def test_X_Frame_Options_fail(self): 14 | """ test invalid input for X_Frame_Options""" 15 | h = X_Frame_Options({'values':'allow-from example.com'}) 16 | with self.assertRaises(Exception): 17 | r = h.create_header() 18 | h = X_Frame_Options({'value':'fail'}) 19 | with self.assertRaises(Exception): 20 | r = h.create_header() 21 | 22 | def test_X_Content_Type_Options_pass(self): 23 | """ test valid X_Content_Type_Options""" 24 | h = X_Content_Type_Options({'value':'nosniff'}) 25 | r = h.create_header() 26 | self.assertEquals(r['X-Content-Type-Options'],'nosniff') 27 | def test_X_Content_Type_Options_fail_input(self): 28 | """ test invalid input for X_Content_Type_Options""" 29 | h = X_Content_Type_Options({'values':'nosniff'}) 30 | with self.assertRaises(Exception): 31 | r = h.create_header() 32 | def test_X_Content_Type_Options_fail_parameter(self): 33 | """ test invalid parameter for X_Content_Type_Options""" 34 | h = X_Content_Type_Options({'value':'fail'}) 35 | with self.assertRaises(Exception): 36 | r = h.create_header() 37 | 38 | def test_X_Download_Options_pass(self): 39 | """ test valid X_Download_Options""" 40 | h = X_Download_Options({'value':'noopen'}) 41 | r = h.create_header() 42 | self.assertEquals(r['X-Download-Options'],'noopen') 43 | def test_X_Download_Options_fail_input(self): 44 | """ test invalid input for X_Download_Options""" 45 | h = X_Download_Options({'values':'noopen'}) 46 | with self.assertRaises(Exception): 47 | r = h.create_header() 48 | def test_X_Download_Options_fail_parameter(self): 49 | """ test invalid parameter for X_Download_Options""" 50 | h = X_Download_Options({'value':'fail'}) 51 | with self.assertRaises(Exception): 52 | r = h.create_header() 53 | 54 | def test_X_Permitted_Cross_Domain_Policies_pass(self): 55 | """ test valid X_Permitted_Cross_Domain_Policies""" 56 | h = X_Permitted_Cross_Domain_Policies({'value':'master-only'}) 57 | r = h.create_header() 58 | self.assertEquals(r['X-Permitted-Cross-Domain-Policies'],'master-only') 59 | def test_X_Permitted_Cross_Domain_Policies_fail_input(self): 60 | """ test invalid input for X_Permitted_Cross_Domain_Policies""" 61 | h = X_Permitted_Cross_Domain_Policies({'values':'master-only'}) 62 | with self.assertRaises(Exception): 63 | r = h.create_header() 64 | def test_X_Permitted_Cross_Domain_Policies_fail_parameter(self): 65 | """ test invalid parameter for X_Permitted_Cross_Domain_Policies""" 66 | h = X_Permitted_Cross_Domain_Policies({'value':'fail'}) 67 | with self.assertRaises(Exception): 68 | r = h.create_header() 69 | 70 | def test_X_XSS_Protection_pass_int(self): 71 | """ test valid X_XSS_Protection (int)""" 72 | h = X_XSS_Protection({'value':1}) 73 | r = h.create_header() 74 | self.assertEquals(r['X-XSS-Protection'],'1') 75 | def test_X_XSS_Protection_pass_str(self): 76 | """ test valid X_XSS_Protection (str)""" 77 | h = X_XSS_Protection({'value':'1'}) 78 | r = h.create_header() 79 | self.assertEquals(r['X-XSS-Protection'],'1') 80 | def test_X_XSS_Protection_pass_second_param(self): 81 | """ test valid X_XSS_Protection (with second parameter)""" 82 | h = X_XSS_Protection({'value':'1','mode':'block'}) 83 | r = h.create_header() 84 | self.assertEquals(r['X-XSS-Protection'],'1; mode=block') 85 | def test_X_XSS_Protection_pass_second_param_false(self): 86 | """ test valid X_XSS_Protection (with second parameter set to false)""" 87 | h = X_XSS_Protection({'value':'1','mode':False}) 88 | r = h.create_header() 89 | self.assertEquals(r['X-XSS-Protection'],'1') 90 | def test_X_XSS_Protection_fail_input(self): 91 | """ test invalid input for X_XSS_Protection""" 92 | h = X_XSS_Protection({'values':1}) 93 | with self.assertRaises(Exception): 94 | r = h.create_header() 95 | def test_X_XSS_Protection_fail_paramater(self): 96 | """ test invalid paramater for X_XSS_Protection""" 97 | h = X_XSS_Protection({'value':'fail'}) 98 | with self.assertRaises(Exception): 99 | r = h.create_header() 100 | def test_X_XSS_Protection_fail_second_paramater(self): 101 | """ test invalid second parameter for X_XSS_Protection """ 102 | h = X_XSS_Protection({'value':'1','mode':'fail'}) 103 | with self.assertRaises(Exception): 104 | r = h.create_header() 105 | 106 | def test_HSTS_pass_int(self): 107 | """ test valid HSTS (int)""" 108 | h = HSTS({'max-age':23}) 109 | r = h.create_header() 110 | self.assertEquals(r['Strict-Transport-Security'],'max-age=23') 111 | def test_HSTS_pass_str(self): 112 | """ test valid HSTS (str)""" 113 | h = HSTS({'max-age':'23'}) 114 | r = h.create_header() 115 | self.assertEquals(r['Strict-Transport-Security'],'max-age=23') 116 | def test_HSTS_pass_second_param(self): 117 | """ test valid HSTS (with second parameter)""" 118 | h = HSTS({'max-age':23,'includeSubDomains':True,'preload':False}) 119 | r = h.create_header() 120 | self.assertEquals(r['Strict-Transport-Security'],'includeSubDomains; max-age=23') 121 | def test_HSTS_fail_input(self): 122 | """ test invalid input for HSTS """ 123 | h = HSTS({'values':23}) 124 | with self.assertRaises(Exception): 125 | r = h.create_header() 126 | def test_HSTS_fail_input_non_digit(self): 127 | """ test non-digit max-age value for HSTS """ 128 | h = HSTS({'max-age':'fail'}) 129 | with self.assertRaises(Exception): 130 | r = h.create_header() 131 | def test_HSTS_fail_non_boolean(self): 132 | """ test non-boolean includeSubDomains value for HSTS """ 133 | h = HSTS({'max-age':'23','includeSubDomains':'Test'}) 134 | with self.assertRaises(Exception): 135 | r = h.create_header() 136 | 137 | def test_HPKP_pass(self): 138 | """ test valid HPKP """ 139 | h = HPKP({'max-age':'23','includeSubDomains':True,'pins':[{'sha256':'1234'}]}) 140 | r = h.create_header() 141 | self.assertEquals(r['Public-Key-Pins'],'includeSubDomains; pin-sha256=1234; max-age=23') 142 | def test_HPKP_pass_2_pins(self): 143 | """ test valid HPKP """ 144 | h = HPKP({'max-age':'23','includeSubDomains':True,'pins':[{'sha256':'1234'},{'sha256':'abcd'}]}) 145 | r = h.create_header() 146 | self.assertEquals(r['Public-Key-Pins'],'includeSubDomains; pin-sha256=1234; pin-sha256=abcd; max-age=23') 147 | def test_HPKP_pass_no_pins(self): 148 | """ test valid HPKP (with no pins) """ 149 | h = HPKP({'max-age':'23','includeSubDomains':True}) 150 | r = h.create_header() 151 | self.assertEquals(r['Public-Key-Pins'],'includeSubDomains; max-age=23') 152 | def test_HPKP_pass_no_include_subdomains(self): 153 | """ test valid HPKP (with no pins) """ 154 | h = HPKP({'max-age':'23','includeSubDomains':False}) 155 | r = h.create_header() 156 | self.assertEquals(r['Public-Key-Pins'],'max-age=23') 157 | def test_HPKP_pass_report_only(self): 158 | """ test valid HPKP for Report-Only header """ 159 | h = HPKP({'max-age':'23','includeSubDomains':True,'pins':[{'sha256':'1234'}],'report-only':True}) 160 | r = h.create_header() 161 | self.assertEquals(r['Public-Key-Pins-Report-Only'],'includeSubDomains; pin-sha256=1234; max-age=23') 162 | def test_HPHP_fail_nonList(self): 163 | """ test invalid pins argument for HPKP (not passing list for pins argument) """ 164 | h = HPKP({'pins':'test'}) 165 | with self.assertRaises(Exception): 166 | r = h.create_header() 167 | def test_HPKP_fail_input(self): 168 | """ test invalid input for HPKP """ 169 | h = HPKP({'test':'test'}) 170 | with self.assertRaises(Exception): 171 | r = h.create_header() 172 | def test_HPKP_fail_parameter(self): 173 | """ test non-digit max-age value for HPKP """ 174 | h = HPKP({'max-age':'test'}) 175 | with self.assertRaises(Exception): 176 | r = h.create_header() 177 | def test_HPKP_fail_non_boolean(self): 178 | """ test non-boolean include_subdomains value for HSTS """ 179 | h = HPKP({'max-age':'23','includeSubDomains':'Test'}) 180 | with self.assertRaises(Exception): 181 | r = h.create_header() 182 | 183 | 184 | 185 | if __name__ == '__main__': 186 | unittest.main() 187 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name = 'flask-secure-headers', 6 | packages = ['flask_secure_headers'], 7 | include_package_data = True, 8 | version = '0.6', 9 | description = 'Secure Header Wrapper for Flask Applications', 10 | long_description = ('Add security headers to a Flask application. ' 11 | 'This is intended to be a simplified version of the ' 12 | 'Twitter SecureHeaders Ruby Gem'), 13 | license='MIT', 14 | author = 'Tristan Waldear', 15 | author_email = 'trwaldear@gmail.com', 16 | url = 'https://github.com/twaldear/flask-secure-headers', 17 | download_url = 'https://github.com/twaldear/flask-secure-headers/tarball/0.1', 18 | keywords = ['flask', 'security', 'header'], 19 | install_requires = ['flask'], 20 | test_suite="nose.collector", 21 | tests_require = ['nose'], 22 | classifiers=[ 23 | 'Development Status :: 4 - Beta', 24 | 'Framework :: Flask', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python', 28 | 'Topic :: Software Development :: Libraries :: Python Modules', 29 | ] 30 | ) 31 | --------------------------------------------------------------------------------