Skip to content

fix(api): guard request-derived KV/R2/DO keys against platform length limits - #399

Merged
demtario merged 1 commit into
masterfrom
fix/api-versions-exists-kv-key-guard
Sep 30, 2026
Merged

demtario merged 1 commit into
masterfrom
fix/api-versions-exists-kv-key-guard

Conversation

@demtario

@demtario demtario commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Context

GET /api/versions/exists?v=<about 600 chars> read env.CACHE.get("version-exists:" + v) before its own try block. Workers KV rejects keys over 512 bytes (KV GET failed: 414 UTF-8 encoded length of 615 exceeds key length limit of 512.), so the call threw into the fetch catch-all and an anonymous caller got a 500 that was also captured in Sentry, meaning anyone can generate Sentry noise and 5xx metrics. /api/payload/:id already shape-checks its id for the same reason; this PR follows that precedent. This retires the criterion-11 probe trigger used on 2026-09-30 (DEV-3093): that probe no longer produces a 500 or a Sentry event.

What changed:

  • /api/versions/exists: v must match ^[0-9A-Za-z][0-9A-Za-z._+-]{0,63}$ (semver / dist-tag shape, ASCII), otherwise 400 {"error":"v is not a valid version"} before any KV access and with no Sentry capture. The 4xx contract is documented in the route's code comment only, because no doc under runner/docs lists this route's statuses. The client (checkVersionExists) already treats any non-ok response as "exists" (fails open), so a 400 changes nothing for it.
  • getDemo (demo:<id> cache key): an id whose key is over 512 bytes or empty is a miss (null), so /d/:id, /embed/:id, GET /api/demos/:id, /api/demos/:id/source, /api/demos/:id/access, PATCH/DELETE /api/demos/:id and the MCP demo routes answer their normal 404 instead of throwing.
  • serveDemoAsset: candidate R2 keys (r2_prefix + URL subpath) over 1024 bytes are skipped, so /d/<real id>/<very long path> falls through to the SPA index exactly like any other unknown path instead of R2 throwing.
  • Tier-2 session ids (session-tombstone:<id> and the meter key in KV, and the Durable Object name): over 128 bytes is a 400 {"error":"invalid session id"} on POST /api/session (client-supplied sessionId), DELETE /api/session/:id and every /api/session/:id/* subroute. This one is worse than a 500: the tombstone and meter reads swallow the KV error (.catch(() => null) / .catch(() => "1")), so an overlong invented id was treated as a live metered session and waved through to a sandbox call for an attacker-chosen DO name.
  • o11y symbolicate.ts: a browser-supplied frame path that would make the sourcemap R2 key exceed 1024 bytes is not looked up (it was a counted fetch_error skip, not a crash).
  • New import-free helper workers/api/src/storage-key.ts measures UTF-8 bytes, not string length.

Every place a KV key, R2 key or DO name is built from request-derived input (workers/api/src and workers/o11y/src):

Site Input Reachable by Status
version-exists:<v> KV (index.ts) ?v anonymous fixed (the reported bug)
demo:<id> KV in getDemo (share.ts) path id on /d, /embed, /api/demos/:id[/source] anonymous fixed
demo:<id> KV, /api/demos/:id/access, PATCH/DELETE /api/demos/:id, /api/mcp/demos/:id[/status] path id authenticated / service token covered by the same getDemo guard
R2 r2_prefix + subpath in serveDemoAsset /d/:id/<subpath> anonymous (needs a real demo id) fixed
session-tombstone:<id>, meter key, getSandbox(<id>) DO name POST /api/session body sessionId; path id on /api/session/:id[/*] anonymous (public routes) fixed
payload:<id> KV /api/payload/:id anonymous already guarded (^[a-z0-9]{1,32}$), left
chat-rl:<bucket>:<m/d>:<ip>:<time> KV (chat.ts) cf-connecting-ip set by Cloudflare, at most 45 bytes left, bounded by the platform
analytics:salt:<day>, versions catalog, last-good latest, settings, budget state, watchdog state constants / clock not request-derived left
admin meter read KV_METER_PREFIX + sessionId (admin.ts) id taken from a KV list result admin only, id already a stored key left
demos/<id>/... and avatars/<uuid> R2 puts server-minted shortId() / randomUUID() not request-derived left
BuildJob DO idFromName(demoId) (snapshot-jobs.ts) id of a row that already exists after getDemo succeeded left
builder sandbox build--<shortId> server-minted not request-derived left
o11y DO getByName("main"/"box"), inbox idFromName("main") constants not request-derived left
o11y sourcemaps/<version>/<path>.map R2 get Faro frame filename ingest is browser-facing fixed
o11y O11Y_INBOX.get(key) in the drain keys listed from the bucket internal left
preview hosts via proxyToSandbox hostname label, limited by DNS to 63 bytes anonymous left, SDK-internal and DNS-bounded

Not fixed, noted for follow-up: sessionId characters are still not restricted to the preview-hostname grammar ([a-z0-9-]), only bounded in length.

Types of changes

  • New example
  • Update to an existing example
  • README / documentation change
  • Demo runner (runner/) change
  • CI / tooling change

How was this verified?

Nothing here was validated in production or against any production host; everything ran locally under node --test against fakes. The new runner/pipeline/storage-key-guards.test.mjs (13 tests) and one case in o11y-symbolicate.test.mjs drive the real default export of workers/api/src/index.ts with a KV fake that throws on keys over 512 bytes (and an R2 fake that throws over 1024), like the real services. They assert: overlong v gives 400 with no KV access, no throw and no Sentry capture; a multibyte string under 512 characters but over 512 bytes is rejected (bytes, not characters); a valid v still asks npm once and is then served from KV; overlong demo ids give 404; an overlong /d subpath does not reach R2; overlong session ids give 400 with no KV or sandbox access on create, delete and subroutes; the symbolicator does not request an overlong map key.

Revert proof: with index.ts, share.ts and symbolicate.ts checked out from the base commit (git checkout 23ebd1881 -- <paths>, then restored), 10 of the 14 new tests fail (the base versions/exists case fails with the real message KV GET failed: 414 ... length of 615 and status 500 instead of 400); the other 4 (unit test of the byte helpers, valid v, empty v, real demo still served) are behavior-preserving guards that pass before and after by design.

  • pnpm --filter @handsontable/demo-runtime build: ok
  • node --experimental-strip-types --test pipeline/*.test.mjs: 2589 pass, 0 fail
  • tsc --noEmit in workers/api and workers/o11y: clean
  • npx wrangler deploy --dry-run --containers-rollout=none in workers/api: bundles (the plain dry run needs Docker, which was not available)

Checklist

  • New/renamed example: not applicable, no example added or renamed
  • New example: not applicable, no README table change
  • Ran pnpm build (and pnpm dev) in the affected example/server-example locally: not applicable, no example changed; runner tests and typechecks run as listed above

Related issue(s):

  1. DEV-3093 (criterion-11 probe trigger retired by this fix)

Note

Medium Risk
Touches public session and version routes and fixes a session-id bypass that could reach sandbox RPCs; behavior for valid inputs is unchanged and guards only add early 4xx/404/miss paths.

Overview
Adds storage-key.ts helpers that measure UTF-8 byte length (not string length) and validates request-derived identifiers before Workers KV, R2, or Durable Object names are built.

API worker: GET /api/versions/exists now shape-checks v with VERSION_QUERY_RE and returns 400 before any KV read (fixes anonymous 500s and Sentry noise from overlong ?v=). Demo lookups treat overlong demo:<id> cache keys as a miss (404). serveDemoAsset drops R2 candidate paths whose keys would exceed 1024 bytes. Tier-2 session routes reject ids over 128 bytes with 400 on create, delete, and all /api/session/:id/* subroutes—blocking a path where KV tombstone/meter errors were swallowed and could boot a sandbox for an attacker-chosen id.

O11y: symbolicate.ts skips sourcemap R2 lookups when a browser-supplied frame would produce an overlong map key.

Tests: New storage-key-guards.test.mjs (strict KV/R2 fakes) plus a symbolication case asserting no overlong map fetch.

Reviewed by Cursor Bugbot for commit 45bee4b. Bugbot is set up for automated code reviews on this repo. Configure here.

…fore use

GET /api/versions/exists read KV before its try block with an unbounded key, so an overlong v threw into the catch-all as an anonymous 500 and a Sentry event. Guard it and the other anonymously reachable key sites (demo ids, /d subpaths against R2, Tier-2 session ids, sourcemap keys).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@demtario
demtario marked this pull request as ready for review September 30, 2026 13:04
@demtario
demtario merged commit 263de70 into master Sep 30, 2026
10 checks passed
@demtario
demtario deleted the fix/api-versions-exists-kv-key-guard branch September 30, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant