devaanand Claude Opus 5 (1M context) commited on
Commit
a7f2409
·
1 Parent(s): 88b1ea9

fix(anon): owner key overflowed owner_user_id, 500ing anonymous writes

Browse files

Live 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 CHANGED
@@ -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
backend/app/auth.py CHANGED
@@ -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
- sid = secrets.token_hex(16)
 
 
 
 
357
  set_anon_session_cookie(response, sid)
358
- return ANON_PREFIX + sid
 
 
 
 
 
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
backend/tests/test_construct_ownership.py CHANGED
@@ -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"
docs/specs/0009-anonymous-session-scope.md CHANGED
@@ -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