Attacker-controlled cookie names or session identifiers

Attacker-controlled cookie names or session identifiers

Description

Cookie APIs handle quotes and control characters when serializing ordinary values. Saving a request-derived preference under a fixed, non-session name such as theme is therefore not cookie injection by itself.

Problems can arise when the application trusts the origin of cookie names or session identifiers:

  • An untrusted value becomes the cookie name.
  • An untrusted value becomes a session identifier.
  • An untrusted string becomes the entire Set-Cookie header.

Copying external input into a session cookie does not always establish account takeover. Session fixation requires the application to accept an identifier known to the attacker and reuse that identifier or session state after the victim authenticates. Secure, HttpOnly, and SameSite do not fix this lifecycle flaw.

Untrusted cookie names can overwrite a session cookie or another security-sensitive cookie. Giving untrusted input control of the entire header also gives it control of the name, value, scope, and attributes.

Potential impact

  • Session fixation and account takeover if an attacker-known identifier remains valid after login.
  • Authentication or application-state tampering by overwriting a security-sensitive cookie name.
  • Weakened scope or transmission policy through attacker-controlled Domain, Path, SameSite, Secure, or HttpOnly attributes.

Review missing security attributes, sensitive cookie contents, and XSS caused by rendering cookie values into HTML separately.

Remediation

  • Use the framework's session manager instead of copying request data into a session cookie. The session system must generate server-side identifiers and accept only values it issued.
  • Renew the session after login and every privilege change, and invalidate the old identifier. Django's login() uses cycle_key() or replaces the existing session as needed. For Flask or Starlette signed-cookie sessions, clear pre-authentication state before recording the authenticated user. Use the rotation API of any server-side session extension.
  • Keep cookie names fixed. If name selection is required, map client input to a small set of server-owned constants. Do not trust an allow-list supplied by the client.
  • Use Response.set_cookie() with a trusted name. Do not assign a request-derived string as the entire Set-Cookie header. Werkzeug's dump_cookie() structures ordinary cookie values, but neither validates the origin of a session identifier nor rotates it. The server must control names and security attributes.
  • For session cookies over HTTPS, use Secure and HttpOnly, choose SameSite to suit the flow, minimize Domain and Path, and prefer __Host- where compatible. These attributes supplement session rotation rather than replace it.
  • Validate fixed non-session cookie values against business requirements. Sign client-side state whose integrity matters, or store it on the server. A signature protects against tampering, not disclosure.

Generating a value with secrets.token_urlsafe() and putting it in a cookie is not enough. The session lifecycle must also accept only server-issued identifiers, rotate them at authentication, and invalidate old identifiers.

Examples

Session management

Before

FastAPI/Starlette copies a request value into a session cookie:

python
from fastapi import FastAPI, Request
from starlette.responses import Response

app = FastAPI()


@app.get("/set-session")
async def set_session(request: Request):
    response = Response("ok")
    response.set_cookie(
        key="session",
        value=request.query_params["sid"],  # Unsafe: a session value known to the attacker
        secure=True,
        httponly=True,
        samesite="lax",
    )
    return response

Even with all security attributes set, session fixation remains possible if the application reuses the attacker-selected session value after authentication.

After

Use Django's session manager:

python
from django.contrib.auth import authenticate, login
from django.http import HttpResponse


def sign_in(request):
    user = authenticate(
        request,
        username=request.POST["username"],
        password=request.POST["password"],
    )
    if user is None:
        return HttpResponse(status=401)

    login(request, user)  # Django rotates or safely replaces the session key
    return HttpResponse(status=204)

Use Flask's signed-cookie session:

python
import os

from flask import Flask, abort, request, session

app = Flask(__name__)
app.config.from_mapping(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
)


@app.post("/login")
def sign_in():
    user = verify_credentials(
        request.form["username"],
        request.form["password"],
    )
    if user is None:
        abort(401)

    session.clear()  # Remove pre-authentication state
    session["user_id"] = user.id
    return {"status": "ok"}

verify_credentials represents the application's authentication function. The important change is to avoid copying request data into a session identifier: after successful authentication, clear the old state and record the authenticated user in the framework session.

Cookie names and headers

Before

Flask uses a request value as the cookie name:

python
from flask import Flask, make_response, request

app = Flask(__name__)


@app.get("/set-cookie")
def set_cookie():
    response = make_response("ok")
    response.set_cookie(request.args["name"], "1")  # Unsafe: a dynamic cookie name
    return response

Django sets the entire Set-Cookie header from request input:

python
from django.http import HttpResponse


def set_raw_cookie(request):
    response = HttpResponse("ok")
    response.headers["Set-Cookie"] = request.GET["raw"]  # Unsafe: the entire raw header
    return response

Flask uses request input as a mapping fallback or a serialized session value:

python
from flask import make_response, request
from werkzeug.http import dump_cookie


def unsafe_cookie_defaults():
    names = {"theme": "theme", "locale": "locale"}
    response = make_response("ok")
    name = names.get("unknown", request.args["name"])
    response.set_cookie(name, "light")  # Unsafe: the fallback is request data outside the constant mapping
    response.headers["Set-Cookie"] = dump_cookie(
        "__Host-session", request.args["sid"], secure=True, path="/"
    )  # Unsafe: serialization does not change the attacker-chosen session ID
    return response

After

Use a fixed non-session cookie:

python
from fastapi import HTTPException, Request
from starlette.responses import Response


async def save_theme(request: Request):
    theme = request.query_params.get("theme", "light")
    if theme not in {"light", "dark"}:
        raise HTTPException(status_code=400)

    response = Response("saved")
    response.set_cookie(
        key="theme",
        value=theme,
        secure=True,
        samesite="lax",
    )
    return response

The cookie API serializes the value under the fixed theme name. Validation here enforces business rules; it is not a way to sanitize a raw Set-Cookie header.

Select a constant name and serialize an ordinary value:

python
from flask import make_response, request
from werkzeug.http import dump_cookie


def save_preference():
    names = {"theme": "theme", "locale": "locale"}
    name = names.get(request.args["kind"], "theme")
    response = make_response("ok")
    response.set_cookie(name, "light")
    response.headers["Set-Cookie"] = dump_cookie("locale", request.args["locale"])
    return response

The fallback is also a constant cookie name, so selection remains bounded. locale is a non-session name, and serialization handles cookie syntax. Validate the business meaning of the value separately.

References