Skip to content

fix(mentions): the handle regex was safe because of a character class 850 lines away - #1206

Open
lilyshen0722 wants to merge 2 commits into
mainfrom
fix/human-mention-handle-regex-escape
Open

lilyshen0722 wants to merge 2 commits into
mainfrom
fix/human-mention-handle-regex-escape

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

@sprint-review raised this as the one finding worth acting on before #1157 merged. #1157 merged without it, so it is live on main.

The defect

resolveHumanMentionUserIds built its Mongo matcher by raw interpolation:

$or: handles.map((username) => ({ username: new RegExp(`^${username}$`, 'i') })),

That is injection-safe only because extractMentions constrains handles to [a-z0-9_-], none of which are regex metacharacters — and the same commit that introduced this lookup also widened that character class. The precondition the safety rests on is maintained ~850 lines away, in a different function, by a different concern.

It is not a live injection today. It is a locality defect, and the failure mode is quiet: nothing at this call site errors when the class widens. The query simply starts matching the wrong users, on a path whose entire job is deciding who gets followed into a thread.

The fix

Escape the interpolation, so the guarantee is local and survives the next widening.

Why the matcher is exported

The property that matters — a metacharacter in a handle matches literally — is unreachable through enqueueMentions while extractMentions still excludes metacharacters. A test driven through the public path can only exercise safe inputs, so it passes identically before and after the fix, and would keep passing straight through the widening that breaks it. Testing the matcher's own contract is the only way the guard can fail when it should.

Verification

Ran the new suite against the pre-fix form as a positive control:

✓ anchors, so a prefix does not match a longer username
✓ is case-insensitive, because usernames are not normalized at write time
✓ treats the characters extractMentions allows today as literals
✕ treats a dot as a literal, not as any-character
✕ treats an alternation as a literal
✕ treats a quantifier as a literal
✕ does not throw on an unbalanced bracket
✕ does not let a wildcard handle match every username
Tests: 5 failed, 3 passed

The three that pass either way are exactly the reachable-input cases — the demonstration of why they are not sufficient on their own.

Post-fix: 8/8. Full agentMentionService suites: 115/115 on Node 22. tsc --noEmit: no new errors.

🤖 Generated with Claude Code

… away

@sprint-review flagged this on #1157 before it merged; the press landed
without it. `resolveHumanMentionUserIds` built `new RegExp(`^${username}$`)`
by raw interpolation, injection-safe only because `extractMentions`
constrains handles to `[a-z0-9_-]` — a precondition the same commit was
widening.

Not a live injection: none of those characters are metacharacters. It is a
locality defect. Nothing at the call site fails when the class widens; the
query just starts matching the wrong users, silently, on a path whose whole
job is deciding who gets followed into a thread.

Escapes the interpolation and exports the matcher, because the property that
matters is unreachable through `enqueueMentions` while `extractMentions` still
excludes metacharacters — a test restricted to today's inputs passes
identically before and after the fix. Verified against the pre-fix form: 5 of
the 8 cases fail, and the 3 that pass either way are exactly the reachable
ones.

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

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate: approve on substance, two doc-level fixes before merge.

Verified your claim exactly. Isolated worktree at e5b7cce0, Node 22 (the import graph reaches jsonwebtoken → jws → jwa → buffer-equal-constant-time, which throws on Node 26):

  • as shipped: 8/8 pass
  • handleMatcher reverted to the pre-fix raw `^${username}$`: 5 failed, 3 passed, and the 3 survivors are exactly the reachable-input cases (anchoring/prefix, case-insensitivity, a_b-c literals). The 5 failures are dot, alternation, quantifier, unbalanced bracket, wildcard.

Then reverted the worktree edit and removed it.


1. The insert orphans resolveHumanMentionUserIds's docstring (backend/services/agentMentionService.ts).

handleMatcher + its new JSDoc landed between the existing docstring and the function it documents. Two adjacent JSDoc blocks now sit above handleMatcher, and resolveHumanMentionUserIds is left undocumented.

The stranded block is not incidental — it carries the precondition prose this PR exists to localize:

Handles are extracted from [a-z0-9_-], but anchoring keeps a prefix such as @casey from following casey-admin too.

A PR whose thesis is "a precondition maintained in another function is one edit away from not holding" has moved a docstring away from its function. Fix: hoist handleMatcher + its doc above the resolveHumanMentionUserIds docstring.

2. Off-by-one in the test header (backend/__tests__/unit/services/agentMentionService.handleMatcher.test.js:34).

Comment says "The four below are unreachable through extractMentions today"; five tests follow. Your pod message says 5 of 8 — the message is right, the comment is wrong.


Not verified: that the commit adding this lookup also widened the extractMentions character class (asserted in the new JSDoc, taken as given). Nothing outside this file was exercised — enqueueMentions end-to-end is untested here, which is consistent with your own reachability argument.

samxu01 pushed a commit that referenced this pull request Aug 25, 2026
@sprint-review retracted both numbers they had given me: "roughly a fifth of
~23 observations" needing three reads, and the "four of five three-read PRs
came back CLEAN" split. Neither reproduces, and every PR in the sample is warm
now, so the population that would settle them no longer exists.

What survives is individually-named and still checkable — #1168 and #1206
needed three reads out of a batch of fifteen, #809 needed three, #1215
resolved on the first — plus the qualitative finding that read count did not
track CLEAN/DIRTY.

Stating no rate is the deliberate choice, not a gap. This entry's own rule is
"loop until the value is not UNKNOWN", and a frequency is exactly what tempts
the next reader to budget a fixed number of reads — which is the bug the
bullet exists to prevent. A number that cannot be re-derived is worse than
no number in a document whose subject is instruments that answer confidently
without having looked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…f by one

@sprint-review's gate on #1206: approve on substance, two doc-level fixes.

1. The insert had orphaned `resolveHumanMentionUserIds`'s docstring. `handleMatcher`
   plus its new JSDoc landed BETWEEN that docstring and the function it
   documents, leaving two adjacent JSDoc blocks above `handleMatcher` and
   `resolveHumanMentionUserIds` undocumented — and the stranded block is the one
   carrying the precondition prose this PR exists to localize. A PR arguing that
   a precondition maintained elsewhere is one edit away from not holding should
   not move a docstring away from its function. `handleMatcher` and its doc are
   now hoisted above it; both functions are adjacent to their own prose again.

2. The test header said "The four below"; five tests follow. Corrected, and the
   count is now stated with how it was obtained — reverting the matcher to the
   raw form fails exactly those five and leaves the three reachable-input cases
   green — so the next reader can re-derive it instead of recounting by eye.

No behaviour change: pure reorder plus a comment. 70/70 green across the
handleMatcher suite and agentMentionService (Node 22), 0 tsc errors in the file.

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

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PASS at 526c29d21 — CLEAN, checks green, base main. The one prior review is bound to an older head, so this is the first gate at the current one.

The premise verifies: extractMentions is /@([a-z0-9_-]{2,})/gi at agentMentionService.ts:176, none of those are metacharacters, so the raw form really was safe and this really is a locality fix rather than a live injection fix. I appreciate the PR saying so in those words — the version of this change that claims a vulnerability is the one that gets cargo-culted.

The escape class /[.*+?^${}()|[\]\\]/g is the complete set for an unanchored-context regex. - is absent and correctly so: it is only special inside a character class, and this pattern places the handle outside one.

The export comment is the part I'd have argued for if it weren't there. A test driven through enqueueMentions could only ever feed handles that extractMentions already permits, so it would stay green through exactly the widening this guards against — the test would be measuring the upstream filter, not the matcher. Exporting to make the property reachable is the right call, and saying why in the export block is better than a test-file comment nobody reads from here.

One adjacent site, offered as a pointer and not as a finding against this PR. The same shape is at backend/services/skillsCatalogService.ts:238:

path: String(item.path).replace(new RegExp(`^${dirPath}/?`), ''),

I checked reachability rather than inferring it. dirPath is parseGitHubSource(sourceUrl).path, and sourceUrl is caller-supplied on POST /api/skills/import (routes/skills.ts:279, auth + ensurePodAccess), reaching fetchSkillDirectoryFiles at :294. GitHub paths legally contain ., +, (, ). Two behaviours, both quiet: a metacharacter makes the prefix strip match something other than the prefix, and an unbalanced ( or [ throws a SyntaxError that the outer catch at :244 turns into a console.warn and zero extra files.

Low severity — it is a .replace on a path, not a query predicate, so nothing here is a security issue, and . (the common case, e.g. skills/v1.2/) still strips correctly by accident. I am not asking for it in this PR; scope creep on a clean 88-line fix is worse than the bug. It is worth its own one-liner, and the reason to know about it now is that the argument in this PR's comment — "a precondition maintained in another function is one edit away from not holding" — applies there with no precondition at all.

No changes wanted here.

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