├── requirements ├── tests.in ├── style.in ├── dev.in ├── docs.in ├── tests.txt ├── style.txt ├── dev.txt └── docs.txt ├── docs ├── contributing.rst ├── license.rst ├── _static │ ├── flask-wtf.png │ └── flask-wtf-icon.png ├── Makefile ├── api.rst ├── install.rst ├── make.bat ├── index.rst ├── conf.py ├── quickstart.rst ├── config.rst ├── csrf.rst ├── form.rst └── changes.rst ├── MANIFEST.in ├── src └── flask_wtf │ ├── recaptcha │ ├── __init__.py │ ├── fields.py │ ├── widgets.py │ └── validators.py │ ├── _compat.py │ ├── __init__.py │ ├── i18n.py │ ├── form.py │ ├── file.py │ └── csrf.py ├── .github ├── dependabot.yml ├── workflows │ ├── lock.yaml │ ├── pre-commit.yaml │ ├── tests.yaml │ └── publish.yaml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.md │ └── bug-report.md └── pull_request_template.md ├── .gitignore ├── .editorconfig ├── .readthedocs.yaml ├── .pre-commit-config.yaml ├── README.rst ├── examples ├── babel │ ├── templates │ │ └── index.html │ └── app.py ├── uploadr │ ├── templates │ │ └── index.html │ └── app.py └── recaptcha │ ├── templates │ └── index.html │ └── app.py ├── tests ├── conftest.py ├── test_i18n.py ├── test_csrf_form.py ├── test_form.py ├── test_recaptcha.py ├── test_csrf_extension.py └── test_file.py ├── tox.ini ├── LICENSE.rst ├── pyproject.toml └── CONTRIBUTING.rst /requirements/tests.in: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /requirements/style.in: -------------------------------------------------------------------------------- 1 | pre-commit 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /requirements/dev.in: -------------------------------------------------------------------------------- 1 | -r docs.in 2 | -r tests.in 3 | pip-compile-multi 4 | pre-commit 5 | tox 6 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | BSD-3-Clause License 2 | ==================== 3 | 4 | .. include:: ../LICENSE.rst 5 | -------------------------------------------------------------------------------- /docs/_static/flask-wtf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/flask-wtf/main/docs/_static/flask-wtf.png -------------------------------------------------------------------------------- /requirements/docs.in: -------------------------------------------------------------------------------- 1 | Pallets-Sphinx-Themes 2 | Sphinx 3 | sphinx-issues 4 | sphinxcontrib-log-cabinet 5 | -------------------------------------------------------------------------------- /docs/_static/flask-wtf-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/flask-wtf/main/docs/_static/flask-wtf-icon.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include tox.ini 2 | include requirements/*.txt 3 | include CONTRIBUTING.rst 4 | graft docs 5 | prune docs/_build 6 | graft examples 7 | graft tests 8 | global-exclude *.pyc 9 | -------------------------------------------------------------------------------- /src/flask_wtf/recaptcha/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import RecaptchaField 2 | from .validators import Recaptcha 3 | from .widgets import RecaptchaWidget 4 | 5 | __all__ = ["RecaptchaField", "RecaptchaWidget", "Recaptcha"] 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | day: "monday" 8 | time: "16:00" 9 | timezone: "UTC" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.vscode/ 3 | /env/ 4 | /venv/ 5 | /.venv/ 6 | __pycache__/ 7 | *.pyc 8 | *.egg-info 9 | /build/ 10 | /dist/ 11 | /.tox/ 12 | /.pytest_cache/ 13 | /.mypy_cache/ 14 | /docs/_build/ 15 | .coverage 16 | .coverage.* 17 | /htmlcov/ 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | charset = utf-8 10 | max_line_length = 88 11 | 12 | [*.{yml,yaml,json,js,css,html}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-24.04 4 | tools: 5 | python: '3.13' 6 | python: 7 | install: 8 | - requirements: requirements/docs.txt 9 | - method: pip 10 | path: . 11 | sphinx: 12 | builder: dirhtml 13 | fail_on_warning: true 14 | configuration: docs/conf.py 15 | -------------------------------------------------------------------------------- /src/flask_wtf/_compat.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | 4 | class FlaskWTFDeprecationWarning(DeprecationWarning): 5 | pass 6 | 7 | 8 | warnings.simplefilter("always", FlaskWTFDeprecationWarning) 9 | warnings.filterwarnings( 10 | "ignore", category=FlaskWTFDeprecationWarning, module="wtforms|flask_wtf" 11 | ) 12 | -------------------------------------------------------------------------------- /.github/workflows/lock.yaml: -------------------------------------------------------------------------------- 1 | name: 'Lock threads' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | lock: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: dessant/lock-threads@v5 12 | with: 13 | github-token: ${{ github.token }} 14 | issue-inactive-days: 14 15 | pr-inactive-days: 14 16 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | # SHA1:0eaa389e1fdb3a1917c0f987514bd561be5718ee 2 | # 3 | # This file is autogenerated by pip-compile-multi 4 | # To update, run: 5 | # 6 | # pip-compile-multi 7 | # 8 | iniconfig==2.0.0 9 | # via pytest 10 | packaging==24.1 11 | # via pytest 12 | pluggy==1.5.0 13 | # via pytest 14 | pytest==8.3.3 15 | # via -r requirements/tests.in 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions 4 | url: https://stackoverflow.com/questions/tagged/flask-wtforms?tab=Frequent 5 | about: Search for and ask questions about your code on Stack Overflow. 6 | - name: Questions and discussions 7 | url: https://discord.gg/pallets 8 | about: Discuss questions about your code on our Discord chat. 9 | -------------------------------------------------------------------------------- /src/flask_wtf/__init__.py: -------------------------------------------------------------------------------- 1 | from .csrf import CSRFProtect 2 | from .form import FlaskForm 3 | from .form import Form 4 | from .recaptcha import Recaptcha 5 | from .recaptcha import RecaptchaField 6 | from .recaptcha import RecaptchaWidget 7 | 8 | __version__ = "1.2.2" 9 | __all__ = [ 10 | "CSRFProtect", 11 | "FlaskForm", 12 | "Form", 13 | "Recaptcha", 14 | "RecaptchaField", 15 | "RecaptchaWidget", 16 | ] 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.7.0 4 | hooks: 5 | - id: ruff 6 | - id: ruff-format 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: check-merge-conflict 11 | - id: debug-statements 12 | - id: fix-byte-order-marker 13 | - id: trailing-whitespace 14 | - id: end-of-file-fixer 15 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Flask-WTF 2 | ========= 3 | 4 | Simple integration of Flask and WTForms, including CSRF, file upload, 5 | and reCAPTCHA. 6 | 7 | Links 8 | ----- 9 | 10 | - Documentation: https://flask-wtf.readthedocs.io/ 11 | - Changes: https://flask-wtf.readthedocs.io/changes/ 12 | - PyPI Releases: https://pypi.org/project/Flask-WTF/ 13 | - Source Code: https://github.com/pallets-eco/flask-wtf/ 14 | - Issue Tracker: https://github.com/pallets-eco/flask-wtf/issues/ 15 | - Chat: https://discord.gg/pallets 16 | -------------------------------------------------------------------------------- /examples/babel/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

zh en

6 |
7 | {{ form.hidden_tag() }} 8 | {% for error in form.name.errors %} 9 |

{{ error }}

10 | {% endfor %} 11 |

12 | {{ form.name.label }} {{ form.name() }} 13 |

14 |

15 | 16 |

17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for Flask-WTF 4 | --- 5 | 6 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/flask_wtf/recaptcha/fields.py: -------------------------------------------------------------------------------- 1 | from wtforms.fields import Field 2 | 3 | from . import widgets 4 | from .validators import Recaptcha 5 | 6 | __all__ = ["RecaptchaField"] 7 | 8 | 9 | class RecaptchaField(Field): 10 | widget = widgets.RecaptchaWidget() 11 | 12 | # error message if recaptcha validation fails 13 | recaptcha_error = None 14 | 15 | def __init__(self, label="", validators=None, **kwargs): 16 | validators = validators or [Recaptcha()] 17 | super().__init__(label, validators, **kwargs) 18 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | on: 3 | pull_request: 4 | push: 5 | branches: [main, '*.x'] 6 | jobs: 7 | main: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 11 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 12 | with: 13 | python-version: 3.x 14 | - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 15 | - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 16 | if: ${{ !cancelled() }} 17 | -------------------------------------------------------------------------------- /requirements/style.txt: -------------------------------------------------------------------------------- 1 | # SHA1:5a0b1bb22ae805d8aebba0f3bf05ab91aceae0d8 2 | # 3 | # This file is autogenerated by pip-compile-multi 4 | # To update, run: 5 | # 6 | # pip-compile-multi 7 | # 8 | cfgv==3.4.0 9 | # via pre-commit 10 | distlib==0.3.9 11 | # via virtualenv 12 | filelock==3.16.1 13 | # via virtualenv 14 | identify==2.6.1 15 | # via pre-commit 16 | nodeenv==1.9.1 17 | # via pre-commit 18 | platformdirs==4.3.6 19 | # via virtualenv 20 | pre-commit==4.0.1 21 | # via -r requirements/style.in 22 | pyyaml==6.0.2 23 | # via pre-commit 24 | virtualenv==20.27.0 25 | # via pre-commit 26 | -------------------------------------------------------------------------------- /examples/uploadr/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% for upload in filedata %} 6 |

{{ upload.data.filename }}

7 | {% endfor %} 8 |
9 | {{ form.errors }} 10 | {{ form.hidden_tag() }} 11 | {% for upload in form.uploads.entries %} 12 |

13 | {{ upload }} 14 |

15 | {% endfor %} 16 |

17 | 18 |

19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 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) 21 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import Flask as _Flask 3 | 4 | 5 | class Flask(_Flask): 6 | testing = True 7 | secret_key = __name__ 8 | 9 | def make_response(self, rv): 10 | if rv is None: 11 | rv = "" 12 | 13 | return super().make_response(rv) 14 | 15 | 16 | @pytest.fixture 17 | def app(): 18 | app = Flask(__name__) 19 | return app 20 | 21 | 22 | @pytest.fixture 23 | def app_ctx(app): 24 | with app.app_context() as ctx: 25 | yield ctx 26 | 27 | 28 | @pytest.fixture 29 | def req_ctx(app): 30 | with app.test_request_context() as ctx: 31 | yield ctx 32 | 33 | 34 | @pytest.fixture 35 | def client(app): 36 | return app.test_client() 37 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py3{13,12,11,10,9},pypy3{10,9} 4 | py-{no-babel} 5 | style 6 | docs 7 | 8 | [testenv] 9 | deps = 10 | -r requirements/tests.txt 11 | Flask-Babel 12 | flask-reuploaded 13 | commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs} 14 | 15 | [testenv:py-no-babel] 16 | deps = -r requirements/tests.txt 17 | commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs} 18 | 19 | [testenv:style] 20 | deps = -r requirements/style.txt 21 | skip_install = true 22 | commands = pre-commit run --all-files --show-diff-on-failure 23 | 24 | [testenv:docs] 25 | deps = -r requirements/docs.txt 26 | commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug in Flask-WTF 4 | --- 5 | 6 | 12 | 13 | 19 | 20 | 23 | 24 | Environment: 25 | 26 | - Python version: 27 | - Flask-WTF version: 28 | - Flask version: 29 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | Developer Interface 2 | =================== 3 | 4 | Forms and Fields 5 | ---------------- 6 | 7 | .. module:: flask_wtf 8 | 9 | .. autoclass:: FlaskForm 10 | :members: 11 | 12 | .. autoclass:: Form(...) 13 | 14 | .. autoclass:: RecaptchaField 15 | 16 | .. autoclass:: Recaptcha 17 | 18 | .. autoclass:: RecaptchaWidget 19 | 20 | .. module:: flask_wtf.file 21 | 22 | .. autoclass:: FileField 23 | 24 | .. autoclass:: FileAllowed 25 | 26 | .. autoclass:: FileRequired 27 | 28 | CSRF Protection 29 | --------------- 30 | 31 | .. module:: flask_wtf.csrf 32 | 33 | .. autoclass:: CSRFProtect 34 | :members: 35 | 36 | .. autoclass:: CSRFError 37 | :members: 38 | 39 | .. autofunction:: generate_csrf 40 | 41 | .. autofunction:: validate_csrf 42 | -------------------------------------------------------------------------------- /examples/recaptcha/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% for comment in comments %} 4 |

{{ comment }}

5 | {% endfor %} 6 |
7 | {{ form.csrf_token }} 8 |

9 | {{ form.comment.label }}
10 | {{ form.comment(rows=5, cols=40) }} 11 |

12 |

13 | {% for error in form.recaptcha.errors %} 14 | {{ error }} 15 | {% endfor %} 16 | {{ form.recaptcha }} 17 |

18 |

19 | 20 |

21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /examples/uploadr/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask import render_template 3 | from wtforms import FieldList 4 | 5 | from flask_wtf import FlaskForm 6 | from flask_wtf.file import FileField 7 | 8 | 9 | class FileUploadForm(FlaskForm): 10 | uploads = FieldList(FileField()) 11 | 12 | 13 | DEBUG = True 14 | SECRET_KEY = "secret" 15 | 16 | app = Flask(__name__) 17 | app.config.from_object(__name__) 18 | 19 | 20 | @app.route("/", methods=("GET", "POST")) 21 | def index(): 22 | form = FileUploadForm() 23 | 24 | for _ in range(5): 25 | form.uploads.append_entry() 26 | 27 | filedata = [] 28 | 29 | if form.validate_on_submit(): 30 | for upload in form.uploads.entries: 31 | filedata.append(upload) 32 | 33 | return render_template("index.html", form=form, filedata=filedata) 34 | 35 | 36 | if __name__ == "__main__": 37 | app.run() 38 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | The `Python Packaging Guide`_ contains general information about how to manage 5 | your project and dependencies. 6 | 7 | .. _Python Packaging Guide: https://packaging.python.org/current/ 8 | 9 | Released version 10 | ---------------- 11 | 12 | Install or upgrade using pip. :: 13 | 14 | pip install -U Flask-WTF 15 | 16 | Development 17 | ----------- 18 | 19 | The latest code is available from `GitHub`_. Clone the repository then install 20 | using pip. :: 21 | 22 | git clone https://github.com/pallets-eco/flask-wtf 23 | pip install -e ./flask-wtf 24 | 25 | Or install the latest build from an `archive`_. :: 26 | 27 | pip install -U https://github.com/pallets-eco/flask-wtf/archive/main.tar.gz 28 | 29 | .. _GitHub: https://github.com/pallets-eco/flask-wtf 30 | .. _archive: https://github.com/pallets-eco/flask-wtf/archive/main.tar.gz 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 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 | 15 | - fixes # 16 | 17 | 23 | 24 | Checklist: 25 | 26 | - [ ] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change. 27 | - [ ] Add or update relevant docs, in the docs folder and in code. 28 | - [ ] Add an entry in `docs/changes.rst` summarizing the change and linking to the issue. Add `.. versionchanged::` entries in any relevant code docs. 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - '*.x' 7 | paths-ignore: 8 | - 'docs/**' 9 | - '*.md' 10 | - '*.rst' 11 | pull_request: 12 | paths-ignore: 13 | - 'docs/**' 14 | - '*.md' 15 | - '*.rst' 16 | jobs: 17 | tests: 18 | name: ${{ matrix.name || matrix.python }} 19 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | include: 24 | - {python: '3.13'} 25 | - {python: '3.12'} 26 | - {python: '3.11'} 27 | - {python: '3.10'} 28 | - {python: '3.9'} 29 | steps: 30 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 31 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 32 | with: 33 | python-version: ${{ matrix.python }} 34 | allow-prereleases: true 35 | cache: pip 36 | - run: pip install tox 37 | - run: tox run -e ${{ matrix.tox || format('py{0}', matrix.python) }} 38 | -------------------------------------------------------------------------------- /src/flask_wtf/i18n.py: -------------------------------------------------------------------------------- 1 | from babel import support 2 | from flask import current_app 3 | from flask import request 4 | from flask_babel import get_locale 5 | from wtforms.i18n import messages_path 6 | 7 | __all__ = ("Translations", "translations") 8 | 9 | 10 | def _get_translations(): 11 | """Returns the correct gettext translations. 12 | Copy from flask-babel with some modifications. 13 | """ 14 | 15 | if not request: 16 | return None 17 | 18 | # babel should be in extensions for get_locale 19 | if "babel" not in current_app.extensions: 20 | return None 21 | 22 | translations = getattr(request, "wtforms_translations", None) 23 | 24 | if translations is None: 25 | translations = support.Translations.load( 26 | messages_path(), [get_locale()], domain="wtforms" 27 | ) 28 | request.wtforms_translations = translations 29 | 30 | return translations 31 | 32 | 33 | class Translations: 34 | def gettext(self, string): 35 | t = _get_translations() 36 | return string if t is None else t.ugettext(string) 37 | 38 | def ngettext(self, singular, plural, n): 39 | t = _get_translations() 40 | 41 | if t is None: 42 | return singular if n == 1 else plural 43 | 44 | return t.ungettext(singular, plural, n) 45 | 46 | 47 | translations = Translations() 48 | -------------------------------------------------------------------------------- /examples/babel/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask import render_template 3 | from flask import request 4 | from flask_babel import Babel 5 | from flask_babel import lazy_gettext as _ 6 | from wtforms import StringField 7 | from wtforms.validators import DataRequired 8 | 9 | from flask_wtf import FlaskForm 10 | 11 | 12 | class BabelForm(FlaskForm): 13 | name = StringField(_("Name"), validators=[DataRequired()]) 14 | 15 | 16 | DEBUG = True 17 | SECRET_KEY = "secret" 18 | WTF_I18N_ENABLED = True 19 | 20 | 21 | def get_locale(): 22 | """how to get the locale is defined by you. 23 | 24 | Match by the Accept Language header:: 25 | 26 | match = app.config.get('BABEL_SUPPORTED_LOCALES', ['en', 'zh']) 27 | default = app.config.get('BABEL_DEFAULT_LOCALES', 'en') 28 | return request.accept_languages.best_match(match, default) 29 | """ 30 | # this is a demo case, we use url to get locale 31 | code = request.args.get("lang", "en") 32 | return code 33 | 34 | 35 | app = Flask(__name__) 36 | app.config.from_object(__name__) 37 | 38 | # config babel 39 | babel = Babel(app, locale_selector=get_locale) 40 | 41 | 42 | @app.route("/", methods=("GET", "POST")) 43 | def index(): 44 | form = BabelForm() 45 | if form.validate_on_submit(): 46 | pass 47 | return render_template("index.html", form=form) 48 | 49 | 50 | if __name__ == "__main__": 51 | app.run() 52 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. rst-class:: hide-header 2 | 3 | Flask-WTF 4 | ========= 5 | 6 | .. image:: _static/flask-wtf.png 7 | :alt: Flask-WTF 8 | :align: center 9 | 10 | Simple integration of `Flask`_ and `WTForms`_, including CSRF, file upload, 11 | and reCAPTCHA. 12 | 13 | .. _Flask: https://www.palletsprojects.com/p/flask 14 | .. _WTForms: https://wtforms.readthedocs.io/ 15 | 16 | Features 17 | -------- 18 | 19 | * Integration with WTForms. 20 | * Secure Form with CSRF token. 21 | * Global CSRF protection. 22 | * reCAPTCHA support. 23 | * File upload that works with Flask-Uploads. 24 | * Internationalization using Flask-Babel. 25 | 26 | User's Guide 27 | ------------ 28 | 29 | This part of the documentation, which is mostly prose, begins with some 30 | background information about Flask-WTF, then focuses on step-by-step 31 | instructions for getting the most out of Flask-WTF. 32 | 33 | .. toctree:: 34 | :maxdepth: 2 35 | 36 | install 37 | quickstart 38 | form 39 | csrf 40 | config 41 | 42 | API Documentation 43 | ----------------- 44 | 45 | If you are looking for information on a specific function, class or method, 46 | this part of the documentation is for you. 47 | 48 | .. toctree:: 49 | :maxdepth: 2 50 | 51 | api 52 | 53 | Additional Notes 54 | ---------------- 55 | 56 | Legal information and changelog are here. 57 | 58 | .. toctree:: 59 | :maxdepth: 2 60 | 61 | license 62 | changes 63 | contributing 64 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | # SHA1:54196885a2acdc154945dacc9470e2a9900fd8c1 2 | # 3 | # This file is autogenerated by pip-compile-multi 4 | # To update, run: 5 | # 6 | # pip-compile-multi 7 | # 8 | -r docs.txt 9 | -r tests.txt 10 | build==1.2.2.post1 11 | # via pip-tools 12 | cachetools==5.5.0 13 | # via tox 14 | cfgv==3.4.0 15 | # via pre-commit 16 | chardet==5.2.0 17 | # via tox 18 | click==8.1.7 19 | # via 20 | # pip-compile-multi 21 | # pip-tools 22 | colorama==0.4.6 23 | # via tox 24 | distlib==0.3.9 25 | # via virtualenv 26 | filelock==3.16.1 27 | # via 28 | # tox 29 | # virtualenv 30 | identify==2.6.1 31 | # via pre-commit 32 | nodeenv==1.9.1 33 | # via pre-commit 34 | pip-compile-multi==2.6.4 35 | # via -r requirements/dev.in 36 | pip-tools==7.4.1 37 | # via pip-compile-multi 38 | platformdirs==4.3.6 39 | # via 40 | # tox 41 | # virtualenv 42 | pre-commit==4.0.1 43 | # via -r requirements/dev.in 44 | pyproject-api==1.8.0 45 | # via tox 46 | pyproject-hooks==1.2.0 47 | # via 48 | # build 49 | # pip-tools 50 | pyyaml==6.0.2 51 | # via pre-commit 52 | toposort==1.10 53 | # via pip-compile-multi 54 | tox==4.23.0 55 | # via -r requirements/dev.in 56 | virtualenv==20.27.0 57 | # via 58 | # pre-commit 59 | # tox 60 | wheel==0.44.0 61 | # via pip-tools 62 | 63 | # The following packages are considered to be unsafe in a requirements file: 64 | # pip 65 | # setuptools 66 | -------------------------------------------------------------------------------- /examples/recaptcha/app.py: -------------------------------------------------------------------------------- 1 | from flask import flash 2 | from flask import Flask 3 | from flask import redirect 4 | from flask import render_template 5 | from flask import session 6 | from flask import url_for 7 | from wtforms import TextAreaField 8 | from wtforms.validators import DataRequired 9 | 10 | from flask_wtf import FlaskForm 11 | from flask_wtf.recaptcha import RecaptchaField 12 | 13 | DEBUG = True 14 | SECRET_KEY = "secret" 15 | 16 | # keys for localhost. Change as appropriate. 17 | 18 | RECAPTCHA_PUBLIC_KEY = "6LeYIbsSAAAAACRPIllxA7wvXjIE411PfdB2gt2J" 19 | RECAPTCHA_PRIVATE_KEY = "6LeYIbsSAAAAAJezaIq3Ft_hSTo0YtyeFG-JgRtu" 20 | 21 | app = Flask(__name__) 22 | app.config.from_object(__name__) 23 | 24 | 25 | class CommentForm(FlaskForm): 26 | comment = TextAreaField("Comment", validators=[DataRequired()]) 27 | recaptcha = RecaptchaField() 28 | 29 | 30 | @app.route("/") 31 | def index(form=None): 32 | if form is None: 33 | form = CommentForm() 34 | comments = session.get("comments", []) 35 | return render_template("index.html", comments=comments, form=form) 36 | 37 | 38 | @app.route("/add/", methods=("POST",)) 39 | def add_comment(): 40 | form = CommentForm() 41 | if form.validate_on_submit(): 42 | comments = session.pop("comments", []) 43 | comments.append(form.comment.data) 44 | session["comments"] = comments 45 | flash("You have added a new comment") 46 | return redirect(url_for("index")) 47 | return index(form) 48 | 49 | 50 | if __name__ == "__main__": 51 | app.run() 52 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | Copyright 2010 WTForms 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | 1. Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 21 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 24 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 25 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 26 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 27 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | # SHA1:45c590f97fe95b8bdc755eef796e91adf5fbe4ea 2 | # 3 | # This file is autogenerated by pip-compile-multi 4 | # To update, run: 5 | # 6 | # pip-compile-multi 7 | # 8 | alabaster==1.0.0 9 | # via sphinx 10 | babel==2.16.0 11 | # via sphinx 12 | certifi==2024.8.30 13 | # via requests 14 | charset-normalizer==3.4.0 15 | # via requests 16 | docutils==0.21.2 17 | # via sphinx 18 | idna==3.10 19 | # via requests 20 | imagesize==1.4.1 21 | # via sphinx 22 | jinja2==3.1.5 23 | # via sphinx 24 | markupsafe==3.0.1 25 | # via jinja2 26 | packaging==24.1 27 | # via 28 | # pallets-sphinx-themes 29 | # sphinx 30 | pallets-sphinx-themes==2.2.0 31 | # via -r docs.in 32 | pygments==2.18.0 33 | # via sphinx 34 | requests==2.32.3 35 | # via sphinx 36 | snowballstemmer==2.2.0 37 | # via sphinx 38 | sphinx==8.1.3 39 | # via 40 | # -r docs.in 41 | # pallets-sphinx-themes 42 | # sphinx-issues 43 | # sphinx-notfound-page 44 | # sphinxcontrib-log-cabinet 45 | sphinx-issues==5.0.0 46 | # via -r docs.in 47 | sphinx-notfound-page==1.0.4 48 | # via pallets-sphinx-themes 49 | sphinxcontrib-applehelp==2.0.0 50 | # via sphinx 51 | sphinxcontrib-devhelp==2.0.0 52 | # via sphinx 53 | sphinxcontrib-htmlhelp==2.1.0 54 | # via sphinx 55 | sphinxcontrib-jsmath==1.0.1 56 | # via sphinx 57 | sphinxcontrib-log-cabinet==1.0.1 58 | # via -r docs.in 59 | sphinxcontrib-qthelp==2.0.0 60 | # via sphinx 61 | sphinxcontrib-serializinghtml==2.0.0 62 | # via sphinx 63 | urllib3==2.2.3 64 | # via requests 65 | -------------------------------------------------------------------------------- /src/flask_wtf/recaptcha/widgets.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from flask import current_app 4 | from markupsafe import Markup 5 | 6 | RECAPTCHA_SCRIPT_DEFAULT = "https://www.google.com/recaptcha/api.js" 7 | RECAPTCHA_DIV_CLASS_DEFAULT = "g-recaptcha" 8 | RECAPTCHA_TEMPLATE = """ 9 | 10 |
11 | """ 12 | 13 | __all__ = ["RecaptchaWidget"] 14 | 15 | 16 | class RecaptchaWidget: 17 | def recaptcha_html(self, public_key): 18 | html = current_app.config.get("RECAPTCHA_HTML") 19 | if html: 20 | return Markup(html) 21 | params = current_app.config.get("RECAPTCHA_PARAMETERS") 22 | script = current_app.config.get("RECAPTCHA_SCRIPT") 23 | if not script: 24 | script = RECAPTCHA_SCRIPT_DEFAULT 25 | if params: 26 | script += "?" + urlencode(params) 27 | attrs = current_app.config.get("RECAPTCHA_DATA_ATTRS", {}) 28 | attrs["sitekey"] = public_key 29 | snippet = " ".join(f'data-{k}="{attrs[k]}"' for k in attrs) # noqa: B028, B907 30 | div_class = current_app.config.get("RECAPTCHA_DIV_CLASS") 31 | if not div_class: 32 | div_class = RECAPTCHA_DIV_CLASS_DEFAULT 33 | return Markup(RECAPTCHA_TEMPLATE % (script, div_class, snippet)) 34 | 35 | def __call__(self, field, error=None, **kwargs): 36 | """Returns the recaptcha input HTML.""" 37 | 38 | try: 39 | public_key = current_app.config["RECAPTCHA_PUBLIC_KEY"] 40 | except KeyError: 41 | raise RuntimeError("RECAPTCHA_PUBLIC_KEY config not set") from None 42 | 43 | return self.recaptcha_html(public_key) 44 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from pallets_sphinx_themes import get_version 2 | from pallets_sphinx_themes import ProjectLink 3 | 4 | # Project -------------------------------------------------------------- 5 | 6 | project = "Flask-WTF" 7 | copyright = "2010 WTForms" 8 | author = "WTForms" 9 | release, version = get_version("Flask-WTF") 10 | 11 | # General -------------------------------------------------------------- 12 | 13 | extensions = [ 14 | "sphinx.ext.autodoc", 15 | "sphinx.ext.intersphinx", 16 | "sphinxcontrib.log_cabinet", 17 | "pallets_sphinx_themes", 18 | "sphinx_issues", 19 | ] 20 | autodoc_typehints = "description" 21 | intersphinx_mapping = { 22 | "python": ("https://docs.python.org/3/", None), 23 | "flask": ("https://flask.palletsprojects.com/", None), 24 | "wtforms": ("https://wtforms.readthedocs.io/", None), 25 | } 26 | issues_github_path = "pallets-eco/flask-wtf" 27 | 28 | # HTML ----------------------------------------------------------------- 29 | 30 | html_theme = "flask" 31 | html_theme_options = {"index_sidebar_logo": False} 32 | html_context = { 33 | "project_links": [ 34 | ProjectLink("PyPI Releases", "https://pypi.org/project/Flask-WTF/"), 35 | ProjectLink("Source Code", "https://github.com/pallets-eco/flask-wtf/"), 36 | ProjectLink( 37 | "Issue Tracker", "https://github.com/pallets-eco/flask-wtf/issues/" 38 | ), 39 | ProjectLink("Chat", "https://discord.gg/pallets"), 40 | ] 41 | } 42 | html_sidebars = { 43 | "index": ["project.html", "localtoc.html", "searchbox.html", "ethicalads.html"], 44 | "**": ["localtoc.html", "relations.html", "searchbox.html", "ethicalads.html"], 45 | } 46 | singlehtml_sidebars = {"index": ["project.html", "localtoc.html", "ethicalads.html"]} 47 | html_static_path = ["_static"] 48 | html_favicon = "_static/flask-wtf-icon.png" 49 | html_logo = "_static/flask-wtf-icon.png" 50 | html_title = f"{project} Documentation ({version})" 51 | html_show_sourcelink = False 52 | -------------------------------------------------------------------------------- /tests/test_i18n.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import request 3 | from wtforms import StringField 4 | from wtforms.validators import DataRequired 5 | from wtforms.validators import Length 6 | 7 | from flask_wtf import FlaskForm 8 | 9 | pytest.importorskip("flask_wtf.i18n", reason="Flask-Babel is not installed.") 10 | 11 | 12 | class NameForm(FlaskForm): 13 | class Meta: 14 | csrf = False 15 | 16 | name = StringField(validators=[DataRequired(), Length(min=8)]) 17 | 18 | 19 | def test_no_extension(app, client): 20 | @app.route("/", methods=["POST"]) 21 | def index(): 22 | form = NameForm() 23 | form.validate() 24 | assert form.name.errors[0] == "This field is required." 25 | 26 | client.post("/", headers={"Accept-Language": "zh-CN,zh;q=0.8"}) 27 | 28 | 29 | def test_i18n(app, client): 30 | try: 31 | from flask_babel import Babel 32 | except ImportError: 33 | pytest.skip("Flask-Babel must be installed.") 34 | 35 | def get_locale(): 36 | return request.accept_languages.best_match(["en", "zh"], "en") 37 | 38 | Babel(app, locale_selector=get_locale) 39 | 40 | @app.route("/", methods=["POST"]) 41 | def index(): 42 | form = NameForm() 43 | form.validate() 44 | 45 | if not app.config.get("WTF_I18N_ENABLED", True): 46 | assert form.name.errors[0] == "This field is required." 47 | elif not form.name.data: 48 | assert form.name.errors[0] == "该字段是必填字段。" 49 | else: 50 | assert form.name.errors[0] == "字段长度必须至少 8 个字符。" 51 | 52 | client.post("/", headers={"Accept-Language": "zh-CN,zh;q=0.8"}) 53 | client.post("/", headers={"Accept-Language": "zh"}, data={"name": "short"}) 54 | app.config["WTF_I18N_ENABLED"] = False 55 | client.post("/", headers={"Accept-Language": "zh"}) 56 | 57 | 58 | def test_outside_request(): 59 | pytest.importorskip("babel") 60 | from flask_wtf.i18n import translations 61 | 62 | s = "This field is required." 63 | assert translations.gettext(s) == s 64 | 65 | ss = "Field must be at least %(min)d character long." 66 | sp = "Field must be at least %(min)d character long." 67 | assert translations.ngettext(ss, sp, 1) == ss 68 | assert translations.ngettext(ss, sp, 2) == sp 69 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Flask-WTF" 3 | description = "Form rendering, validation, and CSRF protection for Flask with WTForms." 4 | readme = "README.rst" 5 | license = {file = "LICENSE.rst"} 6 | maintainers = [{name = "WTForms"}] 7 | classifiers = [ 8 | "Development Status :: 5 - Production/Stable", 9 | "Environment :: Web Environment", 10 | "Intended Audience :: Developers", 11 | "License :: OSI Approved :: BSD License", 12 | "Operating System :: OS Independent", 13 | "Programming Language :: Python", 14 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 15 | "Topic :: Internet :: WWW/HTTP :: WSGI", 16 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 17 | "Topic :: Software Development :: Libraries :: Application Frameworks", 18 | ] 19 | requires-python = ">=3.9" 20 | dependencies = [ 21 | "Flask", 22 | "WTForms", 23 | "itsdangerous", 24 | ] 25 | dynamic = ["version"] 26 | 27 | [project.urls] 28 | Documentation = "https://flask-wtf.readthedocs.io/" 29 | Changes = "https://flask-wtf.readthedocs.io/changes/" 30 | "Source Code" = "https://github.com/pallets-eco/flask-wtf/" 31 | "Issue Tracker" = "https://github.com/pallets-eco/flask-wtf/issues/" 32 | Chat = "https://discord.gg/pallets" 33 | 34 | [project.optional-dependencies] 35 | email = ["email_validator"] 36 | 37 | [build-system] 38 | requires = ["hatchling"] 39 | build-backend = "hatchling.build" 40 | 41 | [tool.hatch.build.targets.wheel] 42 | packages = ["src/flask_wtf"] 43 | 44 | [tool.hatch.version] 45 | path = "src/flask_wtf/__init__.py" 46 | 47 | [tool.hatch.build] 48 | include = [ 49 | "src/", 50 | "docs/", 51 | "tests/", 52 | "CHANGES.rst", 53 | "tox.ini", 54 | ] 55 | exclude = [ 56 | "docs/_build/", 57 | ] 58 | 59 | [tool.pytest.ini_options] 60 | testpaths = ["tests"] 61 | filterwarnings = [ 62 | "error", 63 | ] 64 | 65 | [tool.coverage.run] 66 | branch = true 67 | source = ["flask_wtf", "tests"] 68 | 69 | [tool.coverage.paths] 70 | source = ["src", "*/site-packages"] 71 | 72 | [tool.coverage.report] 73 | exclude_lines = [ 74 | "pragma: no cover", 75 | "except ImportError:", 76 | ] 77 | 78 | [tool.ruff] 79 | src = ["src"] 80 | fix = true 81 | show-fixes = true 82 | output-format = "full" 83 | 84 | [tool.ruff.lint] 85 | select = [ 86 | "B", # flake8-bugbear 87 | "E", # pycodestyle error 88 | "F", # pyflakes 89 | "I", # isort 90 | "UP", # pyupgrade 91 | "W", # pycodestyle warning 92 | ] 93 | 94 | [tool.ruff.lint.isort] 95 | force-single-line = true 96 | order-by-type = false 97 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | Eager to get started? This page gives a good introduction to Flask-WTF. 5 | It assumes you already have Flask-WTF installed. If you do not, head over 6 | to the :doc:`install` section. 7 | 8 | 9 | Creating Forms 10 | -------------- 11 | 12 | Flask-WTF provides your Flask application integration with WTForms. For example:: 13 | 14 | from flask_wtf import FlaskForm 15 | from wtforms import StringField 16 | from wtforms.validators import DataRequired 17 | 18 | class MyForm(FlaskForm): 19 | name = StringField('name', validators=[DataRequired()]) 20 | 21 | 22 | .. note:: 23 | 24 | From version 0.9.0, Flask-WTF will not import anything from wtforms, 25 | you need to import fields from wtforms. 26 | 27 | In addition, a CSRF token hidden field is created automatically. You can 28 | render this in your template: 29 | 30 | .. sourcecode:: html+jinja 31 | 32 |
33 | {{ form.csrf_token }} 34 | {{ form.name.label }} {{ form.name(size=20) }} 35 | 36 |
37 | 38 | If your form has multiple hidden fields, you can render them in one 39 | block using :meth:`~flask_wtf.FlaskForm.hidden_tag`. 40 | 41 | .. sourcecode:: html+jinja 42 | 43 |
44 | {{ form.hidden_tag() }} 45 | {{ form.name.label }} {{ form.name(size=20) }} 46 | 47 |
48 | 49 | 50 | Validating Forms 51 | ---------------- 52 | 53 | Validating the request in your view handlers:: 54 | 55 | @app.route('/submit', methods=['GET', 'POST']) 56 | def submit(): 57 | form = MyForm() 58 | if form.validate_on_submit(): 59 | return redirect('/success') 60 | return render_template('submit.html', form=form) 61 | 62 | Note that you don't have to pass ``request.form`` to Flask-WTF; it will 63 | load automatically. And the convenient ``validate_on_submit`` will check 64 | if it is a POST request and if it is valid. 65 | 66 | If your forms include validation, you'll need to add to your template to display 67 | any error messages. Using the ``form.name`` field from the example above, that 68 | would look like this: 69 | 70 | .. sourcecode:: html+jinja 71 | 72 | {% if form.name.errors %} 73 | 78 | {% endif %} 79 | 80 | Heading over to :doc:`form` to learn more skills. 81 | -------------------------------------------------------------------------------- /src/flask_wtf/recaptcha/validators.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib import request as http 3 | from urllib.parse import urlencode 4 | 5 | from flask import current_app 6 | from flask import request 7 | from wtforms import ValidationError 8 | 9 | RECAPTCHA_VERIFY_SERVER_DEFAULT = "https://www.google.com/recaptcha/api/siteverify" 10 | RECAPTCHA_ERROR_CODES = { 11 | "missing-input-secret": "The secret parameter is missing.", 12 | "invalid-input-secret": "The secret parameter is invalid or malformed.", 13 | "missing-input-response": "The response parameter is missing.", 14 | "invalid-input-response": "The response parameter is invalid or malformed.", 15 | } 16 | 17 | 18 | __all__ = ["Recaptcha"] 19 | 20 | 21 | class Recaptcha: 22 | """Validates a ReCaptcha.""" 23 | 24 | def __init__(self, message=None): 25 | if message is None: 26 | message = RECAPTCHA_ERROR_CODES["missing-input-response"] 27 | self.message = message 28 | 29 | def __call__(self, form, field): 30 | if current_app.testing: 31 | return True 32 | 33 | if request.is_json: 34 | response = request.json.get("g-recaptcha-response", "") 35 | else: 36 | response = request.form.get("g-recaptcha-response", "") 37 | remote_ip = request.remote_addr 38 | 39 | if not response: 40 | raise ValidationError(field.gettext(self.message)) 41 | 42 | if not self._validate_recaptcha(response, remote_ip): 43 | field.recaptcha_error = "incorrect-captcha-sol" 44 | raise ValidationError(field.gettext(self.message)) 45 | 46 | def _validate_recaptcha(self, response, remote_addr): 47 | """Performs the actual validation.""" 48 | try: 49 | private_key = current_app.config["RECAPTCHA_PRIVATE_KEY"] 50 | except KeyError: 51 | raise RuntimeError("No RECAPTCHA_PRIVATE_KEY config set") from None 52 | 53 | verify_server = current_app.config.get("RECAPTCHA_VERIFY_SERVER") 54 | if not verify_server: 55 | verify_server = RECAPTCHA_VERIFY_SERVER_DEFAULT 56 | 57 | data = urlencode( 58 | {"secret": private_key, "remoteip": remote_addr, "response": response} 59 | ) 60 | 61 | http_response = http.urlopen(verify_server, data.encode("utf-8")) 62 | 63 | if http_response.code != 200: 64 | return False 65 | 66 | json_resp = json.loads(http_response.read()) 67 | 68 | if json_resp["success"]: 69 | return True 70 | 71 | for error in json_resp.get("error-codes", []): 72 | if error in RECAPTCHA_ERROR_CODES: 73 | raise ValidationError(RECAPTCHA_ERROR_CODES[error]) 74 | 75 | return False 76 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | hash: ${{ steps.hash.outputs.hash }} 11 | steps: 12 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 13 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 14 | with: 15 | python-version: '3.x' 16 | cache: pip 17 | - run: pip install -e . 18 | - run: pip install build 19 | # Use the commit date instead of the current date during the build. 20 | - run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV 21 | - run: python -m build 22 | # Generate hashes used for provenance. 23 | - name: generate hash 24 | id: hash 25 | run: cd dist && echo "hash=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT 26 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 27 | with: 28 | path: ./dist 29 | provenance: 30 | needs: [build] 31 | permissions: 32 | actions: read 33 | id-token: write 34 | contents: write 35 | # Can't pin with hash due to how this workflow works. 36 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 37 | with: 38 | base64-subjects: ${{ needs.build.outputs.hash }} 39 | create-release: 40 | # Upload the sdist, wheels, and provenance to a GitHub release. They remain 41 | # available as build artifacts for a while as well. 42 | needs: [provenance] 43 | runs-on: ubuntu-latest 44 | permissions: 45 | contents: write 46 | steps: 47 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 48 | - name: create release 49 | run: > 50 | gh release create --draft --repo ${{ github.repository }} 51 | ${{ github.ref_name }} 52 | *.intoto.jsonl/* artifact/* 53 | env: 54 | GH_TOKEN: ${{ github.token }} 55 | publish-pypi: 56 | needs: [provenance] 57 | # Wait for approval before attempting to upload to PyPI. This allows reviewing the 58 | # files in the draft release. 59 | environment: 60 | name: publish 61 | url: https://pypi.org/project/flask-wtf/${{ github.ref_name }} 62 | runs-on: ubuntu-latest 63 | permissions: 64 | id-token: write 65 | steps: 66 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 67 | - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 68 | with: 69 | repository-url: https://test.pypi.org/legacy/ 70 | packages-dir: artifact/ 71 | - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 72 | with: 73 | packages-dir: artifact/ 74 | -------------------------------------------------------------------------------- /tests/test_csrf_form.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import g 3 | from flask import session 4 | from wtforms import ValidationError 5 | 6 | from flask_wtf import FlaskForm 7 | from flask_wtf.csrf import generate_csrf 8 | from flask_wtf.csrf import validate_csrf 9 | 10 | 11 | def test_csrf_requires_secret_key(app, req_ctx): 12 | # use secret key set by test setup 13 | generate_csrf() 14 | # fail with no key 15 | app.secret_key = None 16 | pytest.raises(RuntimeError, generate_csrf) 17 | # use WTF_CSRF config 18 | app.config["WTF_CSRF_SECRET_KEY"] = "wtf_secret" 19 | generate_csrf() 20 | del app.config["WTF_CSRF_SECRET_KEY"] 21 | # use direct argument 22 | generate_csrf(secret_key="direct") 23 | 24 | 25 | def test_token_stored_by_generate(req_ctx): 26 | generate_csrf() 27 | assert "csrf_token" in session 28 | assert "csrf_token" in g 29 | 30 | 31 | def test_custom_token_key(req_ctx): 32 | generate_csrf(token_key="oauth_token") 33 | assert "oauth_token" in session 34 | assert "oauth_token" in g 35 | 36 | 37 | def test_token_cached(req_ctx): 38 | assert generate_csrf() == generate_csrf() 39 | 40 | 41 | def test_validate(req_ctx): 42 | validate_csrf(generate_csrf()) 43 | 44 | 45 | def test_validation_errors(req_ctx): 46 | e = pytest.raises(ValidationError, validate_csrf, None) 47 | assert str(e.value) == "The CSRF token is missing." 48 | 49 | e = pytest.raises(ValidationError, validate_csrf, "no session") 50 | assert str(e.value) == "The CSRF session token is missing." 51 | 52 | token = generate_csrf() 53 | e = pytest.raises(ValidationError, validate_csrf, token, time_limit=-1) 54 | assert str(e.value) == "The CSRF token has expired." 55 | 56 | e = pytest.raises(ValidationError, validate_csrf, "invalid") 57 | assert str(e.value) == "The CSRF token is invalid." 58 | 59 | other_token = generate_csrf(token_key="other_csrf") 60 | e = pytest.raises(ValidationError, validate_csrf, other_token) 61 | assert str(e.value) == "The CSRF tokens do not match." 62 | 63 | 64 | def test_form_csrf(app, client, app_ctx): 65 | @app.route("/", methods=["GET", "POST"]) 66 | def index(): 67 | f = FlaskForm() 68 | 69 | if f.validate_on_submit(): 70 | return "good" 71 | 72 | if f.errors: 73 | return f.csrf_token.errors[0] 74 | 75 | return f.csrf_token.current_token 76 | 77 | response = client.get("/") 78 | assert response.get_data(as_text=True) == g.csrf_token 79 | 80 | response = client.post("/") 81 | assert response.get_data(as_text=True) == "The CSRF token is missing." 82 | 83 | response = client.post("/", data={"csrf_token": g.csrf_token}) 84 | assert response.get_data(as_text=True) == "good" 85 | 86 | 87 | def test_validate_error_logged(req_ctx, monkeypatch): 88 | from flask_wtf.csrf import logger 89 | 90 | messages = [] 91 | 92 | def assert_info(message): 93 | messages.append(message) 94 | 95 | monkeypatch.setattr(logger, "info", assert_info) 96 | FlaskForm().validate() 97 | assert len(messages) == 1 98 | assert messages[0] == "The CSRF token is missing." 99 | -------------------------------------------------------------------------------- /tests/test_form.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | from flask import json 4 | from flask import request 5 | from wtforms import FileField 6 | from wtforms import HiddenField 7 | from wtforms import IntegerField 8 | from wtforms import StringField 9 | from wtforms.validators import DataRequired 10 | from wtforms.widgets import HiddenInput 11 | 12 | from flask_wtf import FlaskForm 13 | 14 | 15 | class BasicForm(FlaskForm): 16 | class Meta: 17 | csrf = False 18 | 19 | name = StringField(validators=[DataRequired()]) 20 | avatar = FileField() 21 | 22 | 23 | def test_populate_from_form(app, client): 24 | @app.route("/", methods=["POST"]) 25 | def index(): 26 | form = BasicForm() 27 | assert form.name.data == "form" 28 | 29 | client.post("/", data={"name": "form"}) 30 | 31 | 32 | def test_populate_from_files(app, client): 33 | @app.route("/", methods=["POST"]) 34 | def index(): 35 | form = BasicForm() 36 | assert form.avatar.data is not None 37 | assert form.avatar.data.filename == "flask.png" 38 | 39 | client.post("/", data={"name": "files", "avatar": (BytesIO(), "flask.png")}) 40 | 41 | 42 | def test_populate_from_json(app, client): 43 | @app.route("/", methods=["POST"]) 44 | def index(): 45 | form = BasicForm() 46 | assert form.name.data == "json" 47 | 48 | client.post("/", data=json.dumps({"name": "json"}), content_type="application/json") 49 | 50 | 51 | def test_populate_manually(app, client): 52 | @app.route("/", methods=["POST"]) 53 | def index(): 54 | form = BasicForm(request.args) 55 | assert form.name.data == "args" 56 | 57 | client.post("/", query_string={"name": "args"}) 58 | 59 | 60 | def test_populate_none(app, client): 61 | @app.route("/", methods=["POST"]) 62 | def index(): 63 | form = BasicForm(None) 64 | assert form.name.data is None 65 | 66 | client.post("/", data={"name": "ignore"}) 67 | 68 | 69 | def test_validate_on_submit(app, client): 70 | @app.route("/", methods=["POST"]) 71 | def index(): 72 | form = BasicForm() 73 | assert form.is_submitted() 74 | assert not form.validate_on_submit() 75 | assert "name" in form.errors 76 | 77 | client.post("/") 78 | 79 | 80 | def test_no_validate_on_get(app, client): 81 | @app.route("/", methods=["GET", "POST"]) 82 | def index(): 83 | form = BasicForm() 84 | assert not form.validate_on_submit() 85 | assert "name" not in form.errors 86 | 87 | client.get("/") 88 | 89 | 90 | def test_hidden_tag(req_ctx): 91 | class F(BasicForm): 92 | class Meta: 93 | csrf = True 94 | 95 | key = HiddenField() 96 | count = IntegerField(widget=HiddenInput()) 97 | 98 | f = F() 99 | out = f.hidden_tag() 100 | assert all(x in out for x in ("csrf_token", "count", "key")) 101 | assert "avatar" not in out 102 | assert "csrf_token" not in f.hidden_tag("count", "key") 103 | 104 | 105 | def test_set_default_message_language(app, client): 106 | @app.route("/default", methods=["POST"]) 107 | def default(): 108 | form = BasicForm() 109 | assert not form.validate_on_submit() 110 | assert "This field is required." in form.name.errors 111 | 112 | @app.route("/es", methods=["POST"]) 113 | def es(): 114 | app.config["WTF_I18N_ENABLED"] = False 115 | 116 | class MyBaseForm(FlaskForm): 117 | class Meta: 118 | csrf = False 119 | locales = ["es"] 120 | 121 | class NameForm(MyBaseForm): 122 | name = StringField(validators=[DataRequired()]) 123 | 124 | form = NameForm() 125 | assert form.meta.locales == ["es"] 126 | assert not form.validate_on_submit() 127 | assert "Este campo es obligatorio." in form.name.errors 128 | 129 | client.post("/default", data={"name": " "}) 130 | client.post("/es", data={"name": " "}) 131 | -------------------------------------------------------------------------------- /docs/config.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | ========================== ===================================================== 5 | ``WTF_CSRF_ENABLED`` Set to ``False`` to disable all CSRF protection. 6 | Default is ``True``. 7 | ``WTF_CSRF_CHECK_DEFAULT`` When using the CSRF protection extension, this 8 | controls whether every view is protected by default. 9 | Default is ``True``. 10 | ``WTF_CSRF_SECRET_KEY`` Random data for generating secure tokens. If this is 11 | not set then ``SECRET_KEY`` is used. 12 | ``WTF_CSRF_METHODS`` HTTP methods to protect from CSRF. Default is 13 | ``{'POST', 'PUT', 'PATCH', 'DELETE'}``. 14 | ``WTF_CSRF_FIELD_NAME`` Name of the form field and session key that holds the 15 | CSRF token. Default is ``csrf_token``. 16 | ``WTF_CSRF_HEADERS`` HTTP headers to search for CSRF token when it is not 17 | provided in the form. Default is 18 | ``['X-CSRFToken', 'X-CSRF-Token']``. 19 | ``WTF_CSRF_TIME_LIMIT`` Max age in seconds for CSRF tokens. Default is 20 | ``3600``. If set to ``None``, the CSRF token is valid 21 | for the life of the session. 22 | If your webserver has a cache policy, make sure it is 23 | configured with at maximum this value, so user browsers 24 | won't display pages with expired CSRF tokens. 25 | ``WTF_CSRF_SSL_STRICT`` Whether to enforce the same origin policy by checking 26 | that the referrer matches the host. Only applies to 27 | HTTPS requests. Default is ``True``. 28 | ``WTF_I18N_ENABLED`` Set to ``False`` to disable Flask-Babel I18N support. 29 | Also set to ``False`` if you want to use WTForms's 30 | built-in messages directly, see more info `here`_. 31 | Default is ``True``. 32 | ========================== ===================================================== 33 | 34 | .. _here: https://wtforms.readthedocs.io/en/stable/i18n.html#using-the-built-in-translations-provider 35 | 36 | Recaptcha 37 | --------- 38 | 39 | =========================== ============================================== 40 | ``RECAPTCHA_PUBLIC_KEY`` **required** A public key. 41 | ``RECAPTCHA_PRIVATE_KEY`` **required** A private key. 42 | https://www.google.com/recaptcha/admin 43 | ``RECAPTCHA_PARAMETERS`` **optional** A dict of configuration options. 44 | ``RECAPTCHA_HTML`` **optional** Override default HTML template 45 | for Recaptcha. 46 | ``RECAPTCHA_DATA_ATTRS`` **optional** A dict of ``data-`` attrs to use 47 | for Recaptcha div 48 | ``RECAPTCHA_SCRIPT`` **optional** Override the default captcha 49 | script URI in case an alternative service to 50 | reCAPtCHA, e.g. hCaptcha is used. Default is 51 | ``'https://www.google.com/recaptcha/api.js'`` 52 | ``RECAPTCHA_DIV_CLASS`` **optional** Override the default class of the 53 | captcha div in case an alternative captcha 54 | service is used. Default is 55 | ``'g-recaptcha'`` 56 | ``RECAPTCHA_VERIFY_SERVER`` **optional** Override the default verification 57 | server in case an alternative service is used. 58 | Default is 59 | ``'https://www.google.com/recaptcha/api/siteverify'`` 60 | 61 | =========================== ============================================== 62 | 63 | Logging 64 | ------- 65 | 66 | CSRF errors are logged at the ``INFO`` level to the ``flask_wtf.csrf`` logger. 67 | You still need to configure logging in your application in order to see these 68 | messages. 69 | -------------------------------------------------------------------------------- /docs/csrf.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: flask_wtf.csrf 2 | 3 | .. _csrf: 4 | 5 | CSRF Protection 6 | =============== 7 | 8 | Any view using :class:`~flask_wtf.FlaskForm` to process the request is already 9 | getting CSRF protection. If you have views that don't use ``FlaskForm`` or make 10 | AJAX requests, use the provided CSRF extension to protect those requests as 11 | well. 12 | 13 | Setup 14 | ----- 15 | 16 | To enable CSRF protection globally for a Flask app, register the 17 | :class:`CSRFProtect` extension. :: 18 | 19 | from flask_wtf.csrf import CSRFProtect 20 | 21 | csrf = CSRFProtect(app) 22 | 23 | Like other Flask extensions, you can apply it lazily:: 24 | 25 | csrf = CSRFProtect() 26 | 27 | def create_app(): 28 | app = Flask(__name__) 29 | csrf.init_app(app) 30 | 31 | .. note:: 32 | 33 | CSRF protection requires a secret key to securely sign the token. By default 34 | this will use the Flask app's ``SECRET_KEY``. If you'd like to use a 35 | separate token you can set ``WTF_CSRF_SECRET_KEY``. 36 | 37 | .. warning:: 38 | 39 | Make sure your webserver cache policy wont't interfere with the CSRF protection. 40 | If pages are cached longer than the ``WTF_CSRF_TIME_LIMIT`` value, then user browsers 41 | may serve cached page including expired CSRF token, resulting in random *Invalid* 42 | or *Expired* CSRF errors. 43 | 44 | HTML Forms 45 | ---------- 46 | 47 | When using a ``FlaskForm``, render the form's CSRF field like normal. 48 | 49 | .. sourcecode:: html+jinja 50 | 51 |
52 | {{ form.csrf_token }} 53 |
54 | 55 | If the template doesn't use a ``FlaskForm``, render a hidden input with the 56 | token in the form. 57 | 58 | .. sourcecode:: html+jinja 59 | 60 |
61 | 62 |
63 | 64 | JavaScript Requests 65 | ------------------- 66 | 67 | When sending an AJAX request, add the ``X-CSRFToken`` header to it. 68 | For example, in jQuery you can configure all requests to send the token. 69 | 70 | .. sourcecode:: html+jinja 71 | 72 | 83 | 84 | In Axios you can set the header for all requests with ``axios.defaults.headers.common``. 85 | 86 | .. sourcecode:: html+jinja 87 | 88 | 91 | 92 | Customize the error response 93 | ---------------------------- 94 | 95 | When CSRF validation fails, it will raise a :class:`CSRFError`. 96 | By default this returns a response with the failure reason and a 400 code. 97 | You can customize the error response using Flask's 98 | :meth:`~flask.Flask.errorhandler`. :: 99 | 100 | from flask_wtf.csrf import CSRFError 101 | 102 | @app.errorhandler(CSRFError) 103 | def handle_csrf_error(e): 104 | return render_template('csrf_error.html', reason=e.description), 400 105 | 106 | Exclude views from protection 107 | ----------------------------- 108 | 109 | We strongly suggest that you protect all your views with CSRF. But if 110 | needed, you can exclude some views using a decorator. :: 111 | 112 | @app.route('/foo', methods=('GET', 'POST')) 113 | @csrf.exempt 114 | def my_handler(): 115 | # ... 116 | return 'ok' 117 | 118 | You can exclude all the views of a blueprint. :: 119 | 120 | csrf.exempt(account_blueprint) 121 | 122 | You can disable CSRF protection in all views by default, by setting 123 | ``WTF_CSRF_CHECK_DEFAULT`` to ``False``, and selectively call 124 | :meth:`~flask_wtf.csrf.CSRFProtect.protect` only when you need. This also enables you to do some 125 | pre-processing on the requests before checking for the CSRF token. :: 126 | 127 | @app.before_request 128 | def check_csrf(): 129 | if not is_oauth(request): 130 | csrf.protect() 131 | -------------------------------------------------------------------------------- /src/flask_wtf/form.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | from flask import request 3 | from flask import session 4 | from markupsafe import Markup 5 | from werkzeug.datastructures import CombinedMultiDict 6 | from werkzeug.datastructures import ImmutableMultiDict 7 | from werkzeug.utils import cached_property 8 | from wtforms import Form 9 | from wtforms.meta import DefaultMeta 10 | from wtforms.widgets import HiddenInput 11 | 12 | from .csrf import _FlaskFormCSRF 13 | 14 | try: 15 | from .i18n import translations 16 | except ImportError: 17 | translations = None # babel not installed 18 | 19 | 20 | SUBMIT_METHODS = {"POST", "PUT", "PATCH", "DELETE"} 21 | _Auto = object() 22 | 23 | 24 | class FlaskForm(Form): 25 | """Flask-specific subclass of WTForms :class:`~wtforms.form.Form`. 26 | 27 | If ``formdata`` is not specified, this will use :attr:`flask.request.form` 28 | and :attr:`flask.request.files`. Explicitly pass ``formdata=None`` to 29 | prevent this. 30 | """ 31 | 32 | class Meta(DefaultMeta): 33 | csrf_class = _FlaskFormCSRF 34 | csrf_context = session # not used, provided for custom csrf_class 35 | 36 | @cached_property 37 | def csrf(self): 38 | return current_app.config.get("WTF_CSRF_ENABLED", True) 39 | 40 | @cached_property 41 | def csrf_secret(self): 42 | return current_app.config.get("WTF_CSRF_SECRET_KEY", current_app.secret_key) 43 | 44 | @cached_property 45 | def csrf_field_name(self): 46 | return current_app.config.get("WTF_CSRF_FIELD_NAME", "csrf_token") 47 | 48 | @cached_property 49 | def csrf_time_limit(self): 50 | return current_app.config.get("WTF_CSRF_TIME_LIMIT", 3600) 51 | 52 | def wrap_formdata(self, form, formdata): 53 | if formdata is _Auto: 54 | if _is_submitted(): 55 | if request.files: 56 | return CombinedMultiDict((request.files, request.form)) 57 | elif request.form: 58 | return request.form 59 | elif request.is_json: 60 | return ImmutableMultiDict(request.get_json()) 61 | 62 | return None 63 | 64 | return formdata 65 | 66 | def get_translations(self, form): 67 | if not current_app.config.get("WTF_I18N_ENABLED", True): 68 | return super().get_translations(form) 69 | 70 | return translations 71 | 72 | def __init__(self, formdata=_Auto, **kwargs): 73 | super().__init__(formdata=formdata, **kwargs) 74 | 75 | def is_submitted(self): 76 | """Consider the form submitted if there is an active request and 77 | the method is ``POST``, ``PUT``, ``PATCH``, or ``DELETE``. 78 | """ 79 | 80 | return _is_submitted() 81 | 82 | def validate_on_submit(self, extra_validators=None): 83 | """Call :meth:`validate` only if the form is submitted. 84 | This is a shortcut for ``form.is_submitted() and form.validate()``. 85 | """ 86 | return self.is_submitted() and self.validate(extra_validators=extra_validators) 87 | 88 | def hidden_tag(self, *fields): 89 | """Render the form's hidden fields in one call. 90 | 91 | A field is considered hidden if it uses the 92 | :class:`~wtforms.widgets.HiddenInput` widget. 93 | 94 | If ``fields`` are given, only render the given fields that 95 | are hidden. If a string is passed, render the field with that 96 | name if it exists. 97 | 98 | .. versionchanged:: 0.13 99 | 100 | No longer wraps inputs in hidden div. 101 | This is valid HTML 5. 102 | 103 | .. versionchanged:: 0.13 104 | 105 | Skip passed fields that aren't hidden. 106 | Skip passed names that don't exist. 107 | """ 108 | 109 | def hidden_fields(fields): 110 | for f in fields: 111 | if isinstance(f, str): 112 | f = getattr(self, f, None) 113 | 114 | if f is None or not isinstance(f.widget, HiddenInput): 115 | continue 116 | 117 | yield f 118 | 119 | return Markup("\n".join(str(f) for f in hidden_fields(fields or self))) 120 | 121 | 122 | def _is_submitted(): 123 | """Consider the form submitted if there is an active request and 124 | the method is ``POST``, ``PUT``, ``PATCH``, or ``DELETE``. 125 | """ 126 | 127 | return bool(request) and request.method in SUBMIT_METHODS 128 | -------------------------------------------------------------------------------- /src/flask_wtf/file.py: -------------------------------------------------------------------------------- 1 | from collections import abc 2 | 3 | from werkzeug.datastructures import FileStorage 4 | from wtforms import FileField as _FileField 5 | from wtforms import MultipleFileField as _MultipleFileField 6 | from wtforms.validators import DataRequired 7 | from wtforms.validators import StopValidation 8 | from wtforms.validators import ValidationError 9 | 10 | 11 | class FileField(_FileField): 12 | """Werkzeug-aware subclass of :class:`wtforms.fields.FileField`.""" 13 | 14 | def process_formdata(self, valuelist): 15 | valuelist = (x for x in valuelist if isinstance(x, FileStorage) and x) 16 | data = next(valuelist, None) 17 | 18 | if data is not None: 19 | self.data = data 20 | else: 21 | self.raw_data = () 22 | 23 | 24 | class MultipleFileField(_MultipleFileField): 25 | """Werkzeug-aware subclass of :class:`wtforms.fields.MultipleFileField`. 26 | 27 | .. versionadded:: 1.2.0 28 | """ 29 | 30 | def process_formdata(self, valuelist): 31 | valuelist = (x for x in valuelist if isinstance(x, FileStorage) and x) 32 | data = list(valuelist) or None 33 | 34 | if data is not None: 35 | self.data = data 36 | else: 37 | self.raw_data = () 38 | 39 | 40 | class FileRequired(DataRequired): 41 | """Validates that the uploaded files(s) is a Werkzeug 42 | :class:`~werkzeug.datastructures.FileStorage` object. 43 | 44 | :param message: error message 45 | 46 | You can also use the synonym ``file_required``. 47 | """ 48 | 49 | def __call__(self, form, field): 50 | field_data = [field.data] if not isinstance(field.data, list) else field.data 51 | if not ( 52 | all(isinstance(x, FileStorage) and x for x in field_data) and field_data 53 | ): 54 | raise StopValidation( 55 | self.message or field.gettext("This field is required.") 56 | ) 57 | 58 | 59 | file_required = FileRequired 60 | 61 | 62 | class FileAllowed: 63 | """Validates that the uploaded file(s) is allowed by a given list of 64 | extensions or a Flask-Uploads :class:`~flaskext.uploads.UploadSet`. 65 | 66 | :param upload_set: A list of extensions or an 67 | :class:`~flaskext.uploads.UploadSet` 68 | :param message: error message 69 | 70 | You can also use the synonym ``file_allowed``. 71 | """ 72 | 73 | def __init__(self, upload_set, message=None): 74 | self.upload_set = upload_set 75 | self.message = message 76 | 77 | def __call__(self, form, field): 78 | field_data = [field.data] if not isinstance(field.data, list) else field.data 79 | if not ( 80 | all(isinstance(x, FileStorage) and x for x in field_data) and field_data 81 | ): 82 | return 83 | 84 | filenames = [f.filename.lower() for f in field_data] 85 | 86 | for filename in filenames: 87 | if isinstance(self.upload_set, abc.Iterable): 88 | if any(filename.endswith("." + x) for x in self.upload_set): 89 | continue 90 | 91 | raise StopValidation( 92 | self.message 93 | or field.gettext( 94 | "File does not have an approved extension: {extensions}" 95 | ).format(extensions=", ".join(self.upload_set)) 96 | ) 97 | 98 | if not self.upload_set.file_allowed(field_data, filename): 99 | raise StopValidation( 100 | self.message 101 | or field.gettext("File does not have an approved extension.") 102 | ) 103 | 104 | 105 | file_allowed = FileAllowed 106 | 107 | 108 | class FileSize: 109 | """Validates that the uploaded file(s) is within a minimum and maximum 110 | file size (set in bytes). 111 | 112 | :param min_size: minimum allowed file size (in bytes). Defaults to 0 bytes. 113 | :param max_size: maximum allowed file size (in bytes). 114 | :param message: error message 115 | 116 | You can also use the synonym ``file_size``. 117 | """ 118 | 119 | def __init__(self, max_size, min_size=0, message=None): 120 | self.min_size = min_size 121 | self.max_size = max_size 122 | self.message = message 123 | 124 | def __call__(self, form, field): 125 | field_data = [field.data] if not isinstance(field.data, list) else field.data 126 | if not ( 127 | all(isinstance(x, FileStorage) and x for x in field_data) and field_data 128 | ): 129 | return 130 | 131 | for f in field_data: 132 | file_size = len(f.read()) 133 | f.seek(0) # reset cursor position to beginning of file 134 | 135 | if (file_size < self.min_size) or (file_size > self.max_size): 136 | # the file is too small or too big => validation failure 137 | raise ValidationError( 138 | self.message 139 | or field.gettext( 140 | f"File must be between {self.min_size}" 141 | f" and {self.max_size} bytes." 142 | ) 143 | ) 144 | 145 | 146 | file_size = FileSize 147 | -------------------------------------------------------------------------------- /tests/test_recaptcha.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import json 3 | from markupsafe import Markup 4 | 5 | from flask_wtf import FlaskForm 6 | from flask_wtf.recaptcha import RecaptchaField 7 | from flask_wtf.recaptcha.validators import http 8 | from flask_wtf.recaptcha.validators import Recaptcha 9 | 10 | 11 | class RecaptchaForm(FlaskForm): 12 | class Meta: 13 | csrf = False 14 | 15 | recaptcha = RecaptchaField() 16 | 17 | 18 | @pytest.fixture 19 | def app(app): 20 | app.testing = False 21 | app.config["PROPAGATE_EXCEPTIONS"] = True 22 | app.config["RECAPTCHA_PUBLIC_KEY"] = "public" 23 | app.config["RECAPTCHA_PRIVATE_KEY"] = "private" 24 | return app 25 | 26 | 27 | @pytest.fixture(autouse=True) 28 | def req_ctx(app): 29 | with app.test_request_context(data={"g-recaptcha-response": "pass"}) as ctx: 30 | yield ctx 31 | 32 | 33 | def test_config(app, monkeypatch): 34 | f = RecaptchaForm() 35 | monkeypatch.setattr(app, "testing", True) 36 | f.validate() 37 | assert not f.recaptcha.errors 38 | monkeypatch.undo() 39 | 40 | monkeypatch.delitem(app.config, "RECAPTCHA_PUBLIC_KEY") 41 | pytest.raises(RuntimeError, f.recaptcha) 42 | monkeypatch.undo() 43 | 44 | monkeypatch.delitem(app.config, "RECAPTCHA_PRIVATE_KEY") 45 | pytest.raises(RuntimeError, f.validate) 46 | 47 | 48 | def test_render_has_js(): 49 | f = RecaptchaForm() 50 | render = f.recaptcha() 51 | assert "https://www.google.com/recaptcha/api.js" in render 52 | 53 | 54 | def test_render_has_custom_js(app): 55 | captcha_script = "https://hcaptcha.com/1/api.js" 56 | app.config["RECAPTCHA_SCRIPT"] = captcha_script 57 | f = RecaptchaForm() 58 | render = f.recaptcha() 59 | assert captcha_script in render 60 | 61 | 62 | def test_render_custom_html(app): 63 | app.config["RECAPTCHA_HTML"] = "custom" 64 | f = RecaptchaForm() 65 | render = f.recaptcha() 66 | assert render == "custom" 67 | assert isinstance(render, Markup) 68 | 69 | 70 | def test_render_custom_div_class(app): 71 | div_class = "h-captcha" 72 | app.config["RECAPTCHA_DIV_CLASS"] = div_class 73 | f = RecaptchaForm() 74 | render = f.recaptcha() 75 | assert div_class in render 76 | 77 | 78 | def test_render_custom_args(app): 79 | app.config["RECAPTCHA_PARAMETERS"] = {"key": "(value)"} 80 | app.config["RECAPTCHA_DATA_ATTRS"] = {"red": "blue"} 81 | f = RecaptchaForm() 82 | render = f.recaptcha() 83 | assert "?key=(value)" in render or "?key=%28value%29" in render 84 | assert 'data-red="blue"' in render 85 | 86 | 87 | def test_missing_response(app): 88 | with app.test_request_context(): 89 | f = RecaptchaForm() 90 | f.validate() 91 | assert f.recaptcha.errors[0] == "The response parameter is missing." 92 | 93 | 94 | class MockResponse: 95 | def __init__(self, code, error="invalid-input-response", read_bytes=False): 96 | self.code = code 97 | self.data = json.dumps( 98 | {"success": not error, "error-codes": [error] if error else []} 99 | ) 100 | self.read_bytes = read_bytes 101 | 102 | def read(self): 103 | if self.read_bytes: 104 | return self.data.encode("utf-8") 105 | 106 | return self.data 107 | 108 | 109 | def test_send_invalid_request(monkeypatch): 110 | def mock_urlopen(url, data): 111 | return MockResponse(200) 112 | 113 | monkeypatch.setattr(http, "urlopen", mock_urlopen) 114 | f = RecaptchaForm() 115 | f.validate() 116 | assert f.recaptcha.errors[0] == ("The response parameter is invalid or malformed.") 117 | 118 | 119 | def test_response_from_json(app, monkeypatch): 120 | def mock_urlopen(url, data): 121 | return MockResponse(200) 122 | 123 | monkeypatch.setattr(http, "urlopen", mock_urlopen) 124 | 125 | with app.test_request_context( 126 | data=json.dumps({"g-recaptcha-response": "pass"}), 127 | content_type="application/json", 128 | ): 129 | f = RecaptchaForm() 130 | f.validate() 131 | assert f.recaptcha.errors[0] != "The response parameter is missing." 132 | 133 | 134 | def test_request_fail(monkeypatch): 135 | def mock_urlopen(url, data): 136 | return MockResponse(400) 137 | 138 | monkeypatch.setattr(http, "urlopen", mock_urlopen) 139 | f = RecaptchaForm() 140 | f.validate() 141 | assert f.recaptcha.errors 142 | 143 | 144 | def test_request_success(monkeypatch): 145 | def mock_urlopen(url, data): 146 | return MockResponse(200, "") 147 | 148 | monkeypatch.setattr(http, "urlopen", mock_urlopen) 149 | f = RecaptchaForm() 150 | f.validate() 151 | assert not f.recaptcha.errors 152 | 153 | 154 | def test_request_custom_verify_server(app, monkeypatch): 155 | verify_server = "https://hcaptcha.com/siteverify" 156 | 157 | def mock_urlopen(url, data): 158 | assert url == verify_server 159 | return MockResponse(200, "") 160 | 161 | monkeypatch.setattr(http, "urlopen", mock_urlopen) 162 | app.config["RECAPTCHA_VERIFY_SERVER"] = verify_server 163 | f = RecaptchaForm() 164 | f.validate() 165 | assert not f.recaptcha.errors 166 | 167 | 168 | def test_request_unmatched_error(monkeypatch): 169 | def mock_urlopen(url, data): 170 | return MockResponse(200, "not-an-error", True) 171 | 172 | monkeypatch.setattr(http, "urlopen", mock_urlopen) 173 | f = RecaptchaForm() 174 | f.recaptcha.validators = [Recaptcha("custom")] 175 | f.validate() 176 | assert f.recaptcha.errors[0] == "custom" 177 | -------------------------------------------------------------------------------- /tests/test_csrf_extension.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import Blueprint 3 | from flask import g 4 | from flask import render_template_string 5 | 6 | from flask_wtf import FlaskForm 7 | from flask_wtf.csrf import CSRFError 8 | from flask_wtf.csrf import CSRFProtect 9 | from flask_wtf.csrf import generate_csrf 10 | 11 | 12 | @pytest.fixture 13 | def app(app): 14 | CSRFProtect(app) 15 | 16 | @app.route("/", methods=["GET", "POST"]) 17 | def index(): 18 | pass 19 | 20 | @app.after_request 21 | def add_csrf_header(response): 22 | response.headers.set("X-CSRF-Token", generate_csrf()) 23 | return response 24 | 25 | return app 26 | 27 | 28 | @pytest.fixture 29 | def csrf(app): 30 | return app.extensions["csrf"] 31 | 32 | 33 | def test_render_token(req_ctx): 34 | token = generate_csrf() 35 | assert render_template_string("{{ csrf_token() }}") == token 36 | 37 | 38 | def test_protect(app, client, app_ctx): 39 | response = client.post("/") 40 | assert response.status_code == 400 41 | assert "The CSRF token is missing." in response.get_data(as_text=True) 42 | 43 | app.config["WTF_CSRF_ENABLED"] = False 44 | assert client.post("/").get_data() == b"" 45 | app.config["WTF_CSRF_ENABLED"] = True 46 | 47 | app.config["WTF_CSRF_CHECK_DEFAULT"] = False 48 | assert client.post("/").get_data() == b"" 49 | app.config["WTF_CSRF_CHECK_DEFAULT"] = True 50 | 51 | assert client.options("/").status_code == 200 52 | assert client.post("/not-found").status_code == 404 53 | 54 | response = client.get("/") 55 | assert response.status_code == 200 56 | token = response.headers["X-CSRF-Token"] 57 | assert client.post("/", data={"csrf_token": token}).status_code == 200 58 | assert client.post("/", data={"prefix-csrf_token": token}).status_code == 200 59 | assert client.post("/", data={"prefix-csrf_token": ""}).status_code == 400 60 | assert client.post("/", headers={"X-CSRF-Token": token}).status_code == 200 61 | 62 | 63 | def test_same_origin(client): 64 | token = client.get("/").headers["X-CSRF-Token"] 65 | response = client.post( 66 | "/", base_url="https://localhost", headers={"X-CSRF-Token": token} 67 | ) 68 | data = response.get_data(as_text=True) 69 | assert "The referrer header is missing." in data 70 | 71 | response = client.post( 72 | "/", 73 | base_url="https://localhost", 74 | headers={"X-CSRF-Token": token, "Referer": "http://localhost/"}, 75 | ) 76 | data = response.get_data(as_text=True) 77 | assert "The referrer does not match the host." in data 78 | 79 | response = client.post( 80 | "/", 81 | base_url="https://localhost", 82 | headers={"X-CSRF-Token": token, "Referer": "https://other/"}, 83 | ) 84 | data = response.get_data(as_text=True) 85 | assert "The referrer does not match the host." in data 86 | 87 | response = client.post( 88 | "/", 89 | base_url="https://localhost", 90 | headers={"X-CSRF-Token": token, "Referer": "https://localhost:8080/"}, 91 | ) 92 | data = response.get_data(as_text=True) 93 | assert "The referrer does not match the host." in data 94 | 95 | response = client.post( 96 | "/", 97 | base_url="https://localhost", 98 | headers={"X-CSRF-Token": token, "Referer": "https://localhost/"}, 99 | ) 100 | assert response.status_code == 200 101 | 102 | 103 | def test_form_csrf_short_circuit(app, client): 104 | @app.route("/skip", methods=["POST"]) 105 | def skip(): 106 | assert g.get("csrf_valid") 107 | # don't pass the token, then validate the form 108 | # this would fail if CSRFProtect didn't run 109 | form = FlaskForm(None) 110 | assert form.validate() 111 | 112 | token = client.get("/").headers["X-CSRF-Token"] 113 | response = client.post("/skip", headers={"X-CSRF-Token": token}) 114 | assert response.status_code == 200 115 | 116 | 117 | def test_exempt_view(app, csrf, client): 118 | @app.route("/exempt", methods=["POST"]) 119 | @csrf.exempt 120 | def exempt(): 121 | pass 122 | 123 | response = client.post("/exempt") 124 | assert response.status_code == 200 125 | 126 | csrf.exempt("test_csrf_extension.index") 127 | response = client.post("/") 128 | assert response.status_code == 200 129 | 130 | 131 | def test_manual_protect(app, csrf, client): 132 | @app.route("/manual", methods=["GET", "POST"]) 133 | @csrf.exempt 134 | def manual(): 135 | csrf.protect() 136 | 137 | response = client.get("/manual") 138 | assert response.status_code == 200 139 | 140 | response = client.post("/manual") 141 | assert response.status_code == 400 142 | 143 | 144 | def test_exempt_blueprint(app, csrf, client): 145 | bp = Blueprint("exempt", __name__, url_prefix="/exempt") 146 | csrf.exempt(bp) 147 | 148 | @bp.route("/", methods=["POST"]) 149 | def index(): 150 | pass 151 | 152 | app.register_blueprint(bp) 153 | response = client.post("/exempt/") 154 | assert response.status_code == 200 155 | 156 | 157 | def test_exempt_nested_blueprint(app, csrf, client): 158 | bp1 = Blueprint("exempt1", __name__, url_prefix="/") 159 | bp2 = Blueprint("exempt2", __name__, url_prefix="/exempt") 160 | csrf.exempt(bp2) 161 | 162 | @bp2.route("/", methods=["POST"]) 163 | def index(): 164 | pass 165 | 166 | bp1.register_blueprint(bp2) 167 | app.register_blueprint(bp1) 168 | 169 | response = client.post("/exempt/") 170 | assert response.status_code == 200 171 | 172 | 173 | def test_error_handler(app, client): 174 | @app.errorhandler(CSRFError) 175 | def handle_csrf_error(e): 176 | return e.description.lower() 177 | 178 | response = client.post("/") 179 | assert response.get_data(as_text=True) == "the csrf token is missing." 180 | 181 | 182 | def test_validate_error_logged(client, monkeypatch): 183 | from flask_wtf.csrf import logger 184 | 185 | messages = [] 186 | 187 | def assert_info(message): 188 | messages.append(message) 189 | 190 | monkeypatch.setattr(logger, "info", assert_info) 191 | 192 | client.post("/") 193 | assert len(messages) == 1 194 | assert messages[0] == "The CSRF token is missing." 195 | -------------------------------------------------------------------------------- /docs/form.rst: -------------------------------------------------------------------------------- 1 | Creating Forms 2 | ============== 3 | 4 | Secure Form 5 | ----------- 6 | 7 | .. currentmodule:: flask_wtf 8 | 9 | Without any configuration, the :class:`FlaskForm` will be a session secure 10 | form with csrf protection. We encourage you not to change this. 11 | 12 | But if you want to disable the csrf protection, you can pass:: 13 | 14 | form = FlaskForm(meta={'csrf': False}) 15 | 16 | You can disable it globally—though you really shouldn't—with the 17 | configuration:: 18 | 19 | WTF_CSRF_ENABLED = False 20 | 21 | In order to generate the csrf token, you must have a secret key, this 22 | is usually the same as your Flask app secret key. If you want to use 23 | another secret key, config it:: 24 | 25 | WTF_CSRF_SECRET_KEY = 'a random string' 26 | 27 | 28 | File Uploads 29 | ------------ 30 | 31 | .. currentmodule:: flask_wtf.file 32 | 33 | The :class:`FileField` provided by Flask-WTF differs from the WTForms-provided 34 | field. It will check that the file is a non-empty instance of 35 | :class:`~werkzeug.datastructures.FileStorage`, otherwise ``data`` will be 36 | ``None``. :: 37 | 38 | from flask_wtf import FlaskForm 39 | from flask_wtf.file import FileField, FileRequired 40 | from werkzeug.utils import secure_filename 41 | 42 | class PhotoForm(FlaskForm): 43 | photo = FileField(validators=[FileRequired()]) 44 | 45 | @app.route('/upload', methods=['GET', 'POST']) 46 | def upload(): 47 | form = PhotoForm() 48 | 49 | if form.validate_on_submit(): 50 | f = form.photo.data 51 | filename = secure_filename(f.filename) 52 | f.save(os.path.join( 53 | app.instance_path, 'photos', filename 54 | )) 55 | return redirect(url_for('index')) 56 | 57 | return render_template('upload.html', form=form) 58 | 59 | 60 | Similarly, you can use the :class:`MultipleFileField` provided by Flask-WTF 61 | to handle multiple files. It will check that the files is a list of non-empty instance of 62 | :class:`~werkzeug.datastructures.FileStorage`, otherwise ``data`` will be 63 | ``None``. :: 64 | 65 | from flask_wtf import FlaskForm 66 | from flask_wtf.file import MultipleFileField, FileRequired 67 | from werkzeug.utils import secure_filename 68 | 69 | class PhotoForm(FlaskForm): 70 | photos = MultipleFileField(validators=[FileRequired()]) 71 | 72 | @app.route('/upload', methods=['GET', 'POST']) 73 | def upload(): 74 | form = PhotoForm() 75 | 76 | if form.validate_on_submit(): 77 | for f in form.photo.data: # form.photo.data return a list of FileStorage object 78 | filename = secure_filename(f.filename) 79 | f.save(os.path.join( 80 | app.instance_path, 'photos', filename 81 | )) 82 | return redirect(url_for('index')) 83 | 84 | return render_template('upload.html', form=form) 85 | 86 | 87 | Remember to set the ``enctype`` of the HTML form to 88 | ``multipart/form-data``, otherwise ``request.files`` will be empty. 89 | 90 | .. sourcecode:: html 91 | 92 |
93 | ... 94 |
95 | 96 | Flask-WTF handles passing form data to the form for you. 97 | If you pass in the data explicitly, remember that ``request.form`` must 98 | be combined with ``request.files`` for the form to see the file data. :: 99 | 100 | form = PhotoForm() 101 | # is equivalent to: 102 | 103 | from flask import request 104 | from werkzeug.datastructures import CombinedMultiDict 105 | form = PhotoForm(CombinedMultiDict((request.files, request.form))) 106 | 107 | 108 | Validation 109 | ~~~~~~~~~~ 110 | 111 | Flask-WTF supports validating file uploads with 112 | :class:`FileRequired`, :class:`FileAllowed`, and :class:`FileSize`. They 113 | can be used with both Flask-WTF's and WTForms's ``FileField`` and 114 | ``MultipleFileField`` classes. 115 | 116 | :class:`FileAllowed` works well with Flask-Uploads. :: 117 | 118 | from flask_uploads import UploadSet, IMAGES 119 | from flask_wtf import FlaskForm 120 | from flask_wtf.file import FileField, FileAllowed, FileRequired 121 | 122 | images = UploadSet('images', IMAGES) 123 | 124 | class UploadForm(FlaskForm): 125 | upload = FileField('image', validators=[ 126 | FileRequired(), 127 | FileAllowed(images, 'Images only!') 128 | ]) 129 | 130 | It can be used without Flask-Uploads by passing the extensions directly. :: 131 | 132 | class UploadForm(FlaskForm): 133 | upload = FileField('image', validators=[ 134 | FileRequired(), 135 | FileAllowed(['jpg', 'png'], 'Images only!') 136 | ]) 137 | 138 | 139 | .. _recaptcha: 140 | 141 | Recaptcha 142 | --------- 143 | 144 | .. currentmodule:: flask_wtf.recaptcha 145 | 146 | Flask-WTF also provides Recaptcha support through a :class:`RecaptchaField`:: 147 | 148 | from flask_wtf import FlaskForm, RecaptchaField 149 | from wtforms import TextField 150 | 151 | class SignupForm(FlaskForm): 152 | username = TextField('Username') 153 | recaptcha = RecaptchaField() 154 | 155 | This comes with a number of configuration variables, some of which you have to configure. 156 | 157 | ======================= ============================================== 158 | RECAPTCHA_PUBLIC_KEY **required** A public key. 159 | RECAPTCHA_PRIVATE_KEY **required** A private key. 160 | RECAPTCHA_API_SERVER **optional** Specify your Recaptcha API server. 161 | RECAPTCHA_PARAMETERS **optional** A dict of JavaScript (api.js) parameters. 162 | RECAPTCHA_DATA_ATTRS **optional** A dict of data attributes options. 163 | https://developers.google.com/recaptcha/docs/display#javascript_resource_apijs_parameters 164 | ======================= ============================================== 165 | 166 | Example of RECAPTCHA_PARAMETERS, and RECAPTCHA_DATA_ATTRS:: 167 | 168 | RECAPTCHA_PARAMETERS = {'hl': 'zh', 'render': 'explicit'} 169 | RECAPTCHA_DATA_ATTRS = {'theme': 'dark'} 170 | 171 | For your convenience, when testing your application, if ``app.testing`` is ``True``, the recaptcha 172 | field will always be valid. 173 | 174 | And it can be easily setup in the templates: 175 | 176 | .. sourcecode:: html+jinja 177 | 178 |
179 | {{ form.username }} 180 | {{ form.recaptcha }} 181 |
182 | 183 | We have an example for you: `recaptcha@github`_. 184 | 185 | .. _`recaptcha@github`: https://github.com/pallets-eco/flask-wtf/tree/main/examples/recaptcha 186 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | How to contribute to Flask-WTF 2 | ============================== 3 | 4 | Thank you for considering contributing to Flask-WTF! 5 | 6 | 7 | Support questions 8 | ----------------- 9 | 10 | Please don't use the issue tracker for this. The issue tracker is a 11 | tool to address bugs and feature requests in Flask-WTF itself. Use one of 12 | the following resources for questions about using Flask-WTF or issues 13 | with your own code: 14 | 15 | - The ``#get-help`` channel on our Discord chat: 16 | https://discord.gg/pallets 17 | - The mailing list flask@python.org for long term discussion or larger 18 | issues. 19 | - Ask on `Stack Overflow`_. Search with Google first using: 20 | ``site:stackoverflow.com flask-wtf {search term, exception message, etc.}`` 21 | 22 | .. _Stack Overflow: https://stackoverflow.com/questions/tagged/flask-wtf?tab=Frequent 23 | 24 | 25 | Reporting issues 26 | ---------------- 27 | 28 | Include the following information in your post: 29 | 30 | - Describe what you expected to happen. 31 | - If possible, include a `minimal reproducible example`_ to help us 32 | identify the issue. This also helps check that the issue is not with 33 | your own code. 34 | - Describe what actually happened. Include the full traceback if there 35 | was an exception. 36 | - List your Python, Flask-WTF, and WTForms versions. If possible, check if this 37 | issue is already fixed in the latest releases or the latest code in 38 | the repository. 39 | 40 | .. _minimal reproducible example: https://stackoverflow.com/help/minimal-reproducible-example 41 | 42 | 43 | Submitting patches 44 | ------------------ 45 | 46 | If there is not an open issue for what you want to submit, prefer 47 | opening one for discussion before working on a PR. You can work on any 48 | issue that doesn't have an open PR linked to it or a maintainer assigned 49 | to it. These show up in the sidebar. No need to ask if you can work on 50 | an issue that interests you. 51 | 52 | Include the following in your patch: 53 | 54 | - Use `Black`_ to format your code. This and other tools will run 55 | automatically if you install `pre-commit`_ using the instructions 56 | below. 57 | - Include tests if your patch adds or changes code. Make sure the test 58 | fails without your patch. 59 | - Update any relevant docs pages and docstrings. Docs pages and 60 | docstrings should be wrapped at 72 characters. 61 | - Add an entry in ``CHANGES.rst``. Use the same style as other 62 | entries. Also include ``.. versionchanged::`` inline changelogs in 63 | relevant docstrings. 64 | 65 | .. _Black: https://black.readthedocs.io 66 | .. _pre-commit: https://pre-commit.com 67 | 68 | 69 | First time setup 70 | ~~~~~~~~~~~~~~~~ 71 | 72 | - Download and install the `latest version of git`_. 73 | - Configure git with your `username`_ and `email`_. 74 | 75 | .. code-block:: text 76 | 77 | $ git config --global user.name 'your name' 78 | $ git config --global user.email 'your email' 79 | 80 | - Make sure you have a `GitHub account`_. 81 | - Fork Flask-WTF to your GitHub account by clicking the `Fork`_ button. 82 | - `Clone`_ the main repository locally. 83 | 84 | .. code-block:: text 85 | 86 | $ git clone https://github.com/pallets-eco/flask-wtf 87 | $ cd flask-wtf 88 | 89 | - Add your fork as a remote to push your work to. Replace 90 | ``{username}`` with your username. This names the remote "fork", the 91 | default WTForms remote is "origin". 92 | 93 | .. code-block:: text 94 | 95 | $ git remote add fork https://github.com/{username}/flask-wtf 96 | 97 | - Create a virtualenv. 98 | 99 | .. code-block:: text 100 | 101 | $ python3 -m venv env 102 | $ . env/bin/activate 103 | 104 | On Windows, activating is different. 105 | 106 | .. code-block:: text 107 | 108 | > env\Scripts\activate 109 | 110 | - Upgrade pip and setuptools. 111 | 112 | .. code-block:: text 113 | 114 | $ python -m pip install --upgrade pip setuptools 115 | 116 | - Install the development dependencies, then install Flask-WTF in 117 | editable mode. 118 | 119 | .. code-block:: text 120 | 121 | $ pip install -r requirements/dev.txt && pip install -e . 122 | 123 | - Install the pre-commit hooks. 124 | 125 | .. code-block:: text 126 | 127 | $ pre-commit install 128 | 129 | .. _latest version of git: https://git-scm.com/downloads 130 | .. _username: https://docs.github.com/en/github/using-git/setting-your-username-in-git 131 | .. _email: https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/setting-your-commit-email-address 132 | .. _GitHub account: https://github.com/join 133 | .. _Fork: https://github.com/pallets-eco/flask-wtf/fork 134 | .. _Clone: https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#step-2-create-a-local-clone-of-your-fork 135 | 136 | 137 | Start coding 138 | ~~~~~~~~~~~~ 139 | 140 | - Create a branch to identify the issue you would like to work on. If 141 | you're submitting a bug or documentation fix, branch off of the 142 | latest ".x" branch. 143 | 144 | .. code-block:: text 145 | 146 | $ git fetch origin 147 | $ git checkout -b your-branch-name origin/1.0.x 148 | 149 | If you're submitting a feature addition or change, branch off of the 150 | "main" branch. 151 | 152 | .. code-block:: text 153 | 154 | $ git fetch origin 155 | $ git checkout -b your-branch-name origin/main 156 | 157 | - Using your favorite editor, make your changes, 158 | `committing as you go`_. 159 | - Include tests that cover any code changes you make. Make sure the 160 | test fails without your patch. Run the tests as described below. 161 | - Push your commits to your fork on GitHub and 162 | `create a pull request`_. Link to the issue being addressed with 163 | ``fixes #123`` in the pull request. 164 | 165 | .. code-block:: text 166 | 167 | $ git push --set-upstream fork your-branch-name 168 | 169 | .. _committing as you go: https://dont-be-afraid-to-commit.readthedocs.io/en/latest/git/commandlinegit.html#commit-your-changes 170 | .. _create a pull request: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request 171 | 172 | 173 | Running the tests 174 | ~~~~~~~~~~~~~~~~~ 175 | 176 | Run the basic test suite with pytest. 177 | 178 | .. code-block:: text 179 | 180 | $ pytest 181 | 182 | This runs the tests for the current environment, which is usually 183 | sufficient. CI will run the full suite when you submit your pull 184 | request. You can run the full test suite with tox if you don't want to 185 | wait. 186 | 187 | .. code-block:: text 188 | 189 | $ tox 190 | 191 | 192 | Running test coverage 193 | ~~~~~~~~~~~~~~~~~~~~~ 194 | 195 | Generating a report of lines that do not have test coverage can indicate 196 | where to start contributing. Run ``pytest`` using ``coverage`` and 197 | generate a report. 198 | 199 | .. code-block:: text 200 | 201 | $ pip install coverage 202 | $ coverage run -m pytest 203 | $ coverage html 204 | 205 | Open ``htmlcov/index.html`` in your browser to explore the report. 206 | 207 | Read more about `coverage `__. 208 | 209 | 210 | Building the docs 211 | ~~~~~~~~~~~~~~~~~ 212 | 213 | Build the docs in the ``docs`` directory using Sphinx. 214 | 215 | .. code-block:: text 216 | 217 | $ cd docs 218 | $ make html 219 | 220 | Open ``_build/html/index.html`` in your browser to view the docs. 221 | 222 | Read more about `Sphinx `__. 223 | -------------------------------------------------------------------------------- /tests/test_file.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from werkzeug.datastructures import FileStorage 3 | from werkzeug.datastructures import ImmutableMultiDict 4 | from werkzeug.datastructures import MultiDict 5 | from wtforms import FileField as BaseFileField 6 | from wtforms import MultipleFileField as BaseMultipleFileField 7 | from wtforms.validators import Length 8 | 9 | from flask_wtf import FlaskForm 10 | from flask_wtf.file import FileAllowed 11 | from flask_wtf.file import FileField 12 | from flask_wtf.file import FileRequired 13 | from flask_wtf.file import FileSize 14 | from flask_wtf.file import MultipleFileField 15 | 16 | 17 | @pytest.fixture 18 | def form(req_ctx): 19 | class UploadForm(FlaskForm): 20 | class Meta: 21 | csrf = False 22 | 23 | file = FileField() 24 | files = MultipleFileField() 25 | 26 | return UploadForm 27 | 28 | 29 | def test_process_formdata(form): 30 | assert form(MultiDict((("file", FileStorage()),))).file.data is None 31 | assert ( 32 | form(MultiDict((("file", FileStorage(filename="real")),))).file.data is not None 33 | ) 34 | 35 | 36 | def test_file_required(form): 37 | form.file.kwargs["validators"] = [FileRequired()] 38 | 39 | f = form() 40 | assert not f.validate() 41 | assert f.file.errors[0] == "This field is required." 42 | 43 | f = form(file="not a file") 44 | assert not f.validate() 45 | assert f.file.errors[0] == "This field is required." 46 | 47 | f = form(file=FileStorage()) 48 | assert not f.validate() 49 | 50 | f = form(file=FileStorage(filename="real")) 51 | assert f.validate() 52 | 53 | 54 | def test_file_allowed(form): 55 | form.file.kwargs["validators"] = [FileAllowed(("txt",))] 56 | 57 | f = form() 58 | assert f.validate() 59 | 60 | f = form(file=FileStorage(filename="test.txt")) 61 | assert f.validate() 62 | 63 | f = form(file=FileStorage(filename="test.png")) 64 | assert not f.validate() 65 | assert f.file.errors[0] == "File does not have an approved extension: txt" 66 | 67 | 68 | def test_file_allowed_uploadset(app, form): 69 | pytest.importorskip("flask_uploads") 70 | from flask_uploads import configure_uploads 71 | from flask_uploads import UploadSet 72 | 73 | app.config["UPLOADS_DEFAULT_DEST"] = "uploads" 74 | txt = UploadSet("txt", extensions=("txt",)) 75 | configure_uploads(app, (txt,)) 76 | form.file.kwargs["validators"] = [FileAllowed(txt)] 77 | 78 | f = form() 79 | assert f.validate() 80 | 81 | f = form(file=FileStorage(filename="test.txt")) 82 | assert f.validate() 83 | 84 | f = form(file=FileStorage(filename="test.png")) 85 | assert not f.validate() 86 | assert f.file.errors[0] == "File does not have an approved extension." 87 | 88 | 89 | def test_file_size_no_file_passes_validation(form): 90 | form.file.kwargs["validators"] = [FileSize(max_size=100)] 91 | f = form() 92 | assert f.validate() 93 | 94 | 95 | def test_file_size_small_file_passes_validation(form, tmp_path): 96 | form.file.kwargs["validators"] = [FileSize(max_size=100)] 97 | path = tmp_path / "test_file_smaller_than_max.txt" 98 | path.write_bytes(b"\0") 99 | 100 | with path.open("rb") as file: 101 | f = form(file=FileStorage(file)) 102 | assert f.validate() 103 | 104 | 105 | @pytest.mark.parametrize( 106 | "min_size, max_size, invalid_file_size", [(1, 100, 0), (0, 100, 101)] 107 | ) 108 | def test_file_size_invalid_file_size_fails_validation( 109 | form, min_size, max_size, invalid_file_size, tmp_path 110 | ): 111 | form.file.kwargs["validators"] = [FileSize(min_size=min_size, max_size=max_size)] 112 | path = tmp_path / "test_file_invalid_size.txt" 113 | path.write_bytes(b"\0" * invalid_file_size) 114 | 115 | with path.open("rb") as file: 116 | f = form(file=FileStorage(file)) 117 | assert not f.validate() 118 | assert ( 119 | f.file.errors[0] == f"File must be between {min_size} and {max_size} bytes." 120 | ) 121 | 122 | 123 | def test_validate_base_field(req_ctx): 124 | class F(FlaskForm): 125 | class Meta: 126 | csrf = False 127 | 128 | f = BaseFileField(validators=[FileRequired()]) 129 | 130 | assert not F().validate() 131 | assert not F(f=FileStorage()).validate() 132 | assert F(f=FileStorage(filename="real")).validate() 133 | assert F(f=FileStorage(filename="real")).validate() 134 | 135 | 136 | def test_process_formdata_for_files(form): 137 | assert ( 138 | form( 139 | ImmutableMultiDict([("files", FileStorage()), ("files", FileStorage())]) 140 | ).files.data 141 | is None 142 | ) 143 | assert ( 144 | form( 145 | ImmutableMultiDict( 146 | [ 147 | ("files", FileStorage(filename="a.jpg")), 148 | ("files", FileStorage(filename="b.jpg")), 149 | ] 150 | ) 151 | ).files.data 152 | is not None 153 | ) 154 | 155 | 156 | def test_files_required(form): 157 | form.files.kwargs["validators"] = [FileRequired()] 158 | 159 | f = form() 160 | assert not f.validate() 161 | assert f.files.errors[0] == "This field is required." 162 | 163 | f = form(files="not a file") 164 | assert not f.validate() 165 | assert f.files.errors[0] == "This field is required." 166 | 167 | f = form(files=[FileStorage()]) 168 | assert not f.validate() 169 | 170 | f = form(files=[FileStorage(filename="real")]) 171 | assert f.validate() 172 | 173 | 174 | def test_files_allowed(form): 175 | form.files.kwargs["validators"] = [FileAllowed(("txt",))] 176 | 177 | f = form() 178 | assert f.validate() 179 | 180 | f = form( 181 | files=[FileStorage(filename="test.txt"), FileStorage(filename="test2.txt")] 182 | ) 183 | assert f.validate() 184 | 185 | f = form(files=[FileStorage(filename="test.txt"), FileStorage(filename="test.png")]) 186 | assert not f.validate() 187 | assert f.files.errors[0] == "File does not have an approved extension: txt" 188 | 189 | 190 | def test_files_allowed_uploadset(app, form): 191 | pytest.importorskip("flask_uploads") 192 | from flask_uploads import configure_uploads 193 | from flask_uploads import UploadSet 194 | 195 | app.config["UPLOADS_DEFAULT_DEST"] = "uploads" 196 | txt = UploadSet("txt", extensions=("txt",)) 197 | configure_uploads(app, (txt,)) 198 | form.files.kwargs["validators"] = [FileAllowed(txt)] 199 | 200 | f = form() 201 | assert f.validate() 202 | 203 | f = form( 204 | files=[FileStorage(filename="test.txt"), FileStorage(filename="test2.txt")] 205 | ) 206 | assert f.validate() 207 | 208 | f = form(files=[FileStorage(filename="test.txt"), FileStorage(filename="test.png")]) 209 | assert not f.validate() 210 | assert f.files.errors[0] == "File does not have an approved extension." 211 | 212 | 213 | def test_validate_base_multiple_field(req_ctx): 214 | class F(FlaskForm): 215 | class Meta: 216 | csrf = False 217 | 218 | f = BaseMultipleFileField(validators=[FileRequired()]) 219 | 220 | assert not F().validate() 221 | assert not F(f=[FileStorage()]).validate() 222 | assert F(f=[FileStorage(filename="real")]).validate() 223 | 224 | 225 | def test_file_size_small_files_pass_validation(form, tmp_path): 226 | form.files.kwargs["validators"] = [FileSize(max_size=100)] 227 | path = tmp_path / "test_file_smaller_than_max.txt" 228 | path.write_bytes(b"\0") 229 | 230 | with path.open("rb") as file: 231 | f = form(files=[FileStorage(file)]) 232 | assert f.validate() 233 | 234 | 235 | @pytest.mark.parametrize( 236 | "min_size, max_size, invalid_file_size", [(1, 100, 0), (0, 100, 101)] 237 | ) 238 | def test_file_size_invalid_file_sizes_fails_validation( 239 | form, min_size, max_size, invalid_file_size, tmp_path 240 | ): 241 | form.files.kwargs["validators"] = [FileSize(min_size=min_size, max_size=max_size)] 242 | path = tmp_path / "test_file_invalid_size.txt" 243 | path.write_bytes(b"\0" * invalid_file_size) 244 | 245 | with path.open("rb") as file: 246 | f = form(files=[FileStorage(file)]) 247 | assert not f.validate() 248 | assert ( 249 | f.files.errors[0] 250 | == f"File must be between {min_size} and {max_size} bytes." 251 | ) 252 | 253 | 254 | def test_files_length(form, min_num=2, max_num=3): 255 | form.files.kwargs["validators"] = [Length(min_num, max_num)] 256 | 257 | f = form(files=[FileStorage("1")]) 258 | assert not f.validate() 259 | assert ( 260 | f.files.errors[0] 261 | == f"Field must be between {min_num} and {max_num} characters long." 262 | ) 263 | 264 | f = form( 265 | files=[ 266 | FileStorage(filename="1"), 267 | FileStorage(filename="2"), 268 | ] 269 | ) 270 | assert f.validate() 271 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | Version 1.2.2 5 | ------------- 6 | 7 | Released 2024-10-20 8 | 9 | - Move the project to the pallets-eco organization. :pr:`602` 10 | - Stop support for Python 3.8. Start support for Python 3.13. :pr:`603` 11 | 12 | Version 1.2.1 13 | ------------- 14 | 15 | Released 2023-10-02 16 | 17 | - Fix a bug introduced with :pr:`556` where file validators were editing 18 | the file fields content. :pr:`578` 19 | 20 | Version 1.2.0 21 | ------------- 22 | 23 | Released 2023-10-01 24 | 25 | - Add field ``MultipleFileField``. ``FileRequired``, ``FileAllowed``, ``FileSize`` 26 | now can be used to validate multiple files :pr:`556` :issue:`338` 27 | 28 | Version 1.1.2 29 | ------------- 30 | 31 | Released 2023-09-29 32 | 33 | - Fixed Flask 2.3 deprecations of ``werkzeug.urls.url_encode`` and 34 | ``flask.Markup`` :pr:`565` :issue:`561` 35 | - Stop support for python 3.7 :pr:`574` 36 | - Use `pyproject.toml` instead of `setup.cfg` :pr:`576` 37 | - Fixed nested blueprint CSRF exemption :pr:`572` 38 | 39 | Version 1.1.1 40 | ------------- 41 | 42 | Released 2023-01-17 43 | 44 | - Fixed `validate` `extra_validators` parameter. :pr:`548` 45 | 46 | Version 1.1.0 47 | ------------- 48 | 49 | Released 2023-01-15 50 | 51 | - Drop support for Python 3.6. 52 | - ``validate_on_submit`` takes a ``extra_validators`` parameters :pr:`479` 53 | - Stop supporting Flask-Babelex :pr:`540` 54 | - Support for python 3.11 :pr:`542` 55 | - Remove unused call to `JSONEncoder` :pr:`536` 56 | 57 | Version 1.0.1 58 | ------------- 59 | 60 | Released 2022-03-31 61 | 62 | - Update compatibility with the latest Werkzeug release. :issue:`511` 63 | 64 | 65 | Version 1.0.0 66 | -------------- 67 | 68 | Released 2021-11-07 69 | 70 | - Deprecated items removal :pr:`484` 71 | - Support for alternatives captcha services :pr:`425` :pr:`342` 72 | :pr:`387` :issue:`384` 73 | 74 | Version 0.15.1 75 | -------------- 76 | 77 | Released 2021-05-25 78 | 79 | - Add ``python_requires`` metadata to avoid installing on unsupported 80 | Python versions. :pr:`442` 81 | 82 | 83 | Version 0.15.0 84 | -------------- 85 | 86 | Released 2021-05-24 87 | 88 | - Drop support for Python < 3.6. :pr:`416` 89 | - ``FileSize`` validator. :pr:`307, 365` 90 | - Extra requirement ``email`` installs the ``email_validator`` 91 | package. :pr:`423` 92 | - Fixed Flask 2.0 warnings. :pr:`434` 93 | - Various documentation fixes. :pr:`315, 321, 335, 344, 386, 400`, 94 | :pr:`404, 420, 437` 95 | - Various CI fixes. :pr:`405, 438` 96 | 97 | 98 | Version 0.14.3 99 | -------------- 100 | 101 | Released 2020-02-06 102 | 103 | - Fix deprecated imports from ``werkzeug`` and ``collections``. 104 | 105 | 106 | Version 0.14.2 107 | -------------- 108 | 109 | Released 2017-01-10 110 | 111 | - Fix bug where ``FlaskForm`` assumed ``meta`` argument was not 112 | ``None`` if it was passed. :issue:`278` 113 | 114 | 115 | Version 0.14.1 116 | -------------- 117 | 118 | Released 2017-01-10 119 | 120 | - Fix bug where the file validators would incorrectly identify an 121 | empty file as valid data. :issue:`276`, :pr:`277` 122 | 123 | - ``FileField`` is no longer deprecated. The data is checked 124 | during processing and only set if it's a valid file. 125 | - ``has_file`` *is* deprecated; it's now equivalent to 126 | ``bool(field.data)``. 127 | - ``FileRequired`` and ``FileAllowed`` work with both the 128 | Flask-WTF and WTForms ``FileField`` classes. 129 | - The ``Optional`` validator now works with ``FileField``. 130 | 131 | 132 | Version 0.14 133 | ------------ 134 | 135 | Released 2017-01-06 136 | 137 | - Use ItsDangerous to sign CSRF tokens and check expiration instead of 138 | doing it ourselves. :issue:`264` 139 | 140 | - All tokens are URL safe, removing the ``url_safe`` parameter 141 | from ``generate_csrf``. :issue:`206` 142 | - All tokens store a timestamp, which is checked in 143 | ``validate_csrf``. The ``time_limit`` parameter of 144 | ``generate_csrf`` is removed. 145 | 146 | - Remove the ``app`` attribute from ``CsrfProtect``, use 147 | ``current_app``. :issue:`264` 148 | - ``CsrfProtect`` protects the ``DELETE`` method by default. 149 | :issue:`264` 150 | - The same CSRF token is generated for the lifetime of a request. It 151 | is exposed as ``g.csrf_token`` for use during testing. 152 | :issue:`227, 264` 153 | - ``CsrfProtect.error_handler`` is deprecated. :issue:`264` 154 | 155 | - Handlers that return a response work in addition to those that 156 | raise an error. The behavior was not clear in previous docs. 157 | - :issue:`200, 209, 243, 252` 158 | 159 | - Use ``Form.Meta`` instead of deprecated ``SecureForm`` for CSRF (and 160 | everything else). :issue:`216, 271` 161 | 162 | - ``csrf_enabled`` parameter is still recognized but deprecated. 163 | All other attributes and methods from ``SecureForm`` are 164 | removed. :issue:`271` 165 | 166 | - Provide ``WTF_CSRF_FIELD_NAME`` to configure the name of the CSRF 167 | token. :issue:`271` 168 | - ``validate_csrf`` raises ``wtforms.ValidationError`` with specific 169 | messages instead of returning ``True`` or ``False``. This breaks 170 | anything that was calling the method directly. :issue:`239, 271` 171 | 172 | - CSRF errors are logged as well as raised. :issue:`239` 173 | 174 | - ``CsrfProtect`` is renamed to ``CSRFProtect``. A deprecation warning 175 | is issued when using the old name. ``CsrfError`` is renamed to 176 | ``CSRFError`` without deprecation. :issue:`271` 177 | - ``FileField`` is deprecated because it no longer provides 178 | functionality over the provided validators. Use 179 | ``wtforms.FileField`` directly. :issue:`272` 180 | 181 | 182 | Version 0.13.1 183 | -------------- 184 | 185 | Released 2016-10-6 186 | 187 | - Deprecation warning for ``Form`` is shown during ``__init__`` 188 | instead of immediately when subclassing. :issue:`262` 189 | - Don't use ``pkg_resources`` to get version, for compatibility with 190 | GAE. :issue:`261` 191 | 192 | 193 | Version 0.13 194 | ------------ 195 | 196 | Released 2016-09-29 197 | 198 | - ``Form`` is renamed to ``FlaskForm`` in order to avoid name 199 | collision with WTForms's base class. Using ``Form`` will show a 200 | deprecation warning. :issue:`250` 201 | - ``hidden_tag`` no longer wraps the hidden inputs in a hidden div. 202 | This is valid HTML5 and any modern HTML parser will behave 203 | correctly. :issue:`193, 217` 204 | - ``flask_wtf.html5`` is deprecated. Import directly from 205 | ``wtforms.fields.html5``. :issue:`251` 206 | - ``is_submitted`` is true for ``PATCH`` and ``DELETE`` in addition to 207 | ``POST`` and ``PUT``. :issue:`187` 208 | - ``generate_csrf`` takes a ``token_key`` parameter to specify the key 209 | stored in the session. :issue:`206` 210 | - ``generate_csrf`` takes a ``url_safe`` parameter to allow the token 211 | to be used in URLs. :issue:`206` 212 | - ``form.data`` can be accessed multiple times without raising an 213 | exception. :issue:`248` 214 | - File extension with multiple parts (``.tar.gz``) can be used in the 215 | ``FileAllowed`` validator. :issue:`201` 216 | 217 | 218 | Version 0.12 219 | ------------ 220 | 221 | Released 2015-07-09 222 | 223 | - Abstract ``protect_csrf()`` into a separate method. 224 | - Update reCAPTCHA configuration. 225 | - Fix reCAPTCHA error handle. 226 | 227 | 228 | Version 0.11 229 | ------------ 230 | 231 | Released 2015-01-21 232 | 233 | - Use the new reCAPTCHA API. :pr:`164` 234 | 235 | 236 | Version 0.10.3 237 | -------------- 238 | 239 | Released 2014-11-16 240 | 241 | - Add configuration: ``WTF_CSRF_HEADERS``. :pr:`159` 242 | - Support customize hidden tags. :pr:`150` 243 | - And many more bug fixes. 244 | 245 | 246 | Version 0.10.2 247 | -------------- 248 | 249 | Released 2014-09-03 250 | 251 | - Update translation for reCaptcha. :pr:`146` 252 | 253 | 254 | Version 0.10.1 255 | -------------- 256 | 257 | Released 2014-08-26 258 | 259 | - Update ``RECAPTCHA_API_SERVER_URL``. :pr:`145` 260 | - Update requirement Werkzeug >= 0.9.5. 261 | - Fix ``CsrfProtect`` exempt for blueprints. :pr:`143` 262 | 263 | 264 | Version 0.10.0 265 | -------------- 266 | 267 | Released 2014-07-16 268 | 269 | - Add configuration: ``WTF_CSRF_METHODS``. 270 | - Support WTForms 2.0 now. 271 | - Fix CSRF validation without time limit (``time_limit=False``). 272 | - ``csrf_exempt`` supports blueprint. :issue:`111` 273 | 274 | 275 | Version 0.9.5 276 | ------------- 277 | 278 | Released 2014-03-21 279 | 280 | - ``csrf_token`` for all template types. :pr:`112` 281 | - Make ``FileRequired`` a subclass of ``InputRequired``. :pr:`108` 282 | 283 | 284 | Version 0.9.4 285 | ------------- 286 | 287 | Released 2013-12-20 288 | 289 | - Bugfix for ``csrf`` module when form has a prefix. 290 | - Compatible support for WTForms 2. 291 | - Remove file API for ``FileField`` 292 | 293 | 294 | Version 0.9.3 295 | ------------- 296 | 297 | Released 2013-10-02 298 | 299 | - Fix validation of recaptcha when app in testing mode. :pr:`89` 300 | - Bugfix for ``csrf`` module. :pr:`91` 301 | 302 | 303 | Version 0.9.2 304 | ------------- 305 | 306 | Released 2013-09-11 307 | 308 | - Upgrade WTForms to 1.0.5. 309 | - No lazy string for i18n. :issue:`77` 310 | - No ``DateInput`` widget in HTML5. :issue:`81` 311 | - ``PUT`` and ``PATCH`` for CSRF. :issue:`86` 312 | 313 | 314 | Version 0.9.1 315 | ------------- 316 | 317 | Released 2013-08-21 318 | 319 | - Compatibility with Flask < 0.10. :issue:`82` 320 | 321 | 322 | Version 0.9.0 323 | ------------- 324 | 325 | Released 2013-08-15 326 | 327 | - Add i18n support. :issue:`65` 328 | - Use default HTML5 widgets and fields provided by WTForms. 329 | - Python 3.3+ support. 330 | - Redesign form, replace ``SessionSecureForm``. 331 | - CSRF protection solution. 332 | - Drop WTForms imports. 333 | - Fix recaptcha i18n support. 334 | - Fix recaptcha validator for Python 3. 335 | - More test cases, it's 90%+ coverage now. 336 | - Redesign documentation. 337 | 338 | 339 | Version 0.8.4 340 | ------------- 341 | 342 | Released 2013-03-28 343 | 344 | - Recaptcha Validator now returns provided message. :issue:`66` 345 | - Minor doc fixes. 346 | - Fixed issue with tests barking because of nose/multiprocessing 347 | issue. 348 | 349 | 350 | Version 0.8.3 351 | ------------- 352 | 353 | Released 2013-03-13 354 | 355 | - Update documentation to indicate pending deprecation of WTForms 356 | namespace facade. 357 | - PEP8 fixes. :issue:`64` 358 | - Fix Recaptcha widget. :issue:`49` 359 | 360 | 361 | Version 0.8.2 and prior 362 | ----------------------- 363 | 364 | Initial development by Dan Jacob and Ron Duplain. 365 | -------------------------------------------------------------------------------- /src/flask_wtf/csrf.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import logging 4 | import os 5 | from urllib.parse import urlparse 6 | 7 | from flask import Blueprint 8 | from flask import current_app 9 | from flask import g 10 | from flask import request 11 | from flask import session 12 | from itsdangerous import BadData 13 | from itsdangerous import SignatureExpired 14 | from itsdangerous import URLSafeTimedSerializer 15 | from werkzeug.exceptions import BadRequest 16 | from wtforms import ValidationError 17 | from wtforms.csrf.core import CSRF 18 | 19 | __all__ = ("generate_csrf", "validate_csrf", "CSRFProtect") 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def generate_csrf(secret_key=None, token_key=None): 24 | """Generate a CSRF token. The token is cached for a request, so multiple 25 | calls to this function will generate the same token. 26 | 27 | During testing, it might be useful to access the signed token in 28 | ``g.csrf_token`` and the raw token in ``session['csrf_token']``. 29 | 30 | :param secret_key: Used to securely sign the token. Default is 31 | ``WTF_CSRF_SECRET_KEY`` or ``SECRET_KEY``. 32 | :param token_key: Key where token is stored in session for comparison. 33 | Default is ``WTF_CSRF_FIELD_NAME`` or ``'csrf_token'``. 34 | """ 35 | 36 | secret_key = _get_config( 37 | secret_key, 38 | "WTF_CSRF_SECRET_KEY", 39 | current_app.secret_key, 40 | message="A secret key is required to use CSRF.", 41 | ) 42 | field_name = _get_config( 43 | token_key, 44 | "WTF_CSRF_FIELD_NAME", 45 | "csrf_token", 46 | message="A field name is required to use CSRF.", 47 | ) 48 | 49 | if field_name not in g: 50 | s = URLSafeTimedSerializer(secret_key, salt="wtf-csrf-token") 51 | 52 | if field_name not in session: 53 | session[field_name] = hashlib.sha1(os.urandom(64)).hexdigest() 54 | 55 | try: 56 | token = s.dumps(session[field_name]) 57 | except TypeError: 58 | session[field_name] = hashlib.sha1(os.urandom(64)).hexdigest() 59 | token = s.dumps(session[field_name]) 60 | 61 | setattr(g, field_name, token) 62 | 63 | return g.get(field_name) 64 | 65 | 66 | def validate_csrf(data, secret_key=None, time_limit=None, token_key=None): 67 | """Check if the given data is a valid CSRF token. This compares the given 68 | signed token to the one stored in the session. 69 | 70 | :param data: The signed CSRF token to be checked. 71 | :param secret_key: Used to securely sign the token. Default is 72 | ``WTF_CSRF_SECRET_KEY`` or ``SECRET_KEY``. 73 | :param time_limit: Number of seconds that the token is valid. Default is 74 | ``WTF_CSRF_TIME_LIMIT`` or 3600 seconds (60 minutes). 75 | :param token_key: Key where token is stored in session for comparison. 76 | Default is ``WTF_CSRF_FIELD_NAME`` or ``'csrf_token'``. 77 | 78 | :raises ValidationError: Contains the reason that validation failed. 79 | 80 | .. versionchanged:: 0.14 81 | Raises ``ValidationError`` with a specific error message rather than 82 | returning ``True`` or ``False``. 83 | """ 84 | 85 | secret_key = _get_config( 86 | secret_key, 87 | "WTF_CSRF_SECRET_KEY", 88 | current_app.secret_key, 89 | message="A secret key is required to use CSRF.", 90 | ) 91 | field_name = _get_config( 92 | token_key, 93 | "WTF_CSRF_FIELD_NAME", 94 | "csrf_token", 95 | message="A field name is required to use CSRF.", 96 | ) 97 | time_limit = _get_config(time_limit, "WTF_CSRF_TIME_LIMIT", 3600, required=False) 98 | 99 | if not data: 100 | raise ValidationError("The CSRF token is missing.") 101 | 102 | if field_name not in session: 103 | raise ValidationError("The CSRF session token is missing.") 104 | 105 | s = URLSafeTimedSerializer(secret_key, salt="wtf-csrf-token") 106 | 107 | try: 108 | token = s.loads(data, max_age=time_limit) 109 | except SignatureExpired as e: 110 | raise ValidationError("The CSRF token has expired.") from e 111 | except BadData as e: 112 | raise ValidationError("The CSRF token is invalid.") from e 113 | 114 | if not hmac.compare_digest(session[field_name], token): 115 | raise ValidationError("The CSRF tokens do not match.") 116 | 117 | 118 | def _get_config( 119 | value, config_name, default=None, required=True, message="CSRF is not configured." 120 | ): 121 | """Find config value based on provided value, Flask config, and default 122 | value. 123 | 124 | :param value: already provided config value 125 | :param config_name: Flask ``config`` key 126 | :param default: default value if not provided or configured 127 | :param required: whether the value must not be ``None`` 128 | :param message: error message if required config is not found 129 | :raises KeyError: if required config is not found 130 | """ 131 | 132 | if value is None: 133 | value = current_app.config.get(config_name, default) 134 | 135 | if required and value is None: 136 | raise RuntimeError(message) 137 | 138 | return value 139 | 140 | 141 | class _FlaskFormCSRF(CSRF): 142 | def setup_form(self, form): 143 | self.meta = form.meta 144 | return super().setup_form(form) 145 | 146 | def generate_csrf_token(self, csrf_token_field): 147 | return generate_csrf( 148 | secret_key=self.meta.csrf_secret, token_key=self.meta.csrf_field_name 149 | ) 150 | 151 | def validate_csrf_token(self, form, field): 152 | if g.get("csrf_valid", False): 153 | # already validated by CSRFProtect 154 | return 155 | 156 | try: 157 | validate_csrf( 158 | field.data, 159 | self.meta.csrf_secret, 160 | self.meta.csrf_time_limit, 161 | self.meta.csrf_field_name, 162 | ) 163 | except ValidationError as e: 164 | logger.info(e.args[0]) 165 | raise 166 | 167 | 168 | class CSRFProtect: 169 | """Enable CSRF protection globally for a Flask app. 170 | 171 | :: 172 | 173 | app = Flask(__name__) 174 | csrf = CSRFProtect(app) 175 | 176 | Checks the ``csrf_token`` field sent with forms, or the ``X-CSRFToken`` 177 | header sent with JavaScript requests. Render the token in templates using 178 | ``{{ csrf_token() }}``. 179 | 180 | See the :ref:`csrf` documentation. 181 | """ 182 | 183 | def __init__(self, app=None): 184 | self._exempt_views = set() 185 | self._exempt_blueprints = set() 186 | 187 | if app: 188 | self.init_app(app) 189 | 190 | def init_app(self, app): 191 | app.extensions["csrf"] = self 192 | 193 | app.config.setdefault("WTF_CSRF_ENABLED", True) 194 | app.config.setdefault("WTF_CSRF_CHECK_DEFAULT", True) 195 | app.config["WTF_CSRF_METHODS"] = set( 196 | app.config.get("WTF_CSRF_METHODS", ["POST", "PUT", "PATCH", "DELETE"]) 197 | ) 198 | app.config.setdefault("WTF_CSRF_FIELD_NAME", "csrf_token") 199 | app.config.setdefault("WTF_CSRF_HEADERS", ["X-CSRFToken", "X-CSRF-Token"]) 200 | app.config.setdefault("WTF_CSRF_TIME_LIMIT", 3600) 201 | app.config.setdefault("WTF_CSRF_SSL_STRICT", True) 202 | 203 | app.jinja_env.globals["csrf_token"] = generate_csrf 204 | app.context_processor(lambda: {"csrf_token": generate_csrf}) 205 | 206 | @app.before_request 207 | def csrf_protect(): 208 | if not app.config["WTF_CSRF_ENABLED"]: 209 | return 210 | 211 | if not app.config["WTF_CSRF_CHECK_DEFAULT"]: 212 | return 213 | 214 | if request.method not in app.config["WTF_CSRF_METHODS"]: 215 | return 216 | 217 | if not request.endpoint: 218 | return 219 | 220 | if app.blueprints.get(request.blueprint) in self._exempt_blueprints: 221 | return 222 | 223 | view = app.view_functions.get(request.endpoint) 224 | dest = f"{view.__module__}.{view.__name__}" 225 | 226 | if dest in self._exempt_views: 227 | return 228 | 229 | self.protect() 230 | 231 | def _get_csrf_token(self): 232 | # find the token in the form data 233 | field_name = current_app.config["WTF_CSRF_FIELD_NAME"] 234 | base_token = request.form.get(field_name) 235 | 236 | if base_token: 237 | return base_token 238 | 239 | # if the form has a prefix, the name will be {prefix}-csrf_token 240 | for key in request.form: 241 | if key.endswith(field_name): 242 | csrf_token = request.form[key] 243 | 244 | if csrf_token: 245 | return csrf_token 246 | 247 | # find the token in the headers 248 | for header_name in current_app.config["WTF_CSRF_HEADERS"]: 249 | csrf_token = request.headers.get(header_name) 250 | 251 | if csrf_token: 252 | return csrf_token 253 | 254 | return None 255 | 256 | def protect(self): 257 | if request.method not in current_app.config["WTF_CSRF_METHODS"]: 258 | return 259 | 260 | try: 261 | validate_csrf(self._get_csrf_token()) 262 | except ValidationError as e: 263 | logger.info(e.args[0]) 264 | self._error_response(e.args[0]) 265 | 266 | if request.is_secure and current_app.config["WTF_CSRF_SSL_STRICT"]: 267 | if not request.referrer: 268 | self._error_response("The referrer header is missing.") 269 | 270 | good_referrer = f"https://{request.host}/" 271 | 272 | if not same_origin(request.referrer, good_referrer): 273 | self._error_response("The referrer does not match the host.") 274 | 275 | g.csrf_valid = True # mark this request as CSRF valid 276 | 277 | def exempt(self, view): 278 | """Mark a view or blueprint to be excluded from CSRF protection. 279 | 280 | :: 281 | 282 | @app.route('/some-view', methods=['POST']) 283 | @csrf.exempt 284 | def some_view(): 285 | ... 286 | 287 | :: 288 | 289 | bp = Blueprint(...) 290 | csrf.exempt(bp) 291 | 292 | """ 293 | 294 | if isinstance(view, Blueprint): 295 | self._exempt_blueprints.add(view) 296 | return view 297 | 298 | if isinstance(view, str): 299 | view_location = view 300 | else: 301 | view_location = ".".join((view.__module__, view.__name__)) 302 | 303 | self._exempt_views.add(view_location) 304 | return view 305 | 306 | def _error_response(self, reason): 307 | raise CSRFError(reason) 308 | 309 | 310 | class CSRFError(BadRequest): 311 | """Raise if the client sends invalid CSRF data with the request. 312 | 313 | Generates a 400 Bad Request response with the failure reason by default. 314 | Customize the response by registering a handler with 315 | :meth:`flask.Flask.errorhandler`. 316 | """ 317 | 318 | description = "CSRF validation failed." 319 | 320 | 321 | def same_origin(current_uri, compare_uri): 322 | current = urlparse(current_uri) 323 | compare = urlparse(compare_uri) 324 | 325 | return ( 326 | current.scheme == compare.scheme 327 | and current.hostname == compare.hostname 328 | and current.port == compare.port 329 | ) 330 | --------------------------------------------------------------------------------