Found during a security review of the SDK. Filing publicly since there's no SECURITY.md / private advisory channel enabled — happy to move this private if you turn on GitHub private vulnerability reporting.
Summary
The two route families disagree on what "no access token" means for the same document:
server/agent-routes.ts fails closed: no token → role null → denied.
server/routes.ts getAccessRole fails open: no token → role editor ("tokenless shared docs default to editable access (slug is the secret)").
The live POST /documents/:slug/ops handler uses the fail-open path, so an unauthenticated caller who only knows a slug can mutate document content — while a tokenless read of the same doc via GET /documents/:slug/state is correctly denied. "Can't read but can write" is almost certainly not intended.
Reproduction (no token anywhere)
SLUG=$(curl -s -XPOST localhost:4000/documents -H 'content-type: application/json' \
-d '{"markdown":"# Confidential\n\nThe original protected sentence.","title":"v"}' | jq -r .slug)
curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/documents/$SLUG/state # 401 (read denied)
curl -s -o /dev/null -w '%{http_code}\n' -XPOST localhost:4000/documents/$SLUG/ops \
-H 'content-type: application/json' \
-d '{"type":"suggestion.add","by":"ai:x","kind":"replace","quote":"The original protected sentence.","content":"ATTACKER TEXT."}' # 200
# ...then suggestion.accept (200) rewrites the body. curl -H 'Accept: text/markdown' localhost:4000/d/$SLUG shows "ATTACKER TEXT."
GET /d/:slug (content negotiation) also returns full content with no token, so tokenless read is possible there too — another inconsistency with /state.
Why it matters even with publicDefaultEditorRole
I see the publicDefaultEditorRole capability flag, so the open-by-default posture is partly intentional. But:
- Slugs are ~41 bits (
server/slug.ts, 8 × [a-z0-9]) and leak in URLs, Referer headers, proxy/access logs, and link-preview unfurls — and unlike a token, a slug can't be rotated or revoked. "Possession of the URL = permanent edit rights" is a much larger grant than sharing a link usually implies.
- The
/state (401) vs /ops (200) split means at least one of the two route families is wrong regardless of the intended default.
Suggested direction
- Unify tokenless handling to fail closed (as
agent-routes.ts already does), or gate the "slug is the secret" write behavior behind an explicit opt-in env flag rather than defaulting on.
- Make
GET /d/:slug reads honor the same policy as GET /state.
- If the open default is kept intentionally, consider widening/de-biasing the slug (it becomes the de-facto write credential).
Relevant code: server/routes.ts (getAccessRole ~695, resolveOpenContextAccess, /ops ~1580) vs server/agent-routes.ts (~3170).
Found during a security review of the SDK. Filing publicly since there's no
SECURITY.md/ private advisory channel enabled — happy to move this private if you turn on GitHub private vulnerability reporting.Summary
The two route families disagree on what "no access token" means for the same document:
server/agent-routes.tsfails closed: no token → rolenull→ denied.server/routes.tsgetAccessRolefails open: no token → roleeditor("tokenless shared docs default to editable access (slug is the secret)").The live
POST /documents/:slug/opshandler uses the fail-open path, so an unauthenticated caller who only knows a slug can mutate document content — while a tokenless read of the same doc viaGET /documents/:slug/stateis correctly denied. "Can't read but can write" is almost certainly not intended.Reproduction (no token anywhere)
GET /d/:slug(content negotiation) also returns full content with no token, so tokenless read is possible there too — another inconsistency with/state.Why it matters even with
publicDefaultEditorRoleI see the
publicDefaultEditorRolecapability flag, so the open-by-default posture is partly intentional. But:server/slug.ts, 8 ×[a-z0-9]) and leak in URLs,Refererheaders, proxy/access logs, and link-preview unfurls — and unlike a token, a slug can't be rotated or revoked. "Possession of the URL = permanent edit rights" is a much larger grant than sharing a link usually implies./state(401) vs/ops(200) split means at least one of the two route families is wrong regardless of the intended default.Suggested direction
agent-routes.tsalready does), or gate the "slug is the secret" write behavior behind an explicit opt-in env flag rather than defaulting on.GET /d/:slugreads honor the same policy asGET /state.Relevant code:
server/routes.ts(getAccessRole~695,resolveOpenContextAccess,/ops~1580) vsserver/agent-routes.ts(~3170).