Skip to content

Auth inconsistency: tokenless requests fail *open* to editor on /ops but fail *closed* on /state #69

Description

@adamsjack711-ux

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions