├── 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 |
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 |
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 |
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 |
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 |
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 |
74 | {% for error in form.name.errors %}
75 | - {{ error }}
76 | {% endfor %}
77 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------