Search code examples
pythonflaskcsrfflask-wtformswtforms

Flask-WTF throws error when csrf_enabled is True (SECRET_KEY is set)


I've run into a problem concerning Flask-WTF's csrf protection.

When a form is instantiated like this:

uform = UserForm(csrf_enabled=False)

everything works as expected, and the form is correctly displayed. However:

uform = UserForm()

results in a TypeError:

Traceback (most recent call last):
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/app.py", line 2463, in __call__
    return self.wsgi_app(environ, start_response)
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/app.py", line 2449, in wsgi_app
    response = self.handle_exception(e)
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/app.py", line 1866, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/app.py", line 2446, in wsgi_app
    response = self.full_dispatch_request()
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/app.py", line 1952, in full_dispatch_request
    return self.finalize_request(rv)
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/app.py", line 1969, in finalize_request
    response = self.process_response(response)
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/app.py", line 2268, in process_response
    self.session_interface.save_session(self, ctx.session, response)
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/sessions.py", line 387, in save_session
    samesite=samesite,
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/werkzeug/wrappers/base_response.py", line 481, in set_cookie
    samesite=samesite,
  File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/werkzeug/http.py", line 1163, in dump_cookie
    buf = [key + b"=" + _cookie_quote(value)]
TypeError: unsupported operand type(s) for +: 'NoneType' and 'bytes'

A SECRET_KEY is set (and so is a WTF_CSRF_SECRET_KEY, just to make sure Flask-WTF didn't expect it, even though according to the docs it's optional).

Here's the rest of the (relevant) code:

admin/routes.py

from flask import current_app as app
from .. import db
from ..models import User
from .forms import UserForm

# Set up a Blueprint
admin_bp = Blueprint('admin_bp', __name__, template_folder='templates', static_folder='static')

@admin_bp.route("users/add/", methods=["GET", "POST"])
def add_user():
    #Add user to the DB
    add_user = True

    #uform = UserForm(csrf_enabled=True)
    uform = UserForm()

    if uform.validate_on_submit():
        user = User(username=uform.username.data, email=uform.email.data, admin=uform.admin.data, address=uform.address.data, delivery=uform.delivery.data)
        user.pwhash(uform.password.data)

        try:
            db.session.add(user)
            db.session.commit()
            flash("Benutzer '", user.username, "' erfolgreich hinzugef  gt.")
        except:
            flash("Fehler: Benutzer konnte nicht hinzugef  gt werden. Existiert Benutzer bereits?")

        return redirect(url_for("admin_bp.users"))

    return render_template("/user.html", action="Hinzuf  gen", add_user=add_user, uform=uform, title="Benutzer hinzuf  gen")

admin/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, PasswordField, BooleanField, SelectField
from wtforms.validators import DataRequired, Email

class UserForm(FlaskForm):
    username = StringField("Username", validators=[DataRequired()])
    email = StringField("Email", validators=[DataRequired(), Email()])
    password = StringField("Neues Passwort")
    address = StringField("Adresse")
    admin = BooleanField("Admin?")
    delivery = SelectField("Delivery", choices=[("1", "test1"), ("2", "test2")])
    submit = SubmitField("OK")

__init__.py:

from flask_sqlalchemy import SQLAlchemy
from flask_static_compress import FlaskStaticCompress
from flask_bootstrap import Bootstrap
from flask_wtf.csrf import CSRFProtect

# Globally accessible libraries
db = SQLAlchemy()
bs = Bootstrap()
csrf = CSRFProtect()

def create_app():
    app = Flask(__name__, instance_relative_config=False, static_folder="static", template_folder="templates")
    from config import Config
    app.config.from_object('config.DevConfig')
    db.init_app(app)
    bs.init_app(app)
    csrf.init_app(app)

    with app.app_context():

        from .models import Image, Gallery, ImageGalleryMap, Delivery, ImageDeliveryMap, Blogpost, User, Log
        db.create_all()

        compress = FlaskStaticCompress(app)

        # Register Blueprints
        from website.admin.routes import admin_bp
        from website.delivery.routes import delivery_bp
        from website.landing.routes import landing_bp
        from website.public.routes import public_bp

        app.register_blueprint(admin_bp, url_prefix='/admin')
        app.register_blueprint(delivery_bp, url_prefix='/delivery')
        app.register_blueprint(landing_bp, url_prefix='/landing')
        app.register_blueprint(public_bp, url_prefix='/')

        return app

Does anyone have an idea how to fix this? For (very obvious) reasons, I'm not exactly keen on setting WTF_CSRF_ENABLED=False...


Solution

  • The only way that I can replicate the Traceback that you have given in your example is if app.secret_key is valid, but app.session_cookie_name is set to None.

    Please see the below script that I used for testing. Running this script and visiting http://127.0.0.1:5000 raises TypeError: unsupported operand type(s) for +: 'NoneType' and 'bytes', which is the exact exception that you have posted above. If the secret key is not set, we get a different error when we try to instantiate the form, before flask tries to serve the request it raises KeyError: 'A secret key is required to use CSRF.' . Here's the script:

    from flask import Flask, render_template_string
    from flask_wtf import FlaskForm
    from wtforms import StringField, SubmitField
    
    
    app = Flask(__name__)
    # comment out below and a KeyError is raised, not a TypeError.
    # So the fact that the code raises the TypeError from trying to generate
    # a cookie means that the secret key is set, the form is instantiating and
    # flask is trying to serve the request. So we can rule out this being a
    # problem with secret key not set.
    app.secret_key = "lskdjflksdj"
    # comment out below and the app runs
    app.session_cookie_name = None
    
    
    class MyForm(FlaskForm):
        a_str = StringField()
        submit = SubmitField()
    
    
    template = """
    <form action="" method="POST">
    {{form.a_str()}}
    {{form.submit()}}
    </form>
    """
    
    
    @app.route("/", methods=["GET", "POST"])
    def route():
        # setting csrf_enabled=False makes the problem go away.
        form = MyForm(csrf_enabled=True)
        return render_template_string(template, form=form)
    
    
    if __name__ == "__main__":
        app.run(debug=True)
    

    So why does setting csrf_enabled=False on the form stop the error? The token that flask is generating when the error occurs is the csrf token. When we disable csrf checking on the form, that token no longer needs to be generated and we don't run into the error.

    The fact that toggling csrf protection on the form has a direct bearing whether the error raises, or not, does make it look like an issue with the secret key configuration but the real question is why is the value of the key parameter passed into dump_cookie() NoneType?

    If you follow the Traceback, you'll see that the last call inside the flask library is here: File "/home/charlotte/hochzeit/venv/lib/python3.6/site-packages/flask/sessions.py", line 387, in save_session. Here's the source:

            response.set_cookie(
                name,
                val,
                expires=expires,
                httponly=httponly,
                domain=domain,
                path=path,
                secure=secure,
                samesite=samesite,
            )
    

    You can see for yourself that the first argument passed to set_cookie above, is passed to the key parameter in werkzeug.wrappers.base_response.set_cookie, which itself is passed through to the key parameter in werkzeug.http.dump_cookie (which is None in the error message).

    The value of name in the save_session snippet above is defined earlier in the function as name = self.get_cookie_name(app), and the body of the get_cookie_name method is simply return app.session_cookie_name.

    The default value for app.session_cookie_name is "session", but somehow that's getting overridden with None in your configuration.