├── AUTHORS ├── .hgtags ├── .gitignore ├── .hgignore ├── tests ├── conftest.py ├── test_utils.py ├── test_validators.py └── test_form.py ├── .readthedocs.yaml ├── docs ├── Makefile ├── make.bat ├── conf.py └── index.rst ├── pyproject.toml ├── LICENSE ├── examples ├── upload.py └── guestbook.py ├── README.rst └── sanic_wtf └── __init__.py /AUTHORS: -------------------------------------------------------------------------------- 1 | A huge thanks to all of our contributors: 2 | 3 | - Lix Xu 4 | - Philip Xu 5 | - Jeremy Zimmerman 6 | - Mykhailo Keda 7 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | b1002a9daee25b09f5a35ea8b5762e140be7f9fd 0.1.0 2 | 8d273d8a07d1191981af1f475754e23e3a64fc3b 0.2.0 3 | b770c980407037b686da68c7415c1045f9edfe80 0.3.0 4 | 5fa5aaa0f5374a714d0c10a0926dcbccfd88245a 0.4.0 5 | fbe98ee9cac44060f708b93aeae0dd8df0186b33 0.5.0 6 | 7d9aa62ba9c027ce3d055c75b842100446db52ce 0.6.0 7 | da58216c5cd7a0b725038681154785d46286813a 0.7.0 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | 3 | .tags 4 | 5 | *~ 6 | *.swp 7 | *.old 8 | *.bak 9 | *.tmp 10 | *.log 11 | 12 | *.orig 13 | *.patch 14 | *.diff 15 | .deps 16 | *.egg-info 17 | build 18 | _build 19 | htmlcov 20 | dist 21 | MANIFEST 22 | 23 | *.[ao] 24 | *.so 25 | *.py[co] 26 | *.mo 27 | 28 | *.7z 29 | *.gz 30 | *.bz? 31 | *.tbz 32 | *.tgz 33 | *.rar 34 | *.zip 35 | 36 | *.deb 37 | *.rpm 38 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | syntax: regexp 2 | 3 | syntax: glob 4 | .* 5 | 6 | tags 7 | .tags 8 | 9 | *~ 10 | *.swp 11 | *.old 12 | *.bak 13 | *.tmp 14 | *.log 15 | .cache 16 | 17 | *.orig 18 | *.patch 19 | *.diff 20 | .deps 21 | *.egg-info 22 | build 23 | _build 24 | htmlcov 25 | dist 26 | MANIFEST 27 | 28 | *.[ao] 29 | *.so 30 | *.py[co] 31 | *.mo 32 | 33 | *.7z 34 | *.gz 35 | *.bz? 36 | *.tbz 37 | *.tgz 38 | *.rar 39 | *.zip 40 | 41 | *.deb 42 | *.rpm 43 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | from sanic import Sanic 5 | 6 | 7 | @pytest.fixture(scope='function') 8 | def app(): 9 | test_app = Sanic('test_app') 10 | session = {} 11 | 12 | @test_app.middleware('request') 13 | async def add_session(request): 14 | name = request.app.config.get('WTF_CSRF_CONTEXT_NAME', 'session') 15 | setattr(request.ctx, name, session) 16 | 17 | return test_app 18 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - method: pip 23 | path: . 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Sanic-WTF 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from sanic_wtf import ChainRequestParameters 3 | 4 | 5 | def test_chainrequestparameters(): 6 | r1 = { 7 | 'a': [1, 2, 3], 8 | 'b': [4, 5, 6], 9 | } 10 | 11 | r2 = { 12 | 'b': [7, 8, 9], 13 | 'd': [10, 11, 12], 14 | } 15 | 16 | crp = ChainRequestParameters(r1, r2) 17 | 18 | assert crp.get('a') == 1 19 | assert crp.getlist('a') == [1, 2, 3] 20 | assert crp.getlist('b') == [4, 5, 6] 21 | assert crp.get('d') == 10 22 | assert crp.getlist('d') == [10, 11, 12] 23 | 24 | crp = ChainRequestParameters(r2, r1) 25 | 26 | assert crp.get('a') == 1 27 | assert crp.getlist('a') == [1, 2, 3] 28 | assert crp.getlist('b') == [7, 8, 9] 29 | assert crp.get('d') == 10 30 | assert crp.getlist('d') == [10, 11, 12] 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=Sanic-WTF 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Sanic-WTF documentation build configuration file 5 | import os 6 | import sys 7 | 8 | PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) 9 | sys.path.insert(0, PROJECT_DIR) 10 | import sanic_wtf # noqa 11 | 12 | extensions = [ 13 | 'sphinx.ext.autodoc', 14 | 'sphinx.ext.viewcode', 15 | ] 16 | 17 | source_suffix = '.rst' 18 | 19 | master_doc = 'index' 20 | 21 | project = u'Sanic-WTF' 22 | copyright = u'2017, Philip Xu' 23 | author = u'Philip Xu and contributors' 24 | 25 | release = sanic_wtf.__version__ 26 | version = release.rsplit('.', 1)[0] 27 | 28 | language = None 29 | 30 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 31 | 32 | pygments_style = 'sphinx' 33 | 34 | todo_include_todos = False 35 | 36 | html_theme = 'alabaster' 37 | 38 | html_theme_options = { 39 | 'github_banner': True, 40 | 'github_repo': 'sanic-wtf', 41 | 'github_user': 'pyx', 42 | } 43 | 44 | htmlhelp_basename = 'Sanic-WTFdoc' 45 | 46 | latex_documents = [ 47 | (master_doc, 'Sanic-WTF.tex', u'Sanic-WTF Documentation', 48 | u'Philip Xu and contributors', 'manual'), 49 | ] 50 | 51 | man_pages = [ 52 | (master_doc, 'sanic-wtf', u'Sanic-WTF Documentation', 53 | [author], 1) 54 | ] 55 | 56 | texinfo_documents = [ 57 | (master_doc, 'Sanic-WTF', u'Sanic-WTF Documentation', 58 | author, 'Sanic-WTF', 'One line description of project.', 59 | 'Miscellaneous'), 60 | ] 61 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Sanic-WTF" 3 | description = "Sanic-WTF - Sanic meets WTForms" 4 | authors = [ 5 | {name = "Philip Xu", email = "pyx@xrefactor.com"}, 6 | ] 7 | dependencies = [ 8 | "sanic>=21.3.0", 9 | "wtforms>=3.0.1", 10 | ] 11 | dynamic = ["version"] 12 | requires-python = ">=3.7" 13 | readme = "README.rst" 14 | license = {text = "BSD-New"} 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Environment :: Web Environment", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: BSD License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3", 22 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 23 | "Topic :: Software Development :: Libraries :: Python Modules", 24 | ] 25 | 26 | [project.urls] 27 | Homepage = "https://github.com/pyx/sanic-wtf/" 28 | 29 | [build-system] 30 | requires = ["pdm-backend"] 31 | build-backend = "pdm.backend" 32 | 33 | [tool.pdm.dev-dependencies] 34 | doc = [ 35 | "Sphinx>=4.3.2", 36 | ] 37 | lint = [ 38 | "flake8>=5.0.4", 39 | ] 40 | test = [ 41 | "sanic-testing>=23.6.0", 42 | "pytest-cov>=4.1.0", 43 | ] 44 | 45 | [tool.pdm.scripts] 46 | doc_html = {shell = "cd docs; make html; cd .."} 47 | doc_pdf = {shell = "cd docs; make latexpdf; cd .."} 48 | docs = {composite = ["doc_html", "doc_pdf"]} 49 | lint = "flake8 sanic_wtf tests" 50 | test = "pytest" 51 | 52 | [tool.pdm.version] 53 | source = "file" 54 | path = "sanic_wtf/__init__.py" 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Philip Xu and contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | 3. The name of the author may not be used to endorse or promote products 16 | derived from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 19 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 20 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 21 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 22 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 23 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 27 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import namedtuple 3 | from wtforms import FileField 4 | from sanic_wtf import FileAllowed, FileRequired, SanicForm 5 | 6 | 7 | class FileUploadForm(SanicForm): 8 | required = FileField('Required File', validators=[FileRequired()]) 9 | 10 | 11 | class ImageUploadForm(SanicForm): 12 | image = FileField( 13 | 'Image', validators=[FileAllowed('png JpG .jpeg'.split())]) 14 | 15 | 16 | # compatible Sanic File object as of v 0.5.4 17 | File = namedtuple('File', 'type body name') 18 | 19 | 20 | def test_file_required(): 21 | data = {'required': File(type='', body=b'', name='')} 22 | form = FileUploadForm(data=data) 23 | assert not form.validate() 24 | 25 | data = {'required': File(type='', body=b'', name='fake-file.lol')} 26 | form = FileUploadForm(data=data) 27 | assert form.validate() 28 | 29 | 30 | def test_file_allowed(): 31 | data = {'image': File(type='', body=b'', name='')} 32 | form = ImageUploadForm(data=data) 33 | # okay, because FileAllowed is fine with empty field 34 | assert form.validate() 35 | 36 | data = {'image': File(type='', body=b'import this', name='left-pad.js')} 37 | form = ImageUploadForm(data=data) 38 | assert not form.validate() 39 | 40 | data = {'image': File(type='', body=b'=^o^=', name='sanic.jpg')} 41 | form = ImageUploadForm(data=data) 42 | assert form.validate() 43 | 44 | data = {'image': File(type='', body=b'=^o^=', name='sanic.JPEG')} 45 | form = ImageUploadForm(data=data) 46 | assert form.validate() 47 | 48 | data = {'image': File(type='', body=b'=^o^=', name='sanic.exe.png')} 49 | form = ImageUploadForm(data=data) 50 | assert form.validate() 51 | 52 | data = {'image': File(type='', body=b'=^o^=', name='sanic.png.exe')} 53 | form = ImageUploadForm(data=data) 54 | assert not form.validate() 55 | 56 | # just for the record... not that this is a good idea 57 | data = {'image': File(type='', body=b'=^o^=', name='.png')} 58 | form = ImageUploadForm(data=data) 59 | assert form.validate() 60 | 61 | 62 | def test_empty_file(): 63 | form = ImageUploadForm(data={}) 64 | assert form.validate() 65 | 66 | # NOTE: this is a reminder, being validated dose not mean file exists 67 | assert not form.image.data 68 | -------------------------------------------------------------------------------- /examples/upload.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from sanic import Sanic, response 3 | from sanic_wtf import FileAllowed, FileRequired, SanicForm 4 | from wtforms import FileField, SubmitField, StringField 5 | from wtforms.validators import Length 6 | 7 | 8 | app = Sanic(__name__) 9 | app.config['SECRET_KEY'] = 'top secret !!!' 10 | app.config['UPLOAD_DIR'] = './uploaded.tmp' 11 | 12 | 13 | # NOTE 14 | # session should be setup somewhere, SanicWTF expects request.ctx.session is a 15 | # dict like session object. 16 | # For demonstration purpose, we use a mock-up globally-shared session object. 17 | session = {} 18 | 19 | 20 | @app.middleware('request') 21 | async def add_session(request): 22 | request.ctx.session = session 23 | 24 | 25 | class UploadForm(SanicForm): 26 | image = FileField('Image', validators=[ 27 | FileRequired(), FileAllowed('bmp gif jpg jpeg png'.split())]) 28 | description = StringField('Description', validators=[Length(max=20)]) 29 | submit = SubmitField('Upload') 30 | 31 | 32 | app.static('/img', app.config.UPLOAD_DIR) 33 | 34 | 35 | @app.listener('after_server_start') 36 | async def make_upload_dir(app, loop): 37 | Path(app.config.UPLOAD_DIR).mkdir(parents=True, exist_ok=True) 38 | 39 | 40 | @app.route('/', methods=['GET', 'POST']) 41 | async def index(request): 42 | form = UploadForm(request) 43 | if form.validate_on_submit(): 44 | image = form.image.data 45 | # NOTE: trusting user submitted file names here, the name should be 46 | # sanitized in production. 47 | uploaded_file = Path(request.app.config.UPLOAD_DIR) / image.name 48 | uploaded_file.write_bytes(image.body) 49 | description = form.description.data or 'no description' 50 | session.setdefault('files', []).append((image.name, description)) 51 | return response.redirect('/') 52 | img = '

{}


' 53 | images = ''.join(img.format(i, d) for i, d in session.get('files', [])) 54 | content = f""" 55 |

Sanic-WTF file field validators example

56 | {images} 57 |
58 | {'
'.join(form.csrf_token.errors)} 59 | {form.csrf_token} 60 | {'
'.join(form.image.errors)} 61 | {'
'.join(form.description.errors)} 62 |
{form.image.label} 63 |
{form.image} 64 |
{form.description.label} 65 |
{form.description(size=20, placeholder="description")} 66 |
{form.submit} 67 |
68 | """ 69 | return response.html(content) 70 | 71 | 72 | if __name__ == '__main__': 73 | app.run(host='127.0.0.1', port=8000, debug=True) 74 | -------------------------------------------------------------------------------- /examples/guestbook.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sanic import Sanic, response 3 | from sanic_wtf import SanicForm 4 | from wtforms.fields import SubmitField, TextAreaField 5 | from wtforms.validators import DataRequired, Length 6 | 7 | 8 | app = Sanic(__name__) 9 | app.config['SECRET_KEY'] = 'top secret !!!' 10 | 11 | 12 | # NOTE 13 | # session should be setup somewhere, SanicWTF expects request.ctx.session is a 14 | # dict like session object. 15 | # For demonstration purpose, we use a mock-up globally-shared session object. 16 | session = {} 17 | @app.middleware('request') 18 | async def add_session(request): 19 | request.ctx.session = session 20 | 21 | 22 | class FeedbackForm(SanicForm): 23 | note = TextAreaField('Note', validators=[DataRequired(), Length(max=40)]) 24 | submit = SubmitField('Submit') 25 | 26 | 27 | @app.route('/', methods=['GET', 'POST']) 28 | async def index(request): 29 | form = FeedbackForm(request) 30 | if request.method == 'POST' and form.validate(): 31 | note = form.note.data 32 | msg = '{} - {}'.format(datetime.now(), note) 33 | session.setdefault('fb', []).append(msg) 34 | return response.redirect('/') 35 | # NOTE: trusting user input here, never do that in production 36 | feedback = ''.join('

{}

'.format(m) for m in session.get('fb', [])) 37 | content = f""" 38 |

Form with CSRF Validation

39 |

Try form that fails CSRF validation

40 | {feedback} 41 |
42 | {'
'.join(form.csrf_token.errors)} 43 | {form.csrf_token} 44 | {'
'.join(form.note.errors)} 45 |
46 | {form.note(size=40, placeholder="say something..")} 47 | {form.submit} 48 |
49 | """ 50 | return response.html(content) 51 | 52 | 53 | @app.route('/fail', methods=['GET', 'POST']) 54 | async def fail(request): 55 | form = FeedbackForm(request) 56 | if request.method == 'POST' and form.validate(): 57 | note = form.note.data 58 | msg = '{} - {}'.format(datetime.now(), note) 59 | session.setdefault('fb', []).append(msg) 60 | return response.redirect('/fail') 61 | feedback = ''.join('

{}

'.format(m) for m in session.get('fb', [])) 62 | content = f""" 63 |

Form which fails CSRF Validation

64 |

This is the same as this form except that CSRF 65 | validation always fail because we did not render the hidden csrf token

66 | {feedback} 67 |
68 | {'
'.join(form.csrf_token.errors)} 69 | {'
'.join(form.note.errors)} 70 |
71 | {form.note(size=40, placeholder="say something..")} 72 | {form.submit} 73 |
74 | """ 75 | return response.html(content) 76 | 77 | 78 | if __name__ == '__main__': 79 | app.run(host='127.0.0.1', port=8000, debug=True) 80 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | Sanic-WTF - Sanic meets WTForms 3 | =============================== 4 | 5 | Sanic-WTF makes using `WTForms`_ with `Sanic`_ and CSRF (Cross-Site Request 6 | Forgery) protection a little bit easier. 7 | 8 | 9 | .. _WTForms: https://wtforms.readthedocs.io/en/3.0.x/ 10 | .. _Sanic: https://github.com/channelcat/sanic 11 | 12 | 13 | Quick Start 14 | =========== 15 | 16 | 17 | Installation 18 | ------------ 19 | 20 | .. code-block:: sh 21 | 22 | pip install --upgrade Sanic-WTF 23 | 24 | 25 | How to use it 26 | ------------- 27 | 28 | 29 | Intialization (of Sanic) 30 | ^^^^^^^^^^^^^^^^^^^^^^^^ 31 | 32 | .. code-block:: python 33 | 34 | from sanic import Sanic 35 | 36 | app = Sanic(__name__) 37 | 38 | # either WTF_CSRF_SECRET_KEY or SECRET_KEY should be set 39 | app.config['WTF_CSRF_SECRET_KEY'] = 'top secret!' 40 | 41 | @app.middleware('request') 42 | async def add_session_to_request(request): 43 | # setup session 44 | 45 | 46 | Defining Forms 47 | ^^^^^^^^^^^^^^ 48 | 49 | .. code-block:: python 50 | 51 | from sanic_wtf import SanicForm 52 | from wtforms.fields import PasswordField, StringField, SubmitField 53 | from wtforms.validators import DataRequired 54 | 55 | class LoginForm(SanicForm): 56 | name = StringField('Name', validators=[DataRequired()]) 57 | password = PasswordField('Password', validators=[DataRequired()]) 58 | submit = SubmitField('Sign In') 59 | 60 | That's it, just subclass `SanicForm` and later on passing in the current 61 | `request` object when you instantiate the form class. Sanic-WTF will do the 62 | trick. 63 | 64 | 65 | Form Validation 66 | ^^^^^^^^^^^^^^^ 67 | 68 | .. code-block:: python 69 | 70 | from sanic import response 71 | 72 | @app.route('/', methods=['GET', 'POST']) 73 | async def index(request): 74 | form = LoginForm(request) 75 | if request.method == 'POST' and form.validate(): 76 | name = form.name.data 77 | password = form.password.data 78 | # check user password, log in user, etc. 79 | return response.redirect('/profile') 80 | # here, render_template is a function that render template with context 81 | return response.html(await render_template('index.html', form=form)) 82 | 83 | 84 | .. note:: 85 | For WTForms users: please note that `SanicForm` requires the whole `request` 86 | object instead of some sort of `MultiDict`. 87 | 88 | 89 | For more details, please see documentation. 90 | 91 | 92 | License 93 | ======= 94 | 95 | BSD New, see LICENSE for details. 96 | 97 | 98 | Links 99 | ===== 100 | 101 | - `Documentation `_ 102 | 103 | - `Issue Tracker `_ 104 | 105 | - `Source Package @ PyPI `_ 106 | 107 | - `Git Repository @ Github 108 | `_ 109 | 110 | - `Git Repository @ Gitlab 111 | `_ 112 | 113 | - `Development Version 114 | `_ 115 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | 4 | Prerequisites 5 | ============= 6 | 7 | To enable CSRF protection, a session is required, Sanic-WTF expects 8 | :code:`request.ctx.session` is available in this case. For a simple client side 9 | only, cookie-based session, similar to Flask's built-in session, you might want 10 | to try `Sanic-CookieSession`_. 11 | 12 | .. _Sanic-CookieSession: https://github.com/pyx/sanic-cookiesession 13 | 14 | 15 | Configuration 16 | ============= 17 | 18 | ================================ ============================================= 19 | Option Description 20 | ================================ ============================================= 21 | :code:`WTF_CSRF_ENABLED` If :code:`True`, CSRF protection is enabled. 22 | Default is :code:`True` 23 | :code:`WTF_CSRF_FIELD_NAME` The field name used in the form and session 24 | to store the CSRF token. Default is 25 | `csrf_token` 26 | :code:`WTF_CSRF_SECRET_KEY` :code:`bytes` used for CSRF token generation. 27 | If it is unset, :code:`SECRET_KEY` will be 28 | used instead. Either one of these have to be 29 | set to enable CSRF protection. 30 | :code:`WTF_CSRF_TIME_LIMIT` How long CSRF tokens are valid for, in seconds. 31 | Default is `1800`. (Half an hour) 32 | ================================ ============================================= 33 | 34 | 35 | API 36 | === 37 | 38 | .. note:: 39 | For users of versions prior to 0.3.0, there is backward incompatible changes 40 | in API. The module-level helper object is not longer required, the new form 41 | :class:`SanicForm` is smart enough to figure out how to get user defined 42 | settings. 43 | 44 | 45 | .. automodule:: sanic_wtf 46 | :members: 47 | 48 | 49 | Full Example 50 | ============ 51 | 52 | 53 | Guest Book 54 | ---------- 55 | 56 | .. literalinclude:: ../examples/guestbook.py 57 | 58 | 59 | File Upload 60 | ----------- 61 | 62 | .. literalinclude:: ../examples/upload.py 63 | 64 | 65 | Changelog 66 | ========= 67 | 68 | - 0.7.0 69 | 70 | **backward incompatible upgrade** 71 | 72 | Upgraded to Sanic 21.3.0 and WTForms 3.0.1 73 | Minimum python version 3.7 74 | 75 | - 0.6.0 76 | 77 | **backward incompatible upgrade** 78 | 79 | Supporting python 3.6, 2.7, 3.8, 3.9, and Sanic 20.3.0 80 | 81 | - 0.5.0 82 | 83 | Added file upload support and filefield validators :class:`FileAllowed` and 84 | :class:`FileRequired`. 85 | 86 | - 0.4.0 87 | 88 | **backward incompatible upgrade** 89 | 90 | Removed property hidden_tag 91 | 92 | - 0.3.0 93 | 94 | **backward incompatible upgrade** 95 | 96 | Re-designed the API to fixed #6 - possible race condition. The new API is 97 | much simplified, easier to use, while still getting things done, and more. 98 | 99 | Added new setting: WTF_CSRF_TIME_LIMIT 100 | Added new method :meth:`validate_on_submit` in the style of Flask-WTF. 101 | 102 | - 0.2.0 103 | 104 | Made :attr:`SanicWTF.Form` always available so that one can create the form 105 | classes before calling :meth:`SanicWTF.init_app` 106 | 107 | - 0.1.0 108 | 109 | First public release. 110 | 111 | 112 | Indices and tables 113 | ================== 114 | 115 | * :ref:`genindex` 116 | * :ref:`modindex` 117 | * :ref:`search` 118 | -------------------------------------------------------------------------------- /sanic_wtf/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import ChainMap 3 | from datetime import timedelta 4 | from itertools import chain 5 | 6 | from wtforms.form import Form 7 | from wtforms.csrf.session import SessionCSRF 8 | from wtforms.meta import DefaultMeta 9 | from wtforms.validators import DataRequired, StopValidation 10 | 11 | __version__ = '0.7.0' 12 | 13 | __all__ = [ 14 | 'SanicForm', 15 | 'FileAllowed', 'file_allowed', 'FileRequired', 'file_required', 16 | ] 17 | 18 | 19 | def to_bytes(text, encoding='utf8'): 20 | if isinstance(text, str): 21 | return text.encode(encoding) 22 | return bytes(text) 23 | 24 | 25 | def meta_for_request(request): 26 | """Create a meta dict object with settings from request.app""" 27 | meta = {'csrf': False} 28 | if not request: 29 | return meta 30 | config = request.app.config 31 | 32 | csrf = meta['csrf'] = config.get('WTF_CSRF_ENABLED', True) 33 | if not csrf: 34 | return meta 35 | 36 | meta['csrf_field_name'] = config.get('WTF_CSRF_FIELD_NAME', 'csrf_token') 37 | secret = config.get('WTF_CSRF_SECRET_KEY') 38 | if secret is None: 39 | secret = config.get('SECRET_KEY') 40 | if not secret: 41 | raise ValueError( 42 | 'CSRF protection needs either WTF_CSRF_SECRET_KEY or SECRET_KEY') 43 | meta['csrf_secret'] = to_bytes(secret) 44 | 45 | seconds = config.get('WTF_CSRF_TIME_LIMIT', 1800) 46 | meta['csrf_time_limit'] = timedelta(seconds=seconds) 47 | 48 | name = config.get('WTF_CSRF_CONTEXT_NAME', 'session') 49 | req = request.ctx.__dict__ if hasattr(request, 'ctx') else request 50 | meta['csrf_context'] = req[name] 51 | return meta 52 | 53 | 54 | SUBMIT_VERBS = frozenset({'DELETE', 'PATCH', 'POST', 'PUT'}) 55 | 56 | 57 | sentinel = object() 58 | 59 | 60 | class FileRequired(DataRequired): 61 | """Validate that the data is a non-empty `sanic.request.File` object""" 62 | def __call__(self, form, field): 63 | # type sanic.request.File as of v 0.5.4 is: 64 | # File = namedtuple('File', ['type', 'body', 'name']) 65 | # here, we check whether the name contains anything 66 | if not getattr(field.data, 'name', ''): 67 | msg = self.message or field.gettext('This field is required.') 68 | raise StopValidation(msg) 69 | 70 | 71 | file_required = FileRequired 72 | 73 | 74 | class FileAllowed: 75 | """Validate that the file (by extention) is one of the listed types""" 76 | def __init__(self, extensions, message=None): 77 | extensions = (ext.lower() for ext in extensions) 78 | extensions = ( 79 | ext if ext.startswith('.') else '.' + ext for ext in extensions) 80 | self.extensions = frozenset(extensions) 81 | self.message = message 82 | 83 | def __call__(self, form, field): 84 | filename = getattr(field.data, 'name', '') 85 | if not filename: 86 | return 87 | 88 | filename = filename.lower() 89 | # testing with .endswith instead of the fastest `in` test, because 90 | # there may be extensions with more than one dot (.), e.g. ".tar.gz" 91 | if any(filename.endswith(ext) for ext in self.extensions): 92 | return 93 | 94 | raise StopValidation(self.message or field.gettext( 95 | 'File type does not allowed.')) 96 | 97 | 98 | file_allowed = FileAllowed 99 | 100 | 101 | class ChainRequestParameters(ChainMap): 102 | """ChainMap with sanic.RequestParameters style API""" 103 | def get(self, name, default=None): 104 | """Return the first element with key `name`""" 105 | return super().get(name, [default])[0] 106 | 107 | def getlist(self, name, default=None): 108 | """Return all elements with key `name` 109 | 110 | Only elementes of the first chained map with such key are return. 111 | """ 112 | return super().get(name, default) 113 | 114 | 115 | class SanicForm(Form): 116 | """Form with session-based CSRF Protection. 117 | 118 | Upon initialization, the form instance will setup CSRF protection with 119 | settings fetched from provided Sanic style request object. With no 120 | request object provided, CSRF protection will be disabled. 121 | """ 122 | class Meta(DefaultMeta): 123 | csrf = True 124 | csrf_class = SessionCSRF 125 | 126 | def __init__(self, request=None, *args, meta=None, **kwargs): 127 | form_meta = meta_for_request(request) 128 | form_meta.update(meta or {}) 129 | kwargs['meta'] = form_meta 130 | 131 | self.request = request 132 | if request is not None: 133 | formdata = kwargs.pop('formdata', sentinel) 134 | if formdata is sentinel: 135 | if request.files: 136 | formdata = ChainRequestParameters( 137 | request.form, request.files) 138 | else: 139 | formdata = request.form 140 | # signature of wtforms.Form (formdata, obj, prefix, ...) 141 | args = chain([formdata], args) 142 | 143 | super().__init__(*args, **kwargs) 144 | 145 | def validate_on_submit(self): 146 | """Return `True` if this form is submited and all fields verified""" 147 | request = self.request 148 | return request and request.method in SUBMIT_VERBS and self.validate() 149 | -------------------------------------------------------------------------------- /tests/test_form.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import os.path 4 | 5 | from sanic import response 6 | from wtforms.validators import DataRequired, Length 7 | from wtforms import FileField, StringField, SubmitField 8 | 9 | from sanic_wtf import SanicForm, to_bytes 10 | 11 | 12 | # NOTE 13 | # taking shortcut here, assuming there will be only one "string" (the token) 14 | # ever longer than 40. 15 | csrf_token_pattern = '''value="([0-9a-f#]{40,})"''' 16 | 17 | 18 | def render_form(form, multipart=False): 19 | if multipart is True: 20 | multipart = ' enctype="multipart/form-data"' 21 | return """ 22 |
23 | {} 24 |
""".format(multipart, ''.join(str(field) for field in form)) 25 | 26 | 27 | def test_form_validation(app): 28 | app.config['WTF_CSRF_ENABLED'] = False 29 | 30 | class TestForm(SanicForm): 31 | msg = StringField('Note', validators=[DataRequired(), Length(max=10)]) 32 | submit = SubmitField('Submit') 33 | 34 | @app.route('/', methods=['GET', 'POST']) 35 | async def index(request): 36 | form = TestForm(request) 37 | if request.method == 'POST' and form.validate(): 38 | return response.text('validated') 39 | content = render_form(form) 40 | return response.html(content) 41 | 42 | req, resp = app.test_client.get('/') 43 | assert resp.status == 200 44 | # we disabled it 45 | assert 'csrf_token' not in resp.text 46 | 47 | # this is longer than 10 48 | payload = {'msg': 'love is beautiful'} 49 | req, resp = app.test_client.post('/', data=payload) 50 | assert resp.status == 200 51 | assert 'validated' not in resp.text 52 | 53 | payload = {'msg': 'happy'} 54 | req, resp = app.test_client.post('/', data=payload) 55 | assert resp.status == 200 56 | assert 'validated' in resp.text 57 | 58 | 59 | def test_form_csrf_validation(app): 60 | app.config['WTF_CSRF_SECRET_KEY'] = 'top secret !!!' 61 | 62 | class TestForm(SanicForm): 63 | msg = StringField('Note', validators=[DataRequired(), Length(max=10)]) 64 | submit = SubmitField('Submit') 65 | 66 | @app.route('/', methods=['GET', 'POST']) 67 | async def index(request): 68 | form = TestForm(request) 69 | if request.method == 'POST' and form.validate(): 70 | return response.text('validated') 71 | content = render_form(form) 72 | return response.html(content) 73 | 74 | req, resp = app.test_client.get('/') 75 | assert resp.status == 200 76 | assert 'csrf_token' in resp.text 77 | token = re.findall(csrf_token_pattern, resp.text)[0] 78 | assert token 79 | 80 | payload = {'msg': 'happy', 'csrf_token': token} 81 | req, resp = app.test_client.post('/', data=payload) 82 | assert resp.status == 200 83 | assert 'validated' in resp.text 84 | 85 | payload = {'msg': 'happy'} 86 | req, resp = app.test_client.post('/', data=payload) 87 | assert resp.status == 200 88 | # should fail, no CSRF token in payload 89 | assert 'validated' not in resp.text 90 | 91 | 92 | def test_secret_key_required(app): 93 | assert app.config.get('SECRET_KEY') is None 94 | assert app.config.get('WTF_CSRF_SECRET_KEY') is None 95 | 96 | @app.route('/') 97 | async def index(request): 98 | form = SanicForm(request) 99 | return response.text(form) 100 | 101 | req, resp = app.test_client.get('/', debug=True) 102 | # the server should render ValueError: no secret key message with 500 103 | assert resp.status == 500 104 | assert 'ValueError' in resp.text 105 | 106 | 107 | def test_csrf_token(app): 108 | app.config['WTF_CSRF_SECRET_KEY'] = 'top secret !!!' 109 | app.config['WTF_CSRF_FIELD_NAME'] = 'csrf_token' 110 | 111 | class TestForm(SanicForm): 112 | msg = StringField('Note', validators=[DataRequired(), Length(max=10)]) 113 | submit = SubmitField('Submit') 114 | 115 | @app.route('/', methods=['GET', 'POST']) 116 | async def index(request): 117 | form = TestForm(request) 118 | return response.html(form.csrf_token) 119 | 120 | req, resp = app.test_client.get('/') 121 | assert resp.status == 200 122 | assert 'csrf_token' in resp.text 123 | token = re.findall(csrf_token_pattern, resp.text)[0] 124 | assert token 125 | 126 | 127 | def test_no_request_disable_csrf(app): 128 | app.config['WTF_CSRF_ENABLED'] = True 129 | app.config['WTF_CSRF_SECRET_KEY'] = 'look ma' 130 | 131 | class TestForm(SanicForm): 132 | msg = StringField('Note', validators=[DataRequired(), Length(max=10)]) 133 | submit = SubmitField('Submit') 134 | 135 | @app.route('/', methods=['GET', 'POST']) 136 | async def index(request): 137 | form = TestForm(formdata=request.form) 138 | if request.method == 'POST' and form.validate(): 139 | return response.text('validated') 140 | content = render_form(form) 141 | return response.html(content) 142 | 143 | payload = {'msg': 'happy'} 144 | req, resp = app.test_client.post('/', data=payload) 145 | assert resp.status == 200 146 | # should be okay, no request means CSRF was disabled 147 | assert 'validated' in resp.text 148 | 149 | 150 | def test_validate_on_submit(app): 151 | app.config['WTF_CSRF_SECRET_KEY'] = 'top secret !!!' 152 | 153 | class TestForm(SanicForm): 154 | msg = StringField('Note', validators=[DataRequired(), Length(max=10)]) 155 | submit = SubmitField('Submit') 156 | 157 | @app.route('/', methods=['GET', 'POST']) 158 | async def index(request): 159 | form = TestForm(request) 160 | if form.validate_on_submit(): 161 | return response.text('validated') 162 | content = render_form(form) 163 | return response.html(content) 164 | 165 | req, resp = app.test_client.get('/') 166 | assert resp.status == 200 167 | assert 'csrf_token' in resp.text 168 | token = re.findall(csrf_token_pattern, resp.text)[0] 169 | assert token 170 | 171 | payload = {'msg': 'happy', 'csrf_token': token} 172 | req, resp = app.test_client.post('/', data=payload) 173 | assert resp.status == 200 174 | assert 'validated' in resp.text 175 | 176 | 177 | def test_file_upload(app): 178 | app.config['WTF_CSRF_ENABLED'] = False 179 | 180 | class TestForm(SanicForm): 181 | upload = FileField('upload file') 182 | submit = SubmitField('Upload') 183 | 184 | @app.route('/upload', methods=['GET', 'POST']) 185 | async def upload(request): 186 | form = TestForm(request) 187 | if form.validate_on_submit(): 188 | return response.text(form.upload.data.name) 189 | content = render_form(form) 190 | return response.html(content) 191 | 192 | req, resp = app.test_client.post( 193 | '/upload', files={'upload': open(__file__, 'rb')}, data={}) 194 | assert resp.status == 200 195 | assert resp.text == os.path.basename(__file__) 196 | 197 | 198 | def test_to_bytes(): 199 | assert isinstance(to_bytes(bytes()), bytes) 200 | assert isinstance(to_bytes(str()), bytes) 201 | --------------------------------------------------------------------------------