Spaces:
Running
fix(anon): owner key overflowed owner_user_id, 500ing anonymous writes
Browse filesLive outage on both deployments since spec 0009 shipped on 2026-09-04:
POST /api/projects and POST /api/constructs returned 500 for anyone not
signed in, which is the entire anonymous try-it path.
ANON_PREFIX + secrets.token_hex(16) is 37 characters and both
owner_user_id columns are String(32). SQLite ignores VARCHAR limits so
all 162 tests passed locally; PostgreSQL enforces them, so every
anonymous insert was rejected. Signed-in users were unaffected because a
user id is exactly 32 characters, which is why nobody reported it - lab
members are signed in.
Shortened the session id to 13 bytes (104 bits of entropy, key length
31) rather than widening the column. A type change is not additive, so
auto_migrate_sqlite would not apply it and both deployed databases would
have needed hand-run DDL; this deploys as-is. No data is lost because no
anon: row was ever successfully written.
The regression test reads the column width from the model rather than
hardcoding it, and asserts against the keys the app actually mints, so it
fails if either the prefix, the id length, or the column changes. Added a
runtime assertion too, since this class of bug is invisible in dev by
construction: the database we test on does not enforce what the database
we deploy on does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- CHANGELOG.md +6 -0
- backend/app/auth.py +13 -2
- backend/tests/test_construct_ownership.py +37 -0
- docs/specs/0009-anonymous-session-scope.md +14 -0
|
@@ -66,6 +66,12 @@ Format: [Keep a Changelog](https://keepachangelog.com/). User-visible changes on
|
|
| 66 |
texts, with the machine and date they were measured on.
|
| 67 |
|
| 68 |
### Fixed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
- Custom constructs are private to the person who created them. A construct
|
| 70 |
anyone typed on the public site was listed to every other visitor, including
|
| 71 |
under a heading that read "My custom constructs", because constructs had no
|
|
|
|
| 66 |
texts, with the machine and date they were measured on.
|
| 67 |
|
| 68 |
### Fixed
|
| 69 |
+
- Creating a project or a custom construct without signing in works again. Since
|
| 70 |
+
2026-09-04 both deployments returned a server error for anonymous visitors:
|
| 71 |
+
the new anonymous session owner key was 37 characters and the database column
|
| 72 |
+
holds 32. Signed-in users were unaffected, which is why it went unnoticed.
|
| 73 |
+
Local SQLite installs never hit it either, because SQLite ignores column
|
| 74 |
+
length limits and PostgreSQL enforces them.
|
| 75 |
- Custom constructs are private to the person who created them. A construct
|
| 76 |
anyone typed on the public site was listed to every other visitor, including
|
| 77 |
under a heading that read "My custom constructs", because constructs had no
|
|
@@ -42,6 +42,9 @@ CONSTRUCTS_COOKIE_NAME = "ccr_constructs"
|
|
| 42 |
# bare "" predate the anonymous session id and belong to nobody reachable; the
|
| 43 |
# TTL sweep removes them.
|
| 44 |
ANON_PREFIX = "anon:"
|
|
|
|
|
|
|
|
|
|
| 45 |
_SECRET = (os.environ.get("CCR_SESSION_SECRET") or secrets.token_hex(32)).encode()
|
| 46 |
|
| 47 |
# Headroom for 200 rows of long documents (200 x 8 KB transcripts ~ 1.6 MB sat
|
|
@@ -353,6 +356,14 @@ def ensure_anon_owner(request: Request, response) -> str:
|
|
| 353 |
minting the session cookie on first write."""
|
| 354 |
sid = anon_session_id(request)
|
| 355 |
if not sid:
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
set_anon_session_cookie(response, sid)
|
| 358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# bare "" predate the anonymous session id and belong to nobody reachable; the
|
| 43 |
# TTL sweep removes them.
|
| 44 |
ANON_PREFIX = "anon:"
|
| 45 |
+
# owner_user_id is String(32) on both Project and Construct; every owner key
|
| 46 |
+
# (a user id, or ANON_PREFIX + session id) has to fit inside it.
|
| 47 |
+
OWNER_KEY_MAX = 32
|
| 48 |
_SECRET = (os.environ.get("CCR_SESSION_SECRET") or secrets.token_hex(32)).encode()
|
| 49 |
|
| 50 |
# Headroom for 200 rows of long documents (200 x 8 KB transcripts ~ 1.6 MB sat
|
|
|
|
| 356 |
minting the session cookie on first write."""
|
| 357 |
sid = anon_session_id(request)
|
| 358 |
if not sid:
|
| 359 |
+
# 13 bytes, not 16: the owner key is ANON_PREFIX + sid and it has to fit
|
| 360 |
+
# Project.owner_user_id / Construct.owner_user_id, which are String(32).
|
| 361 |
+
# SQLite ignores VARCHAR limits, Postgres does not - a 37-character key
|
| 362 |
+
# silently passed every local test and 500'd on both deployments.
|
| 363 |
+
sid = secrets.token_hex(13)
|
| 364 |
set_anon_session_cookie(response, sid)
|
| 365 |
+
owner = ANON_PREFIX + sid
|
| 366 |
+
# Belt and braces: SQLite (dev, tests) ignores VARCHAR limits and Postgres
|
| 367 |
+
# (deployed) does not, so an overlong key is invisible until production.
|
| 368 |
+
assert len(owner) <= OWNER_KEY_MAX, f"owner key {len(owner)} > {OWNER_KEY_MAX}"
|
| 369 |
+
return owner
|
|
@@ -242,3 +242,40 @@ def test_construct_used_by_a_run_is_hidden_not_deleted(client):
|
|
| 242 |
# the run and its reproducibility record still resolve
|
| 243 |
assert client.get(f"/api/jobs/{job_id}").status_code == 200
|
| 244 |
assert client.get(f"/api/jobs/{job_id}/metadata").status_code == 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
# the run and its reproducibility record still resolve
|
| 243 |
assert client.get(f"/api/jobs/{job_id}").status_code == 200
|
| 244 |
assert client.get(f"/api/jobs/{job_id}/metadata").status_code == 200
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def test_owner_keys_fit_the_owner_column(client):
|
| 248 |
+
"""Regression: an anonymous owner key overflowed owner_user_id.
|
| 249 |
+
|
| 250 |
+
ANON_PREFIX + a 16-byte session id is 37 characters and the column is
|
| 251 |
+
String(32). SQLite ignores VARCHAR limits so the whole suite passed, while
|
| 252 |
+
Postgres rejected every anonymous write and both deployments returned 500
|
| 253 |
+
on project and construct creation for three days.
|
| 254 |
+
|
| 255 |
+
Read the width from the model so this cannot drift, and check the real key
|
| 256 |
+
the app mints rather than a hand-written string.
|
| 257 |
+
"""
|
| 258 |
+
from app import auth
|
| 259 |
+
from app.models import Construct, Project
|
| 260 |
+
|
| 261 |
+
for model in (Project, Construct):
|
| 262 |
+
width = model.__table__.c.owner_user_id.type.length
|
| 263 |
+
assert width == auth.OWNER_KEY_MAX, (
|
| 264 |
+
f"{model.__name__}.owner_user_id is String({width}) but auth assumes "
|
| 265 |
+
f"{auth.OWNER_KEY_MAX}"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
# the key an anonymous visitor actually gets, via a real request
|
| 269 |
+
client.post("/api/auth/logout")
|
| 270 |
+
client.post("/api/projects", json={"name": "width check", "description": ""})
|
| 271 |
+
from app.db import SessionLocal
|
| 272 |
+
|
| 273 |
+
db = SessionLocal()
|
| 274 |
+
try:
|
| 275 |
+
keys = {p.owner_user_id for p in db.query(Project).all()}
|
| 276 |
+
keys |= {c.owner_user_id for c in db.query(Construct).all()}
|
| 277 |
+
finally:
|
| 278 |
+
db.close()
|
| 279 |
+
assert any(k.startswith(auth.ANON_PREFIX) for k in keys), "expected an anonymous owner"
|
| 280 |
+
for key in keys:
|
| 281 |
+
assert len(key) <= auth.OWNER_KEY_MAX, f"owner key {key!r} is {len(key)} chars"
|
|
@@ -82,6 +82,20 @@ The cap is a signed cookie, so like the run counter it stops casual
|
|
| 82 |
over-creation rather than a determined script. The TTL sweep is what actually
|
| 83 |
bounds storage.
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
## Deviations (filled after implementation)
|
| 86 |
|
| 87 |
Two existing tests changed because adoption is new behaviour, not because they
|
|
|
|
| 82 |
over-creation rather than a determined script. The TTL sweep is what actually
|
| 83 |
bounds storage.
|
| 84 |
|
| 85 |
+
## Post-release defect (2026-09-07)
|
| 86 |
+
|
| 87 |
+
The owner key ANON_PREFIX + a 16-byte session id is 37 characters, and both
|
| 88 |
+
owner_user_id columns are String(32). SQLite ignores VARCHAR limits so the full
|
| 89 |
+
suite passed; PostgreSQL enforces them, so every anonymous project and construct
|
| 90 |
+
creation returned 500 on both deployments for three days. Signed-in users were
|
| 91 |
+
unaffected (a user id is exactly 32 characters), which hid it.
|
| 92 |
+
|
| 93 |
+
Fixed by shortening the session id to 13 bytes (104 bits, key length 31) rather
|
| 94 |
+
than widening the column: a type change is not additive, so auto_migrate_sqlite
|
| 95 |
+
would not apply it and the deployed databases would need hand-run DDL. Guarded
|
| 96 |
+
by a runtime assertion in ensure_anon_owner and a test that reads the column
|
| 97 |
+
width from the model and checks the keys the app actually mints.
|
| 98 |
+
|
| 99 |
## Deviations (filled after implementation)
|
| 100 |
|
| 101 |
Two existing tests changed because adoption is new behaviour, not because they
|