#5816 "Restoring mode": while a backup goes back in, the scheduler holds, ingest and HTMX answer 503 with Retry-After (agents keep their samples), and every page says what is happening. A safety backup of Steward as it stands is made first, to /data/backups and the bucket. The backup's key is put in place, the restore audited, and Steward exits with code 3 so Swarm, compose or systemd starts it fresh. A failed in-place restore changes nothing and says why on Backups; a full replace that fails after the wipe restarts into first-run setup. #5817 "Settings → Backups: Restore": a Restore button on each sent backup and on the file card. The chosen backup is checked (the same comparison as Check, now a shared partial) and held server-side for an hour under a token; it starts only once the admin types "restore", and an older backup also needs "Replace everything" ticked. A newer backup, or one whose key can't open its secrets, gets no button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
2.8 KiB
Python
75 lines
2.8 KiB
Python
"""Forged-request (CSRF) protection for the browser side of Steward.
|
|
|
|
A page on another site can make a logged-in browser POST to Steward, and the
|
|
browser sends Steward's session cookie with it. Every state-changing request
|
|
from a browser therefore has to carry a token that only Steward's own pages
|
|
know: a random value kept in the session, written into each form by
|
|
`csrf_field()` and sent by HTMX and fetch() as the `X-CSRF-Token` header (set
|
|
once on <body> in base.html).
|
|
|
|
One check in a before_request covers every route, so a new route cannot
|
|
forget it. Callers that authenticate with a bearer token instead of the
|
|
session cookie (the host agent's ingest; /mcp never reaches Quart) are exempt:
|
|
a forged browser request cannot carry their token.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import secrets
|
|
|
|
from markupsafe import Markup, escape
|
|
from quart import Response, request, session
|
|
|
|
SESSION_KEY = "_csrf"
|
|
FORM_FIELD = "csrf_token"
|
|
HEADER = "X-CSRF-Token"
|
|
UNSAFE_METHODS = ("POST", "PUT", "PATCH", "DELETE")
|
|
# Bearer-authenticated endpoints: no session cookie, nothing to forge.
|
|
EXEMPT_PATHS = frozenset({"/plugins/host_agent/ingest"})
|
|
|
|
|
|
def csrf_token() -> str:
|
|
"""This session's token, created on first use."""
|
|
token = session.get(SESSION_KEY)
|
|
if not token:
|
|
token = session[SESSION_KEY] = secrets.token_urlsafe(32)
|
|
return token
|
|
|
|
|
|
def csrf_field() -> Markup:
|
|
"""The hidden input every POST form carries."""
|
|
return Markup(f'<input type="hidden" name="{FORM_FIELD}" value="{escape(csrf_token())}">')
|
|
|
|
|
|
def rotate() -> None:
|
|
"""Drop the token, so a new one is issued — at login, where the session
|
|
changes hands."""
|
|
session.pop(SESSION_KEY, None)
|
|
|
|
|
|
# Forms that send a file past the app's default body limit (16 MiB). This
|
|
# check reads the form first, so the limit is raised here, before it does:
|
|
# a backup file (settings only is small; an older one carries the audit log).
|
|
UPLOAD_LIMITS: dict[str, int] = {
|
|
"/settings/backups/check/file/": 512 * 1024 * 1024,
|
|
"/settings/backups/restore/file/": 512 * 1024 * 1024,
|
|
}
|
|
|
|
|
|
async def check_request() -> Response | None:
|
|
"""before_request hook: refuse an unsafe request without the session's token."""
|
|
if request.method not in UNSAFE_METHODS or request.path in EXEMPT_PATHS:
|
|
return None
|
|
if request.path in UPLOAD_LIMITS:
|
|
request.max_content_length = UPLOAD_LIMITS[request.path]
|
|
expected = session.get(SESSION_KEY)
|
|
sent = request.headers.get(HEADER, "")
|
|
if not sent:
|
|
form = await request.form
|
|
sent = form.get(FORM_FIELD, "")
|
|
if expected and sent and hmac.compare_digest(expected, sent):
|
|
return None
|
|
return Response(
|
|
"This form has expired or did not come from Steward. Reload the page and try again.",
|
|
status=400, content_type="text/plain; charset=utf-8")
|