feat(ui): log refused writes instead of failing silently

A CSRF rejection returned 403 {"error":"bad origin"} and wrote nothing anywhere.
uvicorn's access log is off for both listeners, so a refused write left no trace
on the server at all — the Host/Origin mismatch that made the deployed UI
read-only was only visible by opening browser devtools, and looked like a bug in
the UI rather than a header spelling difference.

The rejection now logs what was actually compared: path, method, whether the
header was missing or merely disallowed, the candidate origin, Host,
X-Forwarded-Host, and the configured allow-list. All of those are non-secret,
and none of them exist without them. The allow-list is now built in one place
that both the check and the log line use, so they cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
This commit is contained in:
MarkusandClaude Opus 5 committed 2026-09-13 16:25:09 +00:00
1 parent 647f8ceb9b
commit 10acfe76e6
2 files changed
+69 -3

No files matched your search

+28 -3
View File
@@ -142,6 +142,26 @@ def build_ui_app(svc: MemoryService) -> FastAPI: # noqa: C901 - one place, many
# itself degrade to "no Origin, so allow".
candidate = request.headers.get("origin") or _origin_from_referer(request)
if candidate is None or not _origin_allowed(candidate, settings, request):
# A silent 403 is only diagnosable from the browser: uvicorn's
# access log is off, so a refused write left no trace at all on
# the server. Log what was compared — origin, host and the
# configured allow-list are all non-secret, and without them a
# proxy that spells the authority differently looks like a bug
# in the UI rather than a header mismatch.
log.warning(
"CSRF check refused a write",
extra={
"fields": {
"path": path,
"method": request.method,
"reason": "no Origin or Referer" if candidate is None else "origin not allowed",
"origin": candidate,
"host": request.headers.get("host"),
"x_forwarded_host": request.headers.get("x-forwarded-host"),
"allowed": sorted(_allowed_origins(settings)),
}
},
)
return JSONResponse({"error": "bad origin"}, status_code=403)
if not is_open:
@@ -639,14 +659,19 @@ def _origin_allowed(origin: str, settings: Any, request: Request) -> bool:
return False
if not _same_authority(_request_host(request), parsed_origin):
return False
allowed = set(settings.ui_allowed_origins)
if settings.ui_base_url:
allowed.add(settings.ui_base_url)
allowed = _allowed_origins(settings)
if not allowed: # not configured: fall back to same-host
return True
return origin.rstrip("/") in {a.rstrip("/") for a in allowed}
def _allowed_origins(settings: Any) -> set[str]:
allowed = set(settings.ui_allowed_origins)
if settings.ui_base_url:
allowed.add(settings.ui_base_url)
return allowed
def _request_host(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-host", "").split(",", 1)[0].strip()
return forwarded or request.headers.get("host", "")
+41
View File
@@ -452,3 +452,44 @@ async def test_a_foreign_host_is_still_refused_with_an_allowed_origin(app_and_sv
headers={"origin": "https://memory.cloud.traunseenet.com", "host": host},
)
assert r.status_code == 403, f"host {host!r} was accepted"
async def test_a_refused_write_is_logged_with_what_was_compared(app_and_svc, caplog):
"""uvicorn's access log is off, so a silent 403 was only visible in the
browser — a proxy spelling the authority differently looked like a UI bug."""
import logging as _logging
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
with caplog.at_level(_logging.WARNING, logger="mdmem.ui"):
async with _client(app, cookie) as c:
r = await c.post(
"/ui/n/whatever/state",
data={"state": "archive"},
headers={"origin": "https://evil.example", "host": "evil.example"},
)
assert r.status_code == 403
record = next(r for r in caplog.records if "CSRF" in r.message)
fields = record.fields
assert fields["origin"] == "https://evil.example"
assert fields["host"] == "evil.example"
assert fields["reason"] == "origin not allowed"
assert "https://memory.cloud.traunseenet.com" in fields["allowed"]
assert fields["path"] == "/ui/n/whatever/state"
async def test_a_write_with_no_origin_header_says_so_in_the_log(app_and_svc, caplog):
import logging as _logging
app, svc = app_and_svc
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="https://memory.cloud.traunseenet.com",
cookies={SESSION_COOKIE: cookie},
) as c:
with caplog.at_level(_logging.WARNING, logger="mdmem.ui"):
r = await c.post("/ui/n/whatever/state", data={"state": "archive"})
assert r.status_code == 403
record = next(r for r in caplog.records if "CSRF" in r.message)
assert record.fields["reason"] == "no Origin or Referer"