Skip to content

Security: data crystals can no longer squat the kind-blind unique key slots (followKey & friends) - #320

Open
lopugit wants to merge 1 commit into
developfrom
claude/crystal-unique-key-squat-guard
Open

Security: data crystals can no longer squat the kind-blind unique key slots (followKey & friends)#320
lopugit wants to merge 1 commit into
developfrom
claude/crystal-unique-key-squat-guard

Conversation

@lopugit

@lopugit lopugit commented Aug 19, 2026

Copy link
Copy Markdown
Owner

The gap

The things_v2 partial unique indexes things_member_key_unique, things_dm_key_unique, things_invite_code_unique, things_emoji_key_unique, things_follow_key_unique (all { 'crystal.<key>': { $type: 'string' } }) and things_friend_unique ({ 'crystal.friendKey': { $exists: true } }) are kind-blind: their filters see only the crystal root path, so any thing carrying the key enters the index. Meanwhile the free-form data crystal sanitizer accepted arbitrary root keys matching [A-Za-z0-9_-]+.

So any user could POST /api/v1/things a data thing with e.g. crystal.followKey = '<followerId>:<followeeId>' or crystal.memberKey = '<communityId>:<userId>' and permanently occupy another user's unique slot: the victim's real follow/join/DM/invite/emoji insert then hits E11000, which the flows mostly swallow (social.ts re-reads and reports state; membership joins fail outright).

Not exposed (verified while auditing):

  • app-data crystals (api/utils/apps/appData.ts): user payloads are stored under crystal.value, never at the crystal root, and things_app_data_unique is already thingtime: 'app-data'-scoped — the exact same squat was found and fixed there previously (see the index comment).
  • things_app_client_unique and the subscription-tier indexes: thingtime-scoped.
  • things_reaction_unique / things_follow_unique (legacy): include ownerId in the key, so a data thing can only collide with the attacker's own slot (self-DoS only).
  • Protected messenger kinds via generic CRUD: already 403 (covered by verify-messenger.mjs, "membership cannot be forged through /things").

The fix — option (a): reserve the keys at the data-crystal root

sanitizeDataCrystal now rejects followKey, friendKey, memberKey, dmKey, inviteCode, emojiKey, voteKey at the crystal root — 400 naming the key, any value type (the friend index fires on $exists, and one name-based rule is deterministic). Nested occurrences stay legal: the indexes only see the root path, and nesting is the documented escape hatch. This covers every generic write surface (POST / PUT / PATCH — updates validate the merged crystal, so a pre-fix squat can't survive an edit either) plus the schema-less convenience path (thingtime omitted → ['data']).

voteKey is reserved ahead of its index (things_vote_key_unique on the poll-voting branch) so that merge can never open a squat window.

Single-source-of-truth wiring: RESERVED_CRYSTAL_ROOT_KEYS (registry.ts, client-pure) ↔ KIND_BLIND_UNIQUE_CRYSTAL_ROOT_KEYS (collections.ts, beside the index definitions) — the lockstep unit test fails the build if a key is added on one side only.

Option (b) evaluated: kind-scoping the partial filters

The task asked to evaluate adding the owning kind to each partialFilterExpression instead. I probed the open semantics question on mongod 8.0.1 (matching prod major): partial-filter equality on the multikey thingtime array does use array-contains inclusion — dup k blocked for thingtime: ['follow'] and ['data','follow'], allowed for ['data']. So (b) is technically sound, despite the older "partial filters can't reliably scope on the multikey thingtime array" comment on things_follow_unique (the marker-field era predates the verified thingtime: 'app-data' scoped pattern).

I still shipped (a) as the fix because:

  • (b) redefines six unique indexes on every deployment DB (home + lazily-ensured custom endpoints) through the conflict-drop path, with a constraint-free window during each rebuild — real blast radius for the same net coverage.
  • (a) closes every current writer (data things are the only user-controlled crystal-root surface; all other sanitizers are whitelists), fails loudly at the API layer with an actionable message instead of a confusing E11000 downstream, and needed zero index churn.
  • The collections.ts comment now records the verified 8.0 semantics and directs new unique indexes to the thingtime-scoped pattern, so the kind-blind class stops growing. Migrating the six existing indexes to scoped filters remains a sensible follow-up hardening.

Testing

  • Unit (remix/app/schemas/reservedCrystalRootKeys.test.ts, in test:schemas): every reserved key rejected at the root naming the key; schema-less path; name-based across value types; nested + plural (followKeys) stay legal; lockstep pin to the collections list; voteKey pre-reservation. Full npm run test:unit: 27 suites, 0 failures.
  • API suite (things-data-reserved-crystal-root in apiTests.ts, /tests page + npm test): the squat POST must never 200 (400 with a session, 401 anonymous). Things group: 31/31 passed against the worktree dev stack.
  • End-to-end against the live dev stack (two users registered through the real register API): the original attack (crystal.followKey = '<victim>:<attacker>') → 400 naming the key; all 7 keys rejected across value types; PATCH-adding a reserved key → 400; nested/plural data thing → 200; and the real follow + friend flows still succeed (no false positive on the server-side writers). 14/14 checks.
  • npm run build:client clean.
  • TESTING.md: new bug-class line under "Data crystals & nesting depth".

Note: existing prod docs that already carry a reserved root key (legit or squats placed before this fix) keep working read-side but will 400 on their next merged edit until the key is renamed/removed (PUT-with-replace clears it). A read-only count of such docs would size whether a cleanup migration is worth it.

🤖 Generated with Claude Code

…llowKey & friends)

The partial unique indexes things_member_key_unique, things_dm_key_unique,
things_invite_code_unique, things_emoji_key_unique, things_follow_key_unique
and things_friend_unique filter on crystal.<key> alone — no kind scoping —
while the free-form data crystal accepted arbitrary root keys. Any user could
POST /api/v1/things a data thing with e.g. crystal.followKey =
'<followerId>:<followeeId>' and permanently occupy another user's unique
slot: the victim's real follow/join/DM insert then hits E11000, which the
flows mostly swallow (social.ts re-reads and reports state; membership joins
fail outright).

sanitizeDataCrystal now rejects the reserved keys at the crystal ROOT — 400
naming the key, any value type, creates and merged updates alike — while
nested occurrences stay legal (the indexes only see the root path). voteKey
is reserved ahead of its index landing (poll-voting branch) so that merge
can never open a squat window. KIND_BLIND_UNIQUE_CRYSTAL_ROOT_KEYS in
collections.ts plus the lockstep test in reservedCrystalRootKeys.test.ts pin
the reserved list to the index list, and TESTING.md records the bug class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thingtime Ready Ready Preview Aug 19, 2026 3:23am
thingtime (develop) Ready Ready Preview Aug 19, 2026 3:23am

Request Review

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

✅ Develop S3 preview ready

The alias passed the develop bucket CORS preflight and a final live PR/SHA fence.

Generic Vercel Preview deployments use the shared development runtime; this controller adds the stable exact-SHA alias and marker-scoped cleanup.

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