├── .nojekyll ├── setup.cfg ├── CHANGES.md ├── requirements.txt ├── pypi.sh ├── MANIFEST.in ├── .travis.yml ├── .gitignore ├── LICENSE ├── setup.py ├── docs ├── Makefile └── conf.py ├── flask_xsrf_tests.py ├── flask_xsrf.py └── README.md /.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | ; [wheel] 2 | ; universal = 1 3 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | flask-xsrf changelog 2 | ==================== 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask>=0.9 2 | werkzeug>=0.8.3 3 | blinker>=1.2 4 | -------------------------------------------------------------------------------- /pypi.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | python setup.py sdist register upload 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CHANGES.md 3 | include LICENSE 4 | include requirements.txt 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.7 5 | 6 | install: 7 | - python setup.py install 8 | 9 | script: 10 | - python setup.py check clean sdist nosetests 11 | 12 | branches: 13 | only: 14 | - develop 15 | - master 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | 23 | #Translations 24 | *.mo 25 | 26 | #Mr Developer 27 | .mr.developer.cfg 28 | 29 | docs/build 30 | .DS_Store 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 by gregorynicholas. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | flask-xsrf 4 | ~~~~~~~~~~ 5 | 6 | a flask extension for defending against cross-site request forgery attacks 7 | (xsrf/csrf). 8 | 9 | 10 | links 11 | ````` 12 | 13 | * `docs `_ 14 | * `source `_ 15 | * `package `_ 16 | * `travis-ci `_ 17 | 18 | """ 19 | from setuptools import setup 20 | 21 | __version__ = "1.0.3" 22 | 23 | with open("requirements.txt", "r") as f: 24 | requires = f.readlines() 25 | 26 | with open("README.md", "r") as f: 27 | long_description = f.read() 28 | 29 | 30 | setup( 31 | name='flask-xsrf', 32 | version=__version__, 33 | url='http://github.com/gregorynicholas/flask-xsrf', 34 | license='MIT', 35 | author='gregorynicholas', 36 | author_email='gn@gregorynicholas.com', 37 | description=__doc__, 38 | long_description=long_description, 39 | zip_safe=False, 40 | platforms='any', 41 | install_requires=requires, 42 | py_modules=[ 43 | 'flask_xsrf', 44 | 'flask_xsrf_tests', 45 | ], 46 | dependency_links=[ 47 | ], 48 | classifiers=[ 49 | 'Development Status :: 4 - Beta', 50 | 'Environment :: Web Environment', 51 | 'Intended Audience :: Developers', 52 | 'License :: OSI Approved :: MIT License', 53 | 'Operating System :: OS Independent', 54 | 'Programming Language :: Python', 55 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 56 | 'Topic :: Software Development :: Libraries :: Python Modules' 57 | ] 58 | ) 59 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # makefile for sphinx documentation 2 | # 3 | 4 | # you can set these variables from the command line 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # internal variables 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml json htmlhelp text changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " text to make text files" 27 | @echo " gettext to make PO message catalogs" 28 | @echo " changes to make an overview of all changed/added/deprecated items" 29 | @echo " linkcheck to check all external links for integrity" 30 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 31 | 32 | clean: 33 | -rm -rf $(BUILDDIR)/* 34 | 35 | html: 36 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 37 | @echo 38 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 39 | 40 | dirhtml: 41 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 42 | @echo 43 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 44 | 45 | singlehtml: 46 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 47 | @echo 48 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 49 | 50 | json: 51 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 52 | @echo 53 | @echo "Build finished; now you can process the JSON files." 54 | 55 | htmlhelp: 56 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 57 | @echo 58 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 59 | ".hhp project file in $(BUILDDIR)/htmlhelp." 60 | 61 | text: 62 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 63 | @echo 64 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 65 | 66 | gettext: 67 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 68 | @echo 69 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 70 | 71 | changes: 72 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 73 | @echo 74 | @echo "The overview file is in $(BUILDDIR)/changes." 75 | 76 | linkcheck: 77 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 78 | @echo 79 | @echo "Link check complete; look for any errors in the above output " \ 80 | "or in $(BUILDDIR)/linkcheck/output.txt." 81 | 82 | doctest: 83 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 84 | @echo "Testing of doctests in the sources finished, look at the " \ 85 | "results in $(BUILDDIR)/doctest/output.txt." 86 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # documentation build configuration file, created by 4 | # sphinx-quickstart on thu dec 6 14:38:14 2012. 5 | # 6 | # this file is execfile()d with the current directory set to its containing dir 7 | # 8 | # note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # all configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys 15 | import os 16 | sys.path.insert(0, os.path.abspath('..')) 17 | sys.path.append(os.path.abspath('_themes')) 18 | html_theme_path = ['_themes'] 19 | html_theme = 'flask' 20 | 21 | 22 | # if extensions (or modules to document with autodoc) are in another directory, 23 | # add these directories to sys.path here. if the directory is relative to the 24 | # documentation root, use os.path.abspath to make it absolute, like shown here. 25 | # sys.path.insert(0, os.path.abspath('.')) 26 | 27 | # -- General configuration ---------------------------------------------------- 28 | 29 | # if your documentation needs a minimal sphinx version, state it here. 30 | # needs_sphinx = '1.0' 31 | 32 | # add any Sphinx extension module names here, as strings. 33 | # they can be extensions 34 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] 36 | 37 | # add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # the suffix of source filenames. 41 | source_suffix = '.rst' 42 | 43 | # the encoding of source files. 44 | # source_encoding = 'utf-8-sig' 45 | 46 | # the master toctree document. 47 | master_doc = 'index' 48 | 49 | project = u'flask-xsrf' 50 | copyright = u'2016, gregory nicholas' 51 | # the short x.y version. 52 | version = '0.0.2' 53 | # the full version, including alpha/beta/rc tags. 54 | release = '0.0.2' 55 | 56 | 57 | # language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | # language = None 60 | 61 | # there are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | # today = '' 64 | # else, today_fmt is used as the format for a strftime call. 65 | # today_fmt = '%B %d, %Y' 66 | 67 | # list of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = ['_build'] 70 | 71 | # the rest default role (used for this markup: `text`) to use for all documents 72 | # default_role = none 73 | 74 | # if true, '()' will be appended to :func: etc. cross-reference text. 75 | add_function_parentheses = True 76 | 77 | # if true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | add_module_names = True 80 | 81 | # if true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. they are ignored by default. 83 | show_authors = True 84 | 85 | # the name of the pygments (syntax highlighting) style to use. 86 | # pygments_style = 'sphinx' 87 | 88 | # a list of ignored prefixes for module index sorting. 89 | # modindex_common_prefix = [] 90 | 91 | 92 | # -- options for html output -------------------------------------------------- 93 | 94 | html_theme = 'flask_small' 95 | 96 | # theme options are theme-specific and customize the look and feel of a theme 97 | # further. for a list of options available for each theme, see the 98 | # documentation. 99 | # html_theme_options = {} 100 | 101 | # add any paths that contain custom themes here, relative to this directory. 102 | html_theme_path = ['_themes'] 103 | 104 | # the name for this set of sphinx documents. if none, it defaults to 105 | # " v documentation". 106 | # html_title = None 107 | 108 | # a shorter title for the navigation bar. default is the same as html_title. 109 | # html_short_title = None 110 | 111 | # the name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | # html_logo = None 114 | 115 | # the name of an image file (within the static path) to use as favicon of the 116 | # docs. this file should be a windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | # html_favicon = None 119 | 120 | # add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. they are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # if not '', a 'last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | # html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # if true, smartypants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | html_use_smartypants = False 132 | 133 | # custom sidebar templates, maps document names to template names. 134 | # html_sidebars = {} 135 | 136 | # additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | # html_additional_pages = {} 139 | 140 | # if false, no module index is generated. 141 | html_domain_indices = False 142 | 143 | # if false, no index is generated. 144 | html_use_index = False 145 | 146 | # if true, the index is split into individual pages for each letter. 147 | html_split_index = False 148 | 149 | # if true, links to the rest sources are added to the pages. 150 | html_show_sourcelink = True 151 | html_show_sphinx = False 152 | 153 | # if true, "(c) copyright ..." is shown in the html footer. default is true. 154 | html_show_copyright = True 155 | 156 | # if true, an opensearch description file will be output, and all pages will 157 | # contain a tag referring to it. the value of this option must be the 158 | # base url from which the finished html is served. 159 | # html_use_opensearch = '' 160 | 161 | html_file_suffix = None 162 | 163 | # output file base name for html help builder. 164 | htmlhelp_basename = 'flask-xsrf-doc' 165 | -------------------------------------------------------------------------------- /flask_xsrf_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import base64 3 | import unittest 4 | from flask.testsuite import FlaskTestCase 5 | import flask_xsrf as xsrf 6 | 7 | class XSRFTokenTests(unittest.TestCase): 8 | 9 | def test_verify_timeout_elapse_raises(self): 10 | """Test that the time span between generation and verification raises if 11 | the time span is greater than the timeout arg.""" 12 | token = xsrf.XSRFToken( 13 | user_id='user@example.com', 14 | secret='secret', 15 | current_time=1354160000) 16 | token_string = token.generate_token_string() 17 | token.verify_token_string( 18 | token_string, 19 | timeout=10, 20 | current_time=1354160010) 21 | self.assertRaises(xsrf.XSRFTokenExpiredException, 22 | token.verify_token_string, 23 | token_string, 24 | timeout=10, 25 | current_time=1354160011) 26 | 27 | def test_verify_no_action_value_raises(self): 28 | """ """ 29 | token = xsrf.XSRFToken( 30 | user_id='user@example.com', 31 | secret='secret', 32 | current_time=1354160000) 33 | token_string = token.generate_token_string() 34 | token.verify_token_string(token_string) 35 | self.assertRaises( 36 | xsrf.XSRFTokenInvalid, 37 | token.verify_token_string, 38 | xsrf.XSRFToken( 39 | user_id='user@example.com', 40 | secret='differentsecret', 41 | current_time=1354160000).generate_token_string()) 42 | self.assertRaises( 43 | xsrf.XSRFTokenInvalid, 44 | token.verify_token_string, 45 | xsrf.XSRFToken( 46 | user_id='user@example.com', 47 | secret='secret', 48 | current_time=1354160000).generate_token_string('action')) 49 | 50 | def test_verify_different_action_values_raises(self): 51 | token = xsrf.XSRFToken( 52 | user_id='user@example.com', 53 | secret='secret', 54 | current_time=1354160000) 55 | token_string = token.generate_token_string('action') 56 | token.verify_token_string(token_string, 'action') 57 | self.assertRaises( 58 | xsrf.XSRFTokenInvalid, 59 | token.verify_token_string, 60 | xsrf.XSRFToken( 61 | user_id='user@example.com', 62 | secret='differentsecret', 63 | current_time=1354160000).generate_token_string()) 64 | 65 | def test_verify_substring_of_tokenstr_fails(self): 66 | """Tests that a substring of the correct token fails to verify.""" 67 | token = xsrf.XSRFToken( 68 | user_id='user@example.com', 69 | secret='secret', 70 | current_time=1354160000) 71 | token_string = token.generate_token_string() 72 | test_token, test_time = base64.urlsafe_b64decode(token_string).split('|') 73 | test_string = base64.urlsafe_b64encode( 74 | '|'.join([test_token[:-1], test_time])) 75 | 76 | self.assertRaises( 77 | xsrf.XSRFTokenInvalid, 78 | token.verify_token_string, 79 | test_string) 80 | 81 | def test_verify_tokenstr_not_b64_raises(self): 82 | """Tests that a token string must be a valid base64 string.""" 83 | token = xsrf.XSRFToken( 84 | user_id='user@example.com', 85 | secret='secret') 86 | self.assertRaises( 87 | xsrf.XSRFTokenMalformed, 88 | token.verify_token_string, 'FAKE_STR_NOT_B64') 89 | 90 | def test_verify_tokenstr_wo_delimiter_raises(self): 91 | """Tests that a token string must properly created from the digest maker.""" 92 | token = xsrf.XSRFToken( 93 | user_id='user@example.com', 94 | secret='secret') 95 | self.assertRaises( 96 | xsrf.XSRFTokenMalformed, 97 | token.verify_token_string, 98 | base64.b64encode('FAKE_STR_NO_DELIMITER')) 99 | 100 | def test_verify_tokenstr_not_int_time_raises(self): 101 | """Tests that the time must be a correct datetime int value.""" 102 | token = xsrf.XSRFToken( 103 | user_id='user@example.com', 104 | secret='secret') 105 | self.assertRaises( 106 | xsrf.XSRFTokenMalformed, 107 | token.verify_token_string, 108 | base64.b64encode('FAKE_STR|FAKE_TIME_NOTINT')) 109 | 110 | 111 | from flask import Flask, Response, session 112 | app = Flask(__name__) 113 | app.debug = True 114 | app.secret_key = 'session_secret_key' 115 | app.config['session_cookie_secure'] = True 116 | app.config['remember_cookie_name'] = 'testdomain.com' 117 | app.config['remember_cookie_duration_in_days'] = 1 118 | 119 | @app.before_request 120 | def before_request(): 121 | if 'user_id' not in session: 122 | session['user_id'] = 'random_generated_anonymous_id' 123 | 124 | def get_user_id(): 125 | return session.get('user_id') 126 | 127 | xsrfh = xsrf.XSRFTokenHandler( 128 | user_func=get_user_id, secret='xsrf_secret', timeout=3600) 129 | 130 | @app.route('/test', methods=['GET']) 131 | @xsrfh.send_token() 132 | def test_get(): 133 | return Response('success') 134 | 135 | @app.route('/test', methods=['POST']) 136 | @xsrfh.handle_token() 137 | def test_post(): 138 | return Response('success') 139 | 140 | 141 | @app.errorhandler(xsrf.XSRFTokenInvalid) 142 | def xsrftokeninvalid(): 143 | return Response(status=400) 144 | 145 | @app.errorhandler(xsrf.XSRFTokenMalformed) 146 | def xsrftokenmalformed(): 147 | return Response(status=400) 148 | 149 | @app.errorhandler(xsrf.XSRFTokenExpiredException) 150 | def xsrftokenexpiredexception(): 151 | return Response(status=400) 152 | 153 | @app.errorhandler(xsrf.XSRFTokenUserIdInvalid) 154 | def xsrftokenuseridinvalid(): 155 | return Response(status=400) 156 | 157 | 158 | class XSRFTokenHandlerTests(FlaskTestCase): 159 | def setUp(self): 160 | self.app = app.test_client() 161 | 162 | def test_app_request_works(self): 163 | test_get = self.app.get( 164 | '/test', data='', headers={}) 165 | self.assertEquals(test_get.status_code, 200) 166 | # assert the token string header 167 | token_string = test_get.headers.get('X-XSRF-Token') 168 | self.assertTrue(token_string and len(token_string) > 0) 169 | # assert the user session is linked to the cookie 170 | cookie = test_get.headers.get('Set-Cookie') 171 | self.assertTrue(cookie and len(cookie) > 0) 172 | test_post = self.app.post( 173 | '/test', data='', headers={'X-XSRF-Token': token_string}) 174 | self.assertEquals(test_post.status_code, 200) 175 | 176 | def test_app_request_w_bad_tokenstr_header_fails(self): 177 | test_post = self.app.post( 178 | '/test', data='', headers={'X-XSRF-Token': 'FAKE_STR'}) 179 | self.assertEquals(test_post.status_code, 406) 180 | 181 | def test_app_request_w_bad_tokenstr_form_fails(self): 182 | """Assert invalid form token str fails properly.""" 183 | test_get = self.app.get( 184 | '/test', data='', headers={}) 185 | self.assertEquals(test_get.status_code, 200) 186 | test_post = self.app.post( 187 | '/test', data={'xsrf-token': 'FAKE_STR'}, headers={}) 188 | self.assertEquals(test_post.status_code, 406) 189 | 190 | def test_app_request_w_valid_form_tokenstr_works(self): 191 | """Assert valid token string form works.""" 192 | test_get = self.app.get( 193 | '/test', data='', headers={}) 194 | self.assertEquals(test_get.status_code, 200) 195 | token_string = test_get.headers.get('X-XSRF-Token') 196 | test_post = self.app.post( 197 | '/test', data={'xsrf-token': token_string}, headers={}) 198 | self.assertEquals(test_post.status_code, 200) 199 | 200 | def test_app_request_w_empty_header_and_form_tokenstr_works(self): 201 | """Assert empty header parses from form properly.""" 202 | test_get = self.app.get( 203 | '/test', data='', headers={}) 204 | self.assertEquals(test_get.status_code, 200) 205 | token_string = test_get.headers.get('X-XSRF-Token') 206 | test_post = self.app.post( 207 | '/test', data={'xsrf-token': token_string}, headers={ 208 | 'X-XSRF-Token': '' }) 209 | self.assertEquals(test_post.status_code, 200) 210 | 211 | 212 | if __name__ == '__main__': 213 | unittest.main() 214 | -------------------------------------------------------------------------------- /flask_xsrf.py: -------------------------------------------------------------------------------- 1 | """ 2 | flask-xsrf 3 | ~~~~~~~~~~ 4 | 5 | flask extension for defending against cross-site request forgery attacks 6 | (XSRF/CSRF). 7 | 8 | :usage: 9 | 10 | from flask import Flask, Response, session 11 | from flask.ext import xsrf 12 | 13 | app = Flask(__name__) 14 | app.debug = True 15 | app.secret_key = 'session_secret_key' 16 | app.config['session_cookie_secure'] = True 17 | 18 | @app.before_request 19 | def before_request(): 20 | if 'user_id' not in session: 21 | session['user_id'] = 'random_generated_anonymous_id' 22 | 23 | def get_user_id(): 24 | return session.get('user_id') 25 | 26 | xsrfh = xsrf.XSRFTokenHandler( 27 | user_func=get_user_id, secret='xsrf_secret', timeout=3600) 28 | 29 | @app.route('/create', methods=['GET']) 30 | @xsrfh.send_token() 31 | def create_get(): 32 | return Response('success') 33 | 34 | @app.route('/create', methods=['POST']) 35 | @xsrfh.handle_token() 36 | def create_post(): 37 | return Response('success') 38 | 39 | 40 | :author: @gregorynicholas 41 | :license: MIT, see LICENSE for more details. 42 | """ 43 | import hmac 44 | import time 45 | import base64 46 | import hashlib 47 | from flask import request 48 | from flask import session 49 | from functools import wraps 50 | from werkzeug import exceptions 51 | 52 | __all__ = ['XSRFTokenHandler', 'XSRFToken', 'XSRFTokenUserIdInvalid', 53 | 'XSRFTokenMalformed', 'XSRFTokenExpiredException', 'XSRFTokenInvalid', 54 | 'TOKEN_FORM_NAME', 'TOKEN_HEADER_NAME'] 55 | 56 | 57 | class XSRFException(exceptions.HTTPException): 58 | pass 59 | 60 | class XSRFTokenMalformed(XSRFException, exceptions.NotAcceptable): 61 | pass 62 | 63 | class XSRFTokenExpiredException(XSRFException, exceptions.Unauthorized): 64 | pass 65 | 66 | class XSRFTokenInvalid(XSRFException, exceptions.NotAcceptable): 67 | pass 68 | 69 | class XSRFTokenUserIdInvalid(XSRFException, exceptions.NotAcceptable): 70 | pass 71 | 72 | TOKEN_FORM_NAME = 'xsrf-token' 73 | TOKEN_HEADER_NAME = 'X-XSRF-Token' 74 | 75 | 76 | class XSRFTokenHandler: 77 | def __init__(self, user_func, secret, timeout=10): 78 | ''' 79 | :param user_func: Function which returns an key string for the current user. 80 | :param secret: String secret. 81 | :param timeout: Integer for the number of seconds the token is active for. 82 | ''' 83 | self.user_func = user_func 84 | self.secret = secret 85 | self.timeout = timeout 86 | 87 | def send_token(self): 88 | def wrapper(func): 89 | @wraps(func) 90 | def decorated(*args, **kw): 91 | user_id = self.user_func() 92 | if not user_id: 93 | raise XSRFTokenUserIdInvalid('XSRFTokenUserIdInvalid') 94 | self.token = XSRFToken(user_id=user_id, secret=self.secret) 95 | session[TOKEN_FORM_NAME] = self.token.generate_token_string() 96 | response = func(*args, **kw) 97 | response.headers.add(TOKEN_HEADER_NAME, session[TOKEN_FORM_NAME]) 98 | return response 99 | return decorated 100 | return wrapper 101 | 102 | def handle_token(self): 103 | def wrapper(func): 104 | @wraps(func) 105 | def decorated(*args, **kw): 106 | user_id = self.user_func() 107 | if not user_id: 108 | raise XSRFTokenUserIdInvalid('UserId not valid.') 109 | self.token = XSRFToken(user_id=user_id, secret=self.secret) 110 | # parse the token string.. 111 | request_token_string = self.parse_xsrftoken_from_request() 112 | token_string = session.pop(TOKEN_FORM_NAME, None) 113 | # validate the token string.. 114 | self.verify_token(token_string, request_token_string) 115 | return func(*args, **kw) 116 | return decorated 117 | return wrapper 118 | 119 | def verify_token(self, token_string, request_token_string): 120 | if not token_string: 121 | raise XSRFTokenInvalid('Token not set.') 122 | if not request_token_string: 123 | raise XSRFTokenInvalid('Request token not valid.') 124 | if token_string != request_token_string: 125 | raise XSRFTokenInvalid('Token not valid.') 126 | self.token.verify_token_string(token_string, timeout=self.timeout) 127 | 128 | def parse_xsrftoken_from_request(self): 129 | # get the value from a request header.. 130 | value = request.headers.get(TOKEN_HEADER_NAME) 131 | if value is None or len(value) < 1: 132 | # get the value from form params.. 133 | value = request.form.get(TOKEN_FORM_NAME) 134 | return value 135 | 136 | 137 | class XSRFToken(object): 138 | _DELIMITER = '|' 139 | 140 | def __init__(self, user_id, secret, current_time=None): 141 | """Initializes the XSRFToken object. 142 | 143 | :param user_id: 144 | A string representing the user that the token will be valid for. 145 | :param secret: 146 | A string containing a secret key that will be used to seed the 147 | hash used by the :class:`XSRFToken`. 148 | :param current_time: 149 | An int representing the number of seconds since the epoch. Will be 150 | used by `verify_token_string` to check for token expiry. If `None` 151 | then the current time will be used. 152 | """ 153 | self.user_id = user_id 154 | self.secret = secret 155 | if current_time is None: 156 | self.current_time = int(time.time()) 157 | else: 158 | self.current_time = int(current_time) 159 | 160 | def _digest_maker(self): 161 | return hmac.new(self.secret, digestmod=hashlib.sha256) 162 | 163 | def generate_token_string(self, action=None): 164 | """Generate a hash of the given token contents that can be verified. 165 | 166 | :param action: 167 | A string representing the action that the generated hash is valid 168 | for. This string is usually a URL. 169 | :returns: 170 | A string containing the hash contents of the given `action` and the 171 | contents of the `XSRFToken`. Can be verified with 172 | `verify_token_string`. The string is base64 encoded so it is safe 173 | to use in HTML forms without escaping. 174 | """ 175 | digest_maker = self._digest_maker() 176 | digest_maker.update(self.user_id) 177 | digest_maker.update(self._DELIMITER) 178 | if action: 179 | digest_maker.update(action) 180 | digest_maker.update(self._DELIMITER) 181 | 182 | digest_maker.update(str(self.current_time)) 183 | return base64.urlsafe_b64encode( 184 | self._DELIMITER.join( 185 | [digest_maker.hexdigest(), 186 | str(self.current_time)] 187 | )) 188 | 189 | def verify_token_string(self, token_string, action=None, timeout=None, 190 | current_time=None): 191 | """Generate a hash of the given token contents that can be verified. 192 | 193 | :param token_string: 194 | A string containing the hashed token (generated by 195 | `generate_token_string`). 196 | :param action: 197 | A string containing the action that is being verified. 198 | :param timeout: 199 | An int or float representing the number of seconds that the token 200 | is valid for. If None then tokens are valid forever. 201 | :current_time: 202 | An int representing the number of seconds since the epoch. Will be 203 | used by to check for token expiry if `timeout` is set. If `None` 204 | then the current time will be used. 205 | :raises: 206 | XSRFTokenMalformed if the given token_string cannot be parsed. 207 | XSRFTokenExpiredException if the given token string is expired. 208 | XSRFTokenInvalid if the given token string does not match the 209 | contents of the `XSRFToken`. 210 | """ 211 | try: 212 | decoded_token_string = base64.urlsafe_b64decode(token_string) 213 | except TypeError: 214 | raise XSRFTokenMalformed() 215 | 216 | split_token = decoded_token_string.split(self._DELIMITER) 217 | if len(split_token) != 2: 218 | raise XSRFTokenMalformed() 219 | 220 | try: 221 | token_time = int(split_token[1]) 222 | except ValueError: 223 | raise XSRFTokenMalformed() 224 | 225 | if timeout is not None: 226 | if current_time is None: 227 | current_time = time.time() 228 | # If an attacker modifies the plain text time then it will not match 229 | # the hashed time so this check is sufficient. 230 | if (token_time + timeout) < current_time: 231 | raise XSRFTokenExpiredException() 232 | 233 | expected_token = XSRFToken(self.user_id, self.secret, token_time) 234 | expected_token_string = expected_token.generate_token_string(action) 235 | 236 | if len(expected_token_string) != len(token_string): 237 | raise XSRFTokenInvalid() 238 | 239 | # Compare the two strings in constant time to prevent timing attacks. 240 | different = 0 241 | for a, b in zip(token_string, expected_token_string): 242 | different |= ord(a) ^ ord(b) 243 | if different: 244 | raise XSRFTokenInvalid() 245 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flask-xsrf 2 | 3 | [flask](http://flask.pocoo.org) extension for defending against *cross-site request forgery attacks* 4 | [(xsrf/csrf)](https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)), 5 | by protecting flask request endpoints with uniquely generated tokens for each 6 | request. 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | 15 | 16 |
17 |
18 | 19 | 20 | 21 | | FLASK | PYTHON | XSRF | 22 | | ----- | ------ | ---- | 23 | | [![flask](https://cloud.githubusercontent.com/assets/407650/15803510/2d4f594a-2a96-11e6-86e0-802592e17aca.png)](http://flask.pocoo.org) | [![python](https://cloud.githubusercontent.com/assets/407650/15803508/24d88944-2a96-11e6-9912-c696d9fc3912.png)](http://www.python.org) | [![csrf](https://cloud.githubusercontent.com/assets/407650/15803506/1c76e002-2a96-11e6-881e-969ef407839a.png)](https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)) | 24 | 25 |
26 |
27 | 28 | 29 | ----- 30 |
31 |
32 | 33 | 34 | 35 | **BUILD STATUS** 36 | 37 | | branch | service | status | service | title | status | 38 | | ------- | ------------ | -------------------------------- | ------- | --------------- | ---------------------- | 39 | | `master` | `ci-build` | [![travis-ci-build-status-master](https://secure.travis-ci.org/gregorynicholas/flask-xsrf.svg?branch=master)](https://travis-ci.org/gregorynicholas/flask-xsrf/builds) | `github` | `tags` | [![github-tags](https://img.shields.io/github/tag/gregorynicholas/flask-xsrf.svg?maxAge=2592000?style=flat-square)](https://github.com/gregorynicholas/flask-xsrf/tags) | 40 | | `develop` | `ci-build` | [![travis-ci-build-status-develop](https://secure.travis-ci.org/gregorynicholas/flask-xsrf.svg?branch=develop)](https://travis-ci.org/gregorynicholas/flask-xsrf/builds) | `github` | `releases-all` | [![github-releases-all](https://img.shields.io/github/downloads/gregorynicholas/flask-xsrf/total.svg?maxAge=2592000?style=flat-square)](https://github.com/gregorynicholas/flask-xsrf/releases) | 41 | | `master` | `coveralls.io` | [![coveralls-coverage-status-master](https://coveralls.io/repos/github/gregorynicholas/flask-xsrf/badge.svg?branch=master)](https://coveralls.io/github/gregorynicholas/flask-xsrf?branch=master) | `github` | `releases-latest` | [![github-releases-latest](https://img.shields.io/github/downloads/gregorynicholas/flask-xsrf/1.0.2/total.svg?maxAge=2592000?style=flat-square)](https://github.com/gregorynicholas/flask-xsrf/releases/latest) | 42 | | `develop` | `coveralls.io` | [![coveralls-coverage-status-develop](https://coveralls.io/repos/github/gregorynicholas/flask-xsrf/badge.svg?branch=develop)](https://coveralls.io/github/gregorynicholas/flask-xsrf?branch=develop) | `pypi` | `releases-latest` | [![pypi-releases-latest](https://img.shields.io/pypi/v/flask-xsrf.svg)](https://pypi.python.org/pypi/flask-xsrf) | 43 | | `master` | `landscape.io` | [![landscape-code-health-master](https://landscape.io/github/gregorynicholas/flask-xsrf/master/landscape.svg?style=flat-square)](https://landscape.io/github/gregorynicholas/flask-xsrf/master) | `pypi` | `downloads` | [![pypi-downloads](https://img.shields.io/pypi/dm/flask-xsrf.svg)](https://pypi.python.org/pypi/flask-xsrf) | 44 | | `develop` | `landscape.io` | [![landscape-code-health-develop](https://landscape.io/github/gregorynicholas/flask-xsrf/develop/landscape.svg?style=flat-square)](https://landscape.io/github/gregorynicholas/flask-xsrf/develop) | `pypi` | `dl-month` | [![pypi](https://img.shields.io/pypi/dm/flask-xsrf.svg?maxAge=2592000?style=flat-square)](https://github.com/gregorynicholas/flask-xsrf) | 45 | 46 | 47 | 48 | ----- 49 |
50 |
51 | 52 | 53 | 54 | **REFERENCE / LINKS** 55 | 56 | * [pypi: package](http://packages.python.org/flask-xsrf) 57 | * [readthedocs: docs](https://readthedocs.org/projects/flask-xsrf/) 58 | * [github: wiki](https://github.com/gregorynicholas/flask-xsrf/wiki) 59 | * [github: source](http://github.com/gregorynicholas/flask-xsrf) 60 | * [github: releases](https://github.com/gregorynicholas/flask-xsrf/releases) 61 | * [changelog notes](https://github.com/gregorynicholas/flask-xsrf/blob/master/CHANGES.md) 62 | * [travis-ci: build-status](http://travis-ci.org/gregorynicholas/flask-xsrf) 63 | * [coveralls: coverage-status](https://coveralls.io/github/gregorynicholas/flask-xsrf) 64 | * [contributing notes](http://github.com/gregorynicholas/flask-xsrf/wiki) 65 | * [github: issues](https://github.com/gregorynicholas/flask-xsrf/issues) 66 | 67 | 68 | 69 | 70 | ----- 71 |
72 | 73 | 74 | 75 | 76 | ### HOW IT WORKS 77 | 78 | * flask route handlers are decorated to generate, send a uniquely hashed token 79 | * the hashed values are stored on the server, using flask sessions 80 | * the token is sent in the response header `X-XSRF-Token` 81 | * on subsequent client requests.. 82 | * the client will be expected to send the token back to the server 83 | * either through form data, or through the header `X-XSRF-Token` 84 | * to the server receive, validate uniquely hashed tokens 85 | 86 | ![diagram of an xsrf attack](https://cloud.githubusercontent.com/assets/407650/15803515/4ec0a606-2a96-11e6-891c-378f52e6f82b.jpg) 87 | 88 | 89 | **FEATURES** 90 | 91 | * **flexible** - decide which style of implementation suits you best. 92 | * capture, validate `XSRF-Tokens` through headers, cookies, form-fields; the style is an easily configurable choice. 93 | * **timeout** - optionally, you can specify a default time window for valid tokens 94 | * **tested** - used internally @ google. 95 | 96 | 97 | 98 | 99 | ----- 100 |
101 | 102 | 103 | 104 | 105 | ### USAGE 106 | 107 | 108 | **REQUIREMENTS** 109 | 110 | | python | flask | 111 | | ------ | ----- | 112 | | `2.7.6+` | `0.11.0+` | 113 | 114 | 115 |
116 |
117 | 118 | 119 | **INSTALLATION** 120 | 121 | install with pip _(more often it is recommended to lock / specify a specific version)_: 122 | 123 | ```sh 124 | pip install --disable-pip-version-check flask-xsrf 125 | pip install --disable-pip-version-check flask-xsrf==1.0.3 126 | ``` 127 | 128 | 129 | **IMPLEMENTATION** 130 | 131 | implementation of the library with your flask app breaks down into four steps. 132 | 133 | 1: add a `secret_key` to your flask app config object: 134 | 135 | ```py 136 | from flask import Flask 137 | 138 | flask_app = Flask(__name__) 139 | flask_app.secret_key = '<:session_secret_key>' 140 | flask_app.config['session_cookie_secure'] = True 141 | flask_app.config['remember_cookie_name'] = 'testdomain.com' 142 | flask_app.config['remember_cookie_duration_in_days'] = 1 143 | ``` 144 | 145 | 2: create an instance of an `XSRFTokenHandler` object, and specify a method/callable 146 | which will be used as a getter by the token handler to get a `user_id`. 147 | optionally, you can assign auto-generated id's for anonymous requests. 148 | lastly, you may specify a default `timeout`, in number of seconds, to expire 149 | tokens after a specific the amount of time: 150 | 151 | ```py 152 | from flask import Response 153 | from flask import session 154 | import flask_xsrf as xsrf 155 | 156 | @flask_app.before_request 157 | def before_request(): 158 | if 'user_id' not in session: 159 | session['user_id'] = 'random_generated_anonymous_id' 160 | 161 | def get_user_id(): 162 | return session.get('user_id') 163 | 164 | xsrf_handler = xsrf.XSRFTokenHandler( 165 | user_fn=get_user_id, secret='xsrf_secret', timeout=3600) 166 | ``` 167 | 168 | _NOTE: currently, usage of the `session` is required ([see TODO notes below](#todos))._ 169 | 170 | 171 | 3: decorate `GET` request-handlers to send a generated token: 172 | 173 | ```py 174 | @flask_app.route('/test', methods=['GET']) 175 | @xsrf_handler.send_token() 176 | def test_get(): 177 | return Response('success') 178 | ``` 179 | 180 | 181 | 4: decorate `POST` request-handlers to receive, validate sent tokens: 182 | 183 | ```py 184 | @flask_app.route('/test', methods=['POST']) 185 | @xsrf_handler.handle_token() 186 | def test_post(): 187 | return Response('success') 188 | ``` 189 | 190 |
191 | 192 | 193 | ##### TO SUMMARIZE 194 | 195 | that's all there is to it. please feel free to contact me 196 | or to [submit an issue on github](https://github.com/gregorynicholas/flask-xsrf/issues) 197 | for any questions or help. however, creating a fork and submitting pull-requests 198 | are much preferred. contributions will be very much appreciated. 199 | 200 | 201 | 202 | 203 | ----- 204 |
205 | 206 | 207 | 208 | 209 | ### CONTRIBUTING 210 | 211 | 212 | **STAR, FORK THIS PROJECT** 213 | 214 | | `forks` | `stars` | 215 | | -------------- | -------------- | 216 | | [![github forks](https://img.shields.io/github/forks/gregorynicholas/flask-xsrf.svg?style=social&label=Fork&maxAge=2592000?style=flat-square)](https://github.com/gregorynicholas/flask-xsrf/fork) | [![github stars](https://img.shields.io/github/stars/gregorynicholas/flask-xsrf.svg?style=social&label=Star&maxAge=2592000?style=flat-square)](https://github.com/gregorynicholas/flask-xsrf/stargazers) | 217 | 218 | 219 | 220 |
221 | 222 | 223 | **LOCAL ENVIRONMENT SETUP (OSX)** 224 | 225 | here's a list summary of the python environment setup: 226 | 227 | * setup `pyenv`, `pyenv-python-2.7.11` 228 | * setup `virtualenv`, `virtualenvwrapper` 229 | * create project `virtualenv` 230 | * with a command such as `$ mkvirtualenv flask-xsrf-dev` 231 | * install pip dependencies 232 | * install local development build 233 | * `$ python setup.py --verbose develop` 234 | 235 | 236 |
237 | 238 | 239 | **INSTALL PYENV** 240 | 241 | pyenv (aka NVM or RVM for python) allows installing python at specific versions, 242 | and helps to manage multiple versions on osx. install is easy, and can be tricky 243 | to work with, unless you remember to set the proper environment shell variables: 244 | ```sh 245 | init-pyenv(){ 246 | export PYENV_ROOT="$HOME/.pyenv" 247 | export PYENV_PY_VERSION="2.7.11" 248 | export PYENV_VERSION_BIN_DIR="$PYENV_ROOT/versions/$PYENV_PY_VERSION/bin" 249 | export PYENV_BUILD_ROOT="$PYENV_ROOT/sources" 250 | export PYENV_SHIMS_DIR="$PYENV_ROOT/shims" 251 | export PYENV_SHELL="bash" 252 | export PYENV_DEBUG=0 253 | export PATH="$PYENV_ROOT/bin:$PYENV_VERSION_BIN_DIR:$PATH" 254 | } 255 | 256 | init-virtualenv(){ 257 | export VIRTUALENVWRAPPER_SCRIPT="$PYENV_VERSION_BIN_DIR/virtualenvwrapper.sh" 258 | export WORKON_HOME="$HOME/.virtualenvs" 259 | export PIP_VIRTUALENV_BASE=$ 260 | . "$VIRTUALENVWRAPPER_SCRIPT" 261 | } 262 | ``` 263 | 264 | now we're ready to install pyenv/python: 265 | ```sh 266 | init-pyenv 267 | git clone https://github.com/yyuu/pyenv.git "$PYENV_ROOT" 268 | eval "$(pyenv init -)" 269 | pyenv install 2.7.11 && say "pyenv install of python 2.7.11 complete" 270 | pyenv rehash 271 | pyenv global 2.7.11 272 | ``` 273 | 274 | next, configure virtualenv: 275 | ```sh 276 | init-virtualenv 277 | pyenv exec pip install --disable-pip-version-check --upgrade pip 278 | pyenv exec pip install --disable-pip-version-check virtualenv && pyenv rehash 279 | pyenv exec pip install --disable-pip-version-check virtualenvwrapper && pyenv rehash 280 | ``` 281 | 282 | next, create the project virtualenv, install dependencies: 283 | ```sh 284 | mkvirtualenv flask-xsrf-dev && pyenv rehash 285 | workon flask-xsrf-dev && pyenv rehash 286 | pyenv exec pip install --disable-pip-version-check -r .serpent/runtime-config/pip/requirements.txt && pyenv rehash 287 | ``` 288 | 289 | 290 | 291 |
292 | 293 | 294 | 295 | **DEVELOPMENT FLOW** 296 | 297 | * TODO: generate asciicinema movie clip of setup steps..? 298 | * activate the environment 299 | * `$ init-pyenv` 300 | * `$ init-virtualenv` 301 | * `$ eval "$(pyenv init -)"` 302 | * `$ workon flask-xsrf-dev` 303 | * run tests 304 | * `$ python setup.py nosetests` 305 | * view coverage report 306 | * `$ coverage report --show-missing` 307 | 308 |
309 | 310 | viola. that's pretty much all there is to the flow (for now..). 311 | 312 | 313 |
314 | 315 | 316 | ----- 317 |
318 | 319 | 320 | 321 | 322 | 323 | #### TODOs 324 | 325 | * add feature: enable checking of referer headers / client ip-address 326 | * remove hard-coded dependency / usage of `session`. 327 | * add feature: enable storage of tokens in cookie. 328 | * this might help ease implementation, as the client would not have to manually manage passing of tokens to server. 329 | 330 | 331 | ----- 332 |
333 | 334 | 335 | 336 | 337 | #### COPYRIGHT, LICENSE 338 | 339 | the derived work is distributed under the [Apache License Version 2.0](http://opensource.org/licenses/Apache-2.0). 340 | 341 | 342 | 343 |
344 | --------------------------------------------------------------------------------