fix(security): close 3 watchdog findings (stub prefix, ReDoS, md) - #8118
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound fixes at the right layers, but the "reserved namespace" invariant is only half-closed: the equality sentinels remain claimable at the same door. Watch
Suggestions
[DESIGN-REVIEWED] 5f0b886 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified: the internal prefixes are only minted in-process ( First-Principles-Verdict: PASS Three named, measured defects, each fixed at its mechanism with the single rider (the SEL audit) grounded in the PR's own harm statement. What this change shipsIntent: close three verified security findings — a stub-prefix exemption grab, a quadratic markdown fold, and a code-span fence escape. FIX.
Depth checks out: grepped Subtractions
[FIRST-PRINCIPLES-REVIEWED] 5f0b886 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
4816671 to
3e9744a
Compare
|
Scope reduced: the Revision 1 of this branch carried a fourth fix: the bare-name #7712 is a strict superset of what I had:
It also touches the same lines, so both landing would conflict — and #7712 has already worked through the So this PR now carries only the three findings that overlap nothing: the reserved stub-uuid prefix at MCP-gateway registration, and the two markdown ones in The one thing rev 1 had that #7712 does not — passing an explicit |
Three independent findings, each closed with the smallest change that removes the mechanism. 1. Model-hidden-tool filter bypass at stub registration (MEDIUM). `Backend` exempts any stub uuid starting with `INTERNAL_STUB_PREFIXES` from the MCP Apps render path AND the model-visibility filter. That is correct for requests the gateway mints itself, but `_handle_stub_conn` rejected only an EMPTY `stub_uuid`, so a registrant that simply NAMED itself with the prefix inherited both exemptions and could be served tools the model is meant not to see -- with no SEL record of the withhold that never happened. Refused at registration, mirroring the existing empty-check reject, and the refusal is itself audited: claiming a reserved prefix is an attempt to acquire an exemption, which is the same class of access decision as `_audit_peer_identity_denied` and is recorded the same way. The sibling rejects on this path stay WARNING-only because they are schema failures with no control being evaded. The gateway's own internal stubs are attached in-process (`Backend.attach_stub` for `__app_call__`, `probe_tool_surface` for `__tool_surface__`), never through a Register frame, so nothing legitimate is refused. 2. ReDoS in the markdown one-line fold (MEDIUM). `_md_one_line` folded with `\s*\n\s*`, where `\s` matches a newline too, so the runs and the anchor competed for the same characters: on a newline-FREE whitespace run the engine retried every split at every offset. Called per heading and per table cell, with provider-controlled content bounded only by the 8MiB fetch cap -- 200k spaces took ~45s. Replaced with split/strip/join, which reads each character a fixed number of times: the same input now folds in 0.3ms. Output is unchanged, verified by a differential harness over 40k inputs including \r, \v, \f, \x85, \u00a0, \u2028 and \u3000. 3. Markdown code-span breakout (MEDIUM). `_md_inline_code` fenced ADF `code`-marked text but let INTERIOR newlines through verbatim. A code span is inline, so a blank line ended the enclosing paragraph and everything after it was parsed as fresh markdown -- outside the fence, and so past `_md_escape_inline`, `_md_link_target` and the redaction gate. Line breaks are now collapsed to a space before fencing. Collapsed rather than promoted to a fenced block because a block would change the document structure at every call site, while the escape is what has to hold. Scope note: an earlier revision of this branch also carried a fix for the bare-name `kiro-cli` spawn in the gateway's unattended auto-update (`slack/gateway.py`). That fix is dropped here because PR #7712 already owns it under issue #7704 -- same `resolve_kiro_cli()` approach, plus the `asyncio.to_thread` offload, plus the `cli_server.py` sibling call site and the `docs/system-specs/modules/cli.md` update this branch did not carry. Keeping a second copy would only conflict with it. Tested - pytest test_source_providers, test_gatewayd_more_coverage, test_mcp_gatewayd_coverage, test_governance_updates, test_spawn_audit, test_gatewayd_diag, test_gatewayd_self_exit, test_source_providers_comment_guard, test_source_provider_plugin: pass. The two `test_provider_executable_accepts_*` failures are pre-existing on origin/main in this environment (home ownership) and pass in CI. - black (added lines only, py312 target), flake8, isort, mypy --platform linux: clean. - brand / focus-cue / changelog / harness-parity gates run diff-scoped with their BASE_REF exported: pass. docs-lint: pass. Revert-verified (each guard fails when its fix is reverted, and passes again when restored): - #1 the registration test fails on `__app_call__` when the prefix reject is removed, and the audit test fails `assert [] == ['__app_call__deadbeef']` when the SEL call is removed. - #2 the linearity guard fails at 45.6s against a 2.0s budget with the old pattern restored. - #3 both code-span tests fail, one on `'\n' not in out`, when the collapse is removed.
3e9744a to
5f0b886
Compare
Legitimate, and I checked the distinction rather than pattern-matching on "nearby rejects don't audit". The two sibling rejects on this path — a malformed Register ( It also lands against my own stated rationale: this PR's description argues the bypass matters partly because it leaves "no SEL record of the withhold that never happened". Refusing to audit my own denial of that bypass would have contradicted the reason the fix exists. What changed. Added On the peer-supplied Pinned by two tests, not left to review:
Revert-verify: removing just the Gates re-run on the rebased head ( |
bolichen97
left a comment
There was a problem hiding this comment.
Approving on the strength of a full readiness audit of every open PR against main, not a
line-by-line reading of this diff — recording that plainly so the next reader knows what this
stamp does and does not cover.
Verified against this exact head SHA:
readiness: passedpresent, andPR Readiness— the one required status context onmain
(rulesetprotected-branches) — issuccesson this head.- No check run on this head is
failure,cancelled,timed_outor still in flight. Skipped
jobs are path-filtered conditionals, none of them required. mergeable: true, and the head is not far enough behindmainfor its green CI to describe a
base that no longer exists.- No surviving reviewer
CHANGES_REQUESTED: any such review is on an older commit and therefore
already dismissed bydismiss_stale_reviews_on_push. - Every issue comment, inline review comment and review thread was read and classified. Nothing
left is an unresolved human change request — the remainder is bot review-lane output, resolved
or outdated threads, explicitly non-blocking suggestions, and author status notes.
Auto-merge (squash) is armed, so this lands once every other ruleset requirement is met.
Problem / Motivation
Three security findings, each verified live against
origin/mainbefore this branch was cut, in two unrelated subsystems:Backendexempts any stub uuid starting withINTERNAL_STUB_PREFIXESfrom both the MCP Apps render path and the model-visibility filter (backend.py:3294)._handle_stub_connrejected only an emptystub_uuid, so a registrant could simply name itself with the reserved prefix and inherit both exemptions._md_one_linefolded withre.sub(r"\s*\n\s*", " ", text).\smatches a newline too, so the two runs and the anchor compete for the same characters._md_inline_codefenced ADFcode-marked text but let interior newlines through verbatim.Why it matters
#1 hands a registrant two exemptions it should never hold. The model-visibility filter is what withholds hidden tools from a
tools/list, and its SEL audit is what records the withhold — so a stub that inherits the internal exemption is served the unfiltered surface and leaves no audit trail of the withhold that never happened.#2 is a denial of service on provider-supplied content.
_md_one_lineis called per heading (:3501) and per table cell (:4007), and the content is bounded only by the 8MiB fetch cap. Measured against this branch's base: 200k spaces took 45.3s in a single call, 20k took 455ms. A document with many such cells stalls the handler for as long as the provider cares to make it.#3 is a full escape from the markdown sanitizer. A blank line inside the code content ends the enclosing paragraph, and everything after it is parsed as fresh markdown — outside the fence, and so past
_md_escape_inline,_md_link_targetand the redaction gate. Whatever those three exist to stop is reachable through it.What changed (motivation → approach → change)
#1
src/kiro_crew/mcp_gateway/gatewayd.py. Thebackend.py:3294guard is correct and is untouched — the gap is registration-side, so that is where the fix goes. Immediately after the existing empty-stub_uuidreject, and in the same shape, astub_uuidstarting withINTERNAL_STUB_PREFIXESis refused with{"type": "rejected", "reason": "reserved stub_uuid prefix"}plus a warning.INTERNAL_STUB_PREFIXESis imported frombackendrather than re-spelled, so adding a prefix there keeps registration and interception in agreement.The refusal is also audited, via a new
_audit_reserved_stub_prefix_deniedbeside its closest sibling_audit_peer_identity_denied. This is the one reject on this path that is a security control rather than a schema check: a malformed Register (:2413) and an emptystub_uuid(:2485) stay WARNING-only because nothing is being evaded, whereas claiming a reserved prefix is an attempt to acquire an exemption — the same class of access decision as the_audit_peer_denied/_audit_pool_rejectedfamily this module already records. Leaving it unaudited would also have contradicted this PR's own rationale above, which counts the missing SEL record as part of the harm.The peer-supplied
stub_uuidis recorded as received rather than pre-sanitized, and that was traced rather than assumed:log_api_accessrunsresourcesthrough_redact_and_clip, which redacts and clips but does not strip control characters, so the escaping comes from the writer —sel.py:1094serializes each event asjson.dumps(asdict(event)) + "\n", and JSON escapes CR/LF. A forged log line is not reachable, so a sanitizing pass would be dead code.No false-positive risk, checked rather than assumed: the gateway's own internal stubs are attached in-process —
Backend.attach_stubfor__app_call__(app_call.py:95),probe_tool_surfacefor__tool_surface__— and never arrive as a Register frame over the socket. Verified by grepping every use of both prefixes.#2
_md_one_line. Replaced the regex with" ".join(seg for seg in (line.strip() for line in text.split("\n")) if seg). Split/strip/join reads each character a fixed number of times, so the pathological input is linear.An intermediate pattern (
[^\S\n]*\n[^\S\n]*(?:\n[^\S\n]*)*) was tried first and measured 4x worse — the leading run still backtracks through every offset. Recorded because it looks like the fix and is not one.Output is unchanged, not merely believed to be: a differential harness compared old and new over 40,031 inputs — 31 hand-written edge cases plus 40,000 random strings over an alphabet of
a, space,\t,\n,\r,\v,\f, backtick,\x85,\u00a0,\u2028,\x1c,\u3000— with zero mismatches. That alphabet is load-bearing:str.split("\n")splits only on\n, wherestr.splitlines()would also split on\v,\fand\r, which the old pattern did not match.xx#3
_md_inline_code. One line,text = re.sub(r"\r\n?|\n", " ", text), ahead of the fence computation, so every downstream step (fence length, boundary padding) sees content that cannot contain a line break.\r\n?|\nrather than\nalone because CommonMark ends a line on a bare CR and on CRLF too.Collapsed rather than promoted to a fenced block: the caller is
_adf_apply_marks, which composes an inline run, so a block would change the document structure at every call site — and the escape, not the rendering shape, is what has to hold.Scope note. An earlier revision of this branch also carried a fix for the bare-name
kiro-clispawn in the gateway's unattended auto-update (src/kiro_crew/slack/gateway.py). It is dropped here because #7712 already owns that fix under #7704: sameresolve_kiro_cli()approach, plus theasyncio.to_threadoffload, plus thecli_server.pysibling call site and thedocs/system-specs/modules/cli.mdupdate this branch did not carry. Both touched the same lines, so a second copy would only conflict with it. The one thing this branch had that #7712 does not — an explicitenv=on the spawn — is raised as a comment on #7712 rather than kept here.Tests
test_gatewayd_more_coverage.py::test_register_naming_a_reserved_stub_prefix_is_rejected— iteratesINTERNAL_STUB_PREFIXES(asserting it is non-empty first, so it cannot pass vacuously) and pins the exact reject frame for each prefix.test_gatewayd_more_coverage.py::test_reserved_stub_prefix_rejection_is_audited— pins that the reject path emits the SEL event, not merely that the helper exists.test_mcp_gatewayd_coverage.py_AUDIT_CASES— the new emitter joins the shared table, inheriting both contracts every sibling emitter is held to: the documented operation name, and that aSecurityEventLogfailure never propagates to the caller.test_source_providers.py::test_newline_in_a_code_span_cannot_break_out_of_the_fence—safe\n\n# Injected\n[x](javascript:alert(1))yields a single-line span with no newline anywhere in the output.test_source_providers.py::test_carriage_return_in_a_code_span_is_collapsed_too— bare CR and CRLF, the two cases a\n-only fix would miss.test_source_providers.py::test_folding_a_long_whitespace_run_is_linear— 200k spaces under a 2.0s budget, ~100x the measured linear cost of 0.34ms, so it fails only on a return to quadratic scanning rather than on machine noise.Manual verification
test_source_providers,test_gatewayd_more_coverage,test_gatewayd_diag,test_gatewayd_self_exit,test_mcp_gatewayd_coverage,test_source_providers_comment_guard,test_source_provider_plugin,test_slack_gateway,test_governance_updates,test_spawn_audit→ 1155 passed.test_provider_executable_accepts_user_owned_installand..._symlinked_installfail on this host from home ownership. Reproduced identically onorigin/mainin a cleangit worktree, and they pass in this PR's own CI run — environmental, outside this diff.flake8,isort,mypy --platform linuxclean.black— the two test files fail--checkonorigin/maintoo under Python 3.12 against apy314target, so it was scoped: black was run on a copy of each changed file and no reformat hunk overlaps a line this branch adds.BRAND_BASE_REF,FOCUS_CUE_BASE_REF,CHANGELOG_BASE_REF,HARNESS_BASE_REF=git merge-base HEAD origin/main) so they enforce rather than report — all four pass, each naming the base sha in its output.docs-lint: 259 files, pass. No doc or spec references the changed behaviour (grepped), so none needed updating.Revert-verify
Every guard was mutated back to the defect and confirmed to fail, then restored and confirmed to pass:
AssertionError: __app_call___audit_reserved_stub_prefix_deniedcallassert [] == ['__app_call__deadbeef']re.sub(r"\s*\n\s*", ...)assert (… - …) < 2.0at 45.58sassert '\n' not in 'safe\n\n\# …', and the CR test onab`All four then pass on the restored tree, and each mutation script asserted the file was byte-identical to the original afterwards.
Related Issues
no linked issue: the findings came from a security watchdog sweep rather than filed issues. The dropped fourth finding is tracked separately as #7704 and fixed by #7712.
Pattern harvest
Rule candidate: review-prompt
Pattern: a regex whose quantified run can also match its own anchor (
\s*\n\s*,.*X.*), making the run and the anchor compete and the scan quadratic.Finding #2 reads as obviously correct and is a DoS on attacker-controlled input. The mis-fix attempted here — a variant that measured 4x worse — shows the trap survives a careful first attempt, so the reviewable signal is the shape of the pattern, not the intent behind it. A review prompt that flags "quantifier whose character class contains the anchor it is searching for" would catch both the original and the bad fix.
Findings #1 and #3 are each a single missing check at a boundary, and the general lesson ("validate at the door, not only at the consumer") is already project doctrine — no new rule.
Checklist