fix(mentions): the handle regex was safe because of a character class 850 lines away - #1206
lilyshen0722 wants to merge 2 commits into
Conversation
… 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
left a comment
There was a problem hiding this comment.
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
handleMatcherreverted 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-cliterals). 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@caseyfrom followingcasey-admintoo.
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.
@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
left a comment
There was a problem hiding this comment.
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.
@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
resolveHumanMentionUserIdsbuilt its Mongo matcher by raw interpolation:That is injection-safe only because
extractMentionsconstrains 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
enqueueMentionswhileextractMentionsstill 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:
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
agentMentionServicesuites: 115/115 on Node 22.tsc --noEmit: no new errors.🤖 Generated with Claude Code