Files
UmbraTrans/umbratrans_web/app.py
umbra 0f5c1e3408 Run the agent as the service account it claims to be
The compute proof of concept had never been run with the agent as
umbra-poc. Running it that way found three defects, all the same
shape: a check that passed because its mechanism did nothing.

The demonstration never reached the agent at all. It held the pid it
spawned, which under `sudo -n -u` is the sudo wrapper - sudo forks the
agent before exec'ing. Signalling that killed the wrapper and left the
worker running, renewing its lease and holding the job indefinitely,
under a printed note saying "exactly like a power cut". An
unprivileged process cannot signal another account's process at all,
and kill(2) on a process group succeeds as soon as one member is
signalled, so the wrapper's death read as the agent's. Every run
leaked an agent.

Lease recovery was a diagnosis rather than a mechanism.
sweep_expired_leases documented itself as "called by the controller's
own timer" and there was no timer; a killed node's job came back only
when somebody noticed a stalled job and posted to an administrative
route by hand. The proof of concept swept by hand and recorded the
result as automatic. app.py now runs the loop for the life of the
application, building the compute repository at startup so a restart
mid-job still recovers it.

The power cut is now the agent's own: os._exit(137) the instant it
claims a job, before running it, because a process that does not own
the agent cannot signal it. A clean stop goes through a stop_file the
agent polls, for the same reason. Liveness is measured by finding the
worker's own pid in /proc matched on its config path, sampled
repeatedly and accumulated - the wrapper and the worker are not
simultaneous, so a single sample finds only the wrapper.

The node key travels by `sudo --preserve-env` rather than as VAR=value
in argv, because argv is world-readable through /proc/<pid>/cmdline
and the environment is not. The demonstration checks whether the key
will survive before starting and falls back to a pipe, which
node_agent.py --node-key-stdin accepts. Neither a file nor argv ever
holds it.

Eight tests, each verified to fail against a mutation of the code it
covers: three for the sweeper (it must return an expired lease, must
survive a failing round, must not invent a repository it has no
database for) and five driving a real agent process against a stub
controller (stops on the stop file, exits 137 holding an uncompleted
job, takes the key from both routes and neither, reports an empty pipe
as an empty pipe).

448 pass with a database, 421 without. The deployment database is
untouched: users=2 orgs=3 projects=0 audit=43, no compute tables.
2026-09-30 06:18:26 -07:00

670 lines
28 KiB
Python

"""FastAPI application factory, security middleware, and shared dependencies.
Design decisions worth stating explicitly:
* Handlers are synchronous ``def`` functions. FastAPI runs them in its worker
threadpool, so the CPU-bound Argon2 work does not block the event loop.
* The session token travels in an ``HttpOnly``, ``SameSite=Lax`` cookie that is
``Secure`` whenever production settings are used. The raw token is never
written to a log, a response body, or the repository.
* CSRF protection uses the double-submit pattern: a readable cookie plus an
``X-CSRF-Token`` header compared in constant time. ``POST /api/auth/login`` is
exempt because it carries credentials the attacker does not have; every other
state-changing API route requires the header.
* Error responses are a fixed ``{"error": {"code", "message"}}`` shape. FastAPI's
default validation payload echoes the submitted value, so a custom handler
strips it: a rejected password must not be reflected to the client.
* No CORS middleware is installed. The API is same-origin only; the approved
reverse proxy terminates TLS in production.
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager, suppress
from datetime import datetime, timezone
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any, cast
from uuid import uuid4
from fastapi import Depends, FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.datastructures import MutableHeaders
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from .config import PRODUCTION_REPOSITORY_KIND, Settings, load_settings
from .errors import (
AuthenticationRequiredError,
ConfigurationError,
CsrfError,
ForbiddenError,
InvalidCredentialsError,
PasswordChangeRequiredError,
PasswordPolicyError,
UmbraWebError,
)
from .models import Principal, RoleName
from .postgres_repository import PostgresAccountRepository
from .repository import AccountRepository, build_repository
from .schemas import HealthResponse
from .security import generate_token, install_secret_redaction_filter, tokens_match
from .service import AccountService
from .setup import SetupGate
LOGGER = logging.getLogger("umbratrans_web.app")
#: Unsafe methods require a CSRF token.
UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
#: The login route is exempt: it carries credentials the attacker cannot know,
#: and requiring a prior CSRF bootstrap would break the sign-in page.
CSRF_EXEMPT_PATHS = frozenset(
{
"/api/auth/login",
# No session exists yet, so there is no ambient authority for a
# cross-site request to ride on. The setup token is the authenticator.
"/api/setup/initial-administrator",
}
)
#: Prefixes exempt from the CSRF check, for a specific and narrow reason.
#:
#: CSRF defends against a *browser* being induced to send a request carrying the
#: user's cookies. It is meaningless for a caller that authenticates with a
#: credential in the ``Authorization`` header: there is no ambient authority to
#: ride on, a cross-site page cannot read the key, and the key is not attached
#: automatically the way a cookie is.
#:
#: `/api/node` is exempt on exactly that basis. It is a prefix rather than an
#: enumerated set of paths, because the node surface is small, is entirely
#: bearer-authenticated, and grows; enumerating it would mean a new node route
#: is broken until somebody remembers to add it here. Everything else stays
#: checked, and a test in ``test_compute.py`` asserts that no administrator or
#: session route has become exempt.
CSRF_EXEMPT_PREFIXES = ("/api/node",)
SESSION_REVOKED_CODES = frozenset({"authentication_required", "invalid_credentials"})
def request_id(request: Request) -> str:
"""Return the correlation id assigned to the current request."""
return cast(str, getattr(request.state, "request_id", "") or "")
def get_settings(request: Request) -> Settings:
return cast(Settings, request.app.state.settings)
def get_service(request: Request) -> AccountService:
return cast(AccountService, request.app.state.service)
def get_repository(request: Request) -> AccountRepository:
return cast(AccountRepository, request.app.state.repository)
def _cookie_value(header: str | None, name: str) -> str | None:
if not header:
return None
jar = SimpleCookie()
try:
jar.load(header)
except Exception: # noqa: BLE001 - malformed cookie header must not raise
return None
morsel = jar.get(name)
return morsel.value if morsel else None
def session_token(request: Request) -> str | None:
"""Extract the session token from the request cookie."""
settings = get_settings(request)
return _cookie_value(request.headers.get("cookie"), settings.session_cookie_name)
def read_csrf_token(request: Request) -> str:
settings = get_settings(request)
return _cookie_value(request.headers.get("cookie"), settings.csrf_cookie_name) or ""
def build_cookie(
name: str,
value: str,
*,
max_age: int | None = None,
http_only: bool = False,
secure: bool = False,
same_site: str = "lax",
path: str = "/",
) -> str:
"""Serialize one ``Set-Cookie`` header value."""
jar: SimpleCookie[str] = SimpleCookie()
jar[name] = value
morsel = jar[name]
morsel["path"] = path
if max_age is not None:
morsel["max-age"] = str(max_age)
morsel["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
if http_only:
morsel["httponly"] = True
if secure:
morsel["secure"] = True
morsel["samesite"] = same_site.capitalize()
return jar.output(header="").strip()
def set_session_cookie(response: Response, *, token: str, settings: Settings) -> None:
response.set_cookie(
key=settings.session_cookie_name,
value=token,
max_age=settings.session_absolute_seconds,
httponly=True,
secure=settings.cookie_secure,
samesite=settings.cookie_samesite,
path=settings.cookie_path,
)
def clear_session_cookie(response: Response, *, settings: Settings) -> None:
response.delete_cookie(
key=settings.session_cookie_name,
path=settings.cookie_path,
secure=settings.cookie_secure,
httponly=True,
samesite=settings.cookie_samesite,
)
def set_csrf_cookie(response: Response, *, token: str, settings: Settings) -> None:
"""Issue the readable double-submit cookie. Not ``HttpOnly`` by design."""
response.set_cookie(
key=settings.csrf_cookie_name,
value=token,
max_age=settings.session_absolute_seconds,
httponly=False,
secure=settings.cookie_secure,
samesite=settings.cookie_samesite,
path=settings.cookie_path,
)
def clear_csrf_cookie(response: Response, *, settings: Settings) -> None:
response.delete_cookie(
key=settings.csrf_cookie_name,
path=settings.cookie_path,
secure=settings.cookie_secure,
httponly=False,
samesite=settings.cookie_samesite,
)
def ensure_csrf_token(response: Response, *, settings: Settings, existing: str | None = None) -> str:
"""Return the current CSRF token, issuing one if the request had none."""
token = existing or generate_token()
if not existing:
set_csrf_cookie(response, token=token, settings=settings)
return token
class ApiSecurityMiddleware:
"""Request ids, response hardening headers, and CSRF enforcement.
Implemented as raw ASGI so it also covers requests rejected before routing
and so response headers can be added without buffering the body.
"""
def __init__(self, app: ASGIApp, settings: Settings) -> None:
self._app = app
self._settings = settings
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self._app(scope, receive, send)
return
headers = MutableHeaders(scope=scope)
correlation = headers.get("x-request-id") or uuid4().hex
if len(correlation) > 128 or not correlation.isprintable():
correlation = uuid4().hex
state = scope.setdefault("state", {})
state["request_id"] = correlation
path = scope.get("path", "")
is_api = path.startswith("/api")
is_html = not is_api and scope.get("method", "GET").upper() == "GET"
method = scope.get("method", "GET").upper()
csrf_exempt = path in CSRF_EXEMPT_PATHS or path.startswith(CSRF_EXEMPT_PREFIXES)
if is_api and self._settings.require_csrf and method in UNSAFE_METHODS and not csrf_exempt:
problem = self._check_csrf(headers)
if problem is not None:
response = JSONResponse(
status_code=problem.http_status,
content={"error": {"code": problem.code, "message": problem.message}},
)
await response(scope, receive, send)
return
csrf_cookie = _cookie_value(headers.get("cookie"), self._settings.csrf_cookie_name)
should_issue_csrf = is_api and method not in UNSAFE_METHODS and not csrf_cookie
issued_csrf = generate_token() if should_issue_csrf else None
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
response_headers = MutableHeaders(scope=message)
response_headers["x-request-id"] = correlation
response_headers["x-content-type-options"] = "nosniff"
response_headers["x-frame-options"] = "DENY"
response_headers["referrer-policy"] = "no-referrer"
response_headers["cross-origin-opener-policy"] = "same-origin"
if is_api:
response_headers["cache-control"] = "no-store"
response_headers["content-security-policy"] = "default-src 'none'; frame-ancestors 'none'"
response_headers["vary"] = "Cookie"
elif is_html:
# The browser application. Same restrictive framing and
# sniffing policy as the API, plus a policy that only allows
# its own origin: no inline script, no remote origins.
response_headers["cache-control"] = "no-store"
response_headers["content-security-policy"] = (
"default-src 'self'; base-uri 'none'; form-action 'self'; "
"frame-ancestors 'none'; object-src 'none'; img-src 'self' data:; "
"style-src 'self'; script-src 'self'"
)
response_headers["vary"] = "Cookie"
if issued_csrf is not None:
response_headers.append(
"set-cookie",
build_cookie(
self._settings.csrf_cookie_name,
issued_csrf,
max_age=self._settings.session_absolute_seconds,
http_only=False,
secure=self._settings.cookie_secure,
same_site=self._settings.cookie_samesite,
path=self._settings.cookie_path,
),
)
await send(message)
await self._app(scope, receive, send_wrapper)
def _check_csrf(self, headers: MutableHeaders) -> CsrfError | None:
cookie = _cookie_value(headers.get("cookie"), self._settings.csrf_cookie_name)
header = headers.get(self._settings.csrf_header_name)
if not cookie or not header:
return CsrfError("csrf_token_missing", "This request is missing its CSRF token. Reload and retry.")
if not tokens_match(header, cookie):
return CsrfError("csrf_token_invalid")
return None
def get_principal_allowing_password_change(request: Request) -> Principal:
"""Resolve the session, tolerating a mandatory first-password change."""
token = session_token(request)
if not token:
raise AuthenticationRequiredError()
service = get_service(request)
try:
return service.resolve_session(token, request_id=request_id(request))
except InvalidCredentialsError as error:
raise AuthenticationRequiredError() from error
def require_principal(principal: Principal = Depends(get_principal_allowing_password_change)) -> Principal:
"""Require a fully usable session."""
if principal.must_change_password:
raise PasswordChangeRequiredError()
return principal
def require_admin(principal: Principal = Depends(require_principal)) -> Principal:
"""Require a platform or organization administrator session."""
if not principal.is_admin:
raise ForbiddenError()
return principal
def error_payload(error: UmbraWebError) -> tuple[int, dict[str, dict[str, Any]]]:
"""Map a domain error to a status and a fixed-shape body.
Messages are only echoed for client errors (4xx). A 5xx always returns a
generic body so an internal message can never leak configuration. A
password-policy failure additionally names the unmet *rules*; it never
echoes the candidate value.
"""
status = error.http_status
message = error.message if 400 <= status < 500 else "The request could not be completed."
body: dict[str, Any] = {"code": error.code, "message": message}
if isinstance(error, PasswordPolicyError) and error.unmet_requirements:
body["requirements"] = list(error.unmet_requirements)
return status, {"error": body}
def validation_fields(error: RequestValidationError) -> list[str]:
"""Return the field names of a validation failure, never the values.
FastAPI's default payload embeds the submitted input, which for this API
could be a password or a session token. Only the dotted field path is kept.
"""
skip = {"body", "query", "path", "header", "cookie"}
names: set[str] = set()
for item in error.errors():
parts = [str(part) for part in item.get("loc", ()) if str(part) not in skip]
names.add(".".join(parts) if parts else "payload")
return sorted(names)
def _begin_setup_if_needed(gate: SetupGate, service: AccountService) -> None:
"""Mint the first-run setup token if the deployment has no administrator.
Called once at startup. A failure to reach the store here is logged and
swallowed rather than allowed to abort startup: the same question is asked
again on every setup request, so a store that was briefly unavailable at boot
costs a restart later rather than a process that will not start.
"""
try:
gate.begin_if_needed(platform_administrators=service.repository.count_users_with_role(RoleName.PLATFORM_ADMIN))
except Exception: # noqa: BLE001 - setup is retried per request
LOGGER.warning("could not determine whether first-run setup is required", exc_info=True)
#: How often expired leases are swept, in seconds. Shorter than the shortest
#: lease any node may be configured with, so a job is never recovered later than
#: one lease period after its holder stopped renewing.
LEASE_SWEEP_SECONDS = 10.0
async def _sweep_expired_leases(app: FastAPI) -> None:
"""Return jobs whose holder stopped renewing, without being asked.
The lease is the whole recovery mechanism: a worker that is ``kill -9``'d
never releases anything, so the expiry *is* the signal. And a signal nobody
listens to is not a signal.
This used to exist only as an administrator endpoint, on the understanding
that a controller timer would call it. There was no such timer, so the
recovery path depended on a human noticing a stalled job and posting to an
administrative route by hand. That is not a mechanism; it is a diagnosis. The
proof of concept masked it, because the demonstration swept by hand itself.
Errors are logged and the loop continues. A sweeper that dies on one bad lease
is a queue that silently stops recovering, which is the exact failure it
exists to prevent, and the failure would be invisible.
"""
import asyncio
log = logging.getLogger("umbratrans_web.compute")
while True:
try:
await asyncio.sleep(LEASE_SWEEP_SECONDS)
repository = getattr(app.state, "compute_repository", None)
recovered = repository.sweep_expired_leases() if repository is not None else []
if recovered:
log.warning(
"returned %d job(s) whose lease expired: %s",
len(recovered),
", ".join(str(job_id) for job_id in recovered),
)
except asyncio.CancelledError:
raise
except Exception:
log.exception("lease sweep failed; will try again in %.0fs", LEASE_SWEEP_SECONDS)
@asynccontextmanager
async def _lifespan(app: FastAPI):
"""Run the lease sweeper for as long as the application serves.
The compute repository is built here rather than on first use, because the
sweeper needs one before any request arrives. A controller restarted while a
job was running must recover that job: if the repository were built lazily,
nothing would touch the compute surface, nothing would build it, and the
sweeper would have nothing to sweep.
A deployment with no database configured gets no repository and no sweeper,
which is not a degraded compute tier but the absence of one.
"""
if getattr(app.state.settings, "database_dsn", None):
from .compute import build_compute_repository
try:
app.state.compute_repository = build_compute_repository(
app.state.settings, getattr(app.state, "password_service", None)
)
except Exception:
# Refusing to serve would turn a compute problem into an outage of
# the account and project surfaces, which do not need it. The sweeper
# skips its round and the compute routes report the real error.
logging.getLogger("umbratrans_web.compute").exception(
"compute repository unavailable at startup; lease recovery is off until "
"a compute request builds one"
)
task = asyncio.create_task(_sweep_expired_leases(app))
app.state.lease_sweeper = task
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
def create_app(
*,
settings: Settings | None = None,
repository: AccountRepository | None = None,
service: AccountService | None = None,
) -> FastAPI:
"""Build the phase-1 application.
The repository is built from settings when one is not injected, which is how
a deployment runs: the configuration gate has already refused a production
environment without a PostgreSQL DSN, and the repository boundary refuses
anything but the PostgreSQL adapter. The check below is a second, independent
statement of the same rule at the point where it would otherwise be
unstated.
"""
resolved_settings = settings or load_settings()
# Checked against the configured kind *before* anything is opened, so refusing
# never involves a connection attempt. The earlier version of this check
# demanded the repository be injected and refused to start otherwise, on the
# grounds that the PostgreSQL adapter did not exist yet. It does now, so what
# is being asserted is which adapter is in use, not where it came from.
if resolved_settings.is_production and resolved_settings.repository_kind != PRODUCTION_REPOSITORY_KIND:
raise ConfigurationError(
"production_repository_missing",
"Production requires the PostgreSQL repository adapter.",
)
resolved_repository = build_repository(resolved_settings, repository=repository)
# Repeated after the build, because an injected repository bypasses the
# configured kind. This is the last point before the process serves anything.
if resolved_settings.is_production and not isinstance(resolved_repository, PostgresAccountRepository):
raise ConfigurationError(
"production_repository_missing",
"Production requires the PostgreSQL repository adapter.",
)
resolved_service = service or AccountService(resolved_repository, resolved_settings)
# The same transport the service will use, so the health check probes the
# configuration that is actually in force rather than a second, separately
# constructed one that could disagree.
resolved_service.mailer # fail fast if the transport cannot be built
logging.getLogger("umbratrans_web").setLevel(logging.INFO)
install_secret_redaction_filter()
app = FastAPI(
title="UmbraTrans account management",
version=resolved_settings.version,
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=_lifespan,
)
app.state.settings = resolved_settings
app.state.repository = resolved_repository
app.state.service = resolved_service
# First-run setup. The token is minted here, at boot, and only when no
# platform administrator exists; the gate holds it and the route layer never
# reads it back out. A configured deployment mints nothing and holds nothing.
setup_gate = SetupGate()
app.state.setup_gate = setup_gate
# The compute repository hashes node credentials with the *same* argon2id
# profile as user passwords, so the profile is published rather than letting a
# second code path default its own. Two profiles for two credential kinds is
# two things to review, and one of them will be wrong.
app.state.password_service = resolved_service.password_service
_begin_setup_if_needed(setup_gate, resolved_service)
if resolved_settings.allowed_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(resolved_settings.allowed_hosts))
app.add_middleware(ApiSecurityMiddleware, settings=resolved_settings)
@app.exception_handler(UmbraWebError)
async def _handle_domain_error(_request: Request, error: UmbraWebError) -> JSONResponse:
status, body = error_payload(error)
headers: dict[str, str] = {}
if isinstance(error, AuthenticationRequiredError) or error.code in SESSION_REVOKED_CODES:
headers["x-umbratrans-reauthenticate"] = "1"
return JSONResponse(status_code=status, content=body, headers=headers)
@app.exception_handler(RequestValidationError)
async def _handle_validation_error(_request: Request, error: RequestValidationError) -> JSONResponse:
# FastAPI's default payload echoes the submitted input. Strip it: a
# rejected password must never be reflected back to the client.
return JSONResponse(
status_code=422,
content={
"error": {
"code": "invalid_request",
"message": "The request payload is invalid.",
"fields": validation_fields(error),
}
},
)
from .routes_auth import router as auth_router
from .routes_compute import admin_router as compute_router
from .routes_compute import agent_router as node_router
from .routes_setup import router as setup_router
from .routes_uadmin import router as uadmin_router
from .routes_workspace import router as workspace_router
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(uadmin_router, prefix="/api/uadmin", tags=["uadmin"])
# Mounted at /api rather than /api/auth: this is the member's own view, not
# something they authenticate *to*, and it needs no administrative role.
app.include_router(workspace_router, prefix="/api/workspace", tags=["workspace"])
# Two mounts of one router, because the two surfaces have genuinely different
# authentication. /api/compute/* is the administrator's inventory and queue
# view and needs a session with an administrative role, like every other
# uadmin route. /api/node/* is what a node agent may do, and it authenticates
# with a node credential that can claim work and report a result and nothing
# else. Mounting them together would mean one of the two checks is decorative.
app.include_router(compute_router, prefix="/api/compute", tags=["compute"])
app.include_router(node_router, prefix="/api/node", tags=["node"])
@app.get("/api/health", tags=["health"])
def health() -> HealthResponse:
"""Unauthenticated liveness probe. Contains no secret or database detail."""
fingerprint = resolved_settings.public_fingerprint()
return HealthResponse(
status="ok",
service=resolved_settings.service_name,
version=resolved_settings.version,
environment=str(fingerprint["environment"]),
repository=str(fingerprint["repository"]),
cookie_secure=bool(fingerprint["cookie_secure"]),
csrf_required=bool(fingerprint["csrf_required"]),
# Configured *and* verified. A host, a port, and a password can all be
# present and still be refused by the server, and reporting that as
# working would let the control panel create accounts whose
# confirmation links are never delivered.
mail_deliverable=resolved_service.mail_is_deliverable(),
mail_delivery_waived=bool(fingerprint["mail_delivery_waived"]),
setup_required=resolved_service.setup_is_required(),
time=datetime.now(timezone.utc),
)
LOGGER.info(
"application created environment=%s repository=%s csrf_required=%s cookie_secure=%s",
resolved_settings.environment,
resolved_settings.repository_kind,
resolved_settings.require_csrf,
resolved_settings.cookie_secure,
)
if resolved_settings.static_root is not None:
_mount_frontend(app, resolved_settings.static_root)
LOGGER.info("frontend mounted from %s", resolved_settings.static_root)
return app
def _mount_frontend(app: FastAPI, static_root: Path) -> None:
"""Serve the browser application from the same origin as the API.
Same-origin is not a convenience here. The session cookie is ``HttpOnly``
and the CSRF token travels as a header, and the application deliberately
installs no CORS middleware, so a separately hosted frontend could not call
the API at all. Mounting after the routers are registered keeps every
``/api`` route ahead of the static files.
A missing or non-directory root is refused rather than silently serving an
API-only application, because that would look like a broken deployment.
"""
root = static_root.expanduser()
if not root.is_dir():
raise ConfigurationError("static_root_missing", f"Static root is not a directory: {root}")
app.mount("/", StaticFiles(directory=str(root), html=True), name="frontend")
__all__ = [
"ApiSecurityMiddleware",
"CSRF_EXEMPT_PATHS",
"UNSAFE_METHODS",
"build_cookie",
"clear_csrf_cookie",
"clear_session_cookie",
"create_app",
"ensure_csrf_token",
"error_payload",
"get_principal_allowing_password_change",
"get_repository",
"get_service",
"get_settings",
"read_csrf_token",
"request_id",
"require_admin",
"require_principal",
"session_token",
"set_csrf_cookie",
"set_session_cookie",
"validation_fields",
]