Security: data crystals can no longer squat the kind-blind unique key slots (followKey & friends) - #320
Open
lopugit wants to merge 1 commit into
Open
Security: data crystals can no longer squat the kind-blind unique key slots (followKey & friends)#320lopugit wants to merge 1 commit into
lopugit wants to merge 1 commit into
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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. |
lopugit
added a commit
that referenced
this pull request
Aug 19, 2026
…hen PR #320 lands Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lopugit
added a commit
that referenced
this pull request
Aug 19, 2026
…hen PR #320 lands Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
The
things_v2partial unique indexesthings_member_key_unique,things_dm_key_unique,things_invite_code_unique,things_emoji_key_unique,things_follow_key_unique(all{ 'crystal.<key>': { $type: 'string' } }) andthings_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-formdatacrystal sanitizer accepted arbitrary root keys matching[A-Za-z0-9_-]+.So any user could
POST /api/v1/thingsa data thing with e.g.crystal.followKey = '<followerId>:<followeeId>'orcrystal.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.tsre-reads and reports state; membership joins fail outright).Not exposed (verified while auditing):
api/utils/apps/appData.ts): user payloads are stored undercrystal.value, never at the crystal root, andthings_app_data_uniqueis alreadythingtime: 'app-data'-scoped — the exact same squat was found and fixed there previously (see the index comment).things_app_client_uniqueand the subscription-tier indexes:thingtime-scoped.things_reaction_unique/things_follow_unique(legacy): includeownerIdin the key, so a data thing can only collide with the attacker's own slot (self-DoS only).verify-messenger.mjs, "membership cannot be forged through /things").The fix — option (a): reserve the keys at the data-crystal root
sanitizeDataCrystalnow rejectsfollowKey,friendKey,memberKey,dmKey,inviteCode,emojiKey,voteKeyat 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 (thingtimeomitted →['data']).voteKeyis reserved ahead of its index (things_vote_key_uniqueon 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
partialFilterExpressioninstead. I probed the open semantics question on mongod 8.0.1 (matching prod major): partial-filter equality on the multikeythingtimearray does use array-contains inclusion — dupkblocked forthingtime: ['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 onthings_follow_unique(the marker-field era predates the verifiedthingtime: 'app-data'scoped pattern).I still shipped (a) as the fix because:
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
remix/app/schemas/reservedCrystalRootKeys.test.ts, intest: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;voteKeypre-reservation. Fullnpm run test:unit: 27 suites, 0 failures.things-data-reserved-crystal-rootinapiTests.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.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:clientclean.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