fix(security): resolve the CodeQL backlog, three fixes and three documented false positives (#1225)

Triage of all 9 CodeQL alerts surfaced on main by #1223. None were introduced by that PR.

Fixed: the compliance-review artifact now unpacks to runner.temp instead of over the trusted checkout (actions/artifact-poisoning, critical); MCP LIKE patterns escape backslash first, which was a real correctness bug returning wrong rows for any search containing a backslash (js/incomplete-sanitization, 2 sites); and the mcp-oauth consent form action is HTML-escaped (js/reflected-xss, not exploitable because WHATWG URL already percent-encodes " < >, but & is not in that encode set).

Dismissed as false positives with reasoning recorded at each site and in DECISIONS.md: sie-export escapeQuotes, where doubling backslashes would violate SIE 4B, corrupt files in conformant readers and skew #KSUMMA under BFL 7-year retention; hashApiKey, where SHA-256 is correct for a 256-bit CSPRNG token and changing it would invalidate every live gnubok_sk_ key; and the DuplicateBookingDialog href, which is a DB UUID behind a literal path prefix.

Regression tests cover both behavioural fixes, including the escape ordering.
This commit is contained in:
Jakob Wennberg
2026-07-27 14:02:25 +02:00
committed by GitHub
parent 4702a63cff
commit 7dde8cac82
8 changed files with 161 additions and 7 deletions
@@ -110,6 +110,42 @@ describe('GET /api/mcp-oauth/authorize: CSP', () => {
expect(csp).not.toContain('env=prod')
})
it('HTML-escapes the reflected query string in the form action', async () => {
// The consent form posts back to the same URL, so url.search is echoed into
// an HTML attribute, and only redirect_uri/client_id/scope are validated:
// any extra parameter reaches that attribute.
//
// Two layers, and it is worth being precise about which does what. WHATWG
// URL parsing already percent-encodes " < > in the query component, so an
// injected tag arrives inert and CodeQL's js/reflected-xss report is not a
// live exploit. But `&` is NOT in that encode set, so without escaping the
// attribute carries raw ampersands, which is invalid HTML and leaves the
// page one refactor (a raw header, a non-WHATWG parser) away from a real
// breakout. This asserts the escaping layer, independent of the parser.
const request = new Request(
buildAuthorizeUrl({
response_type: 'code',
redirect_uri: 'https://claude.com/api/oauth/callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
}) + '&evil=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E'
)
const response = await GET(request)
expect(response.status).toBe(200)
const html = await response.text()
const action = html.match(/<form method="POST" action="([^"]*)"/)?.[1]
expect(action).toBeDefined()
// Separators are entity-encoded: proof escapeHtml ran over the whole thing.
expect(action).toContain('&amp;evil=')
expect(action).not.toMatch(/&(?!amp;|quot;|lt;|gt;)/)
// The attribute is never closed early, so no raw markup escapes into the page.
expect(html).not.toContain('"><script>')
expect(html).not.toContain('<script>alert(1)</script>')
})
it('renders both read and write rows when client passes only the legacy `mcp` scope marker', async () => {
// Claude's connector sends scope=mcp today. The consent UI must render
// every scope group so the user can opt into write/approval rows if they
+16 -1
View File
@@ -556,7 +556,7 @@ export async function GET(request: Request) {
<span class="account-name">${escapeHtml(companyName)}</span>
</div>
<form method="POST" action="${url.pathname}${url.search}" id="consent-form">
<form method="POST" action="${escapeHtml(url.pathname + url.search)}" id="consent-form">
<input type="hidden" name="scope_binding" value="${escapeHtml(scopeBindingValue)}">
<input type="hidden" name="scope_binding_sig" value="${escapeHtml(scopeBindingSignature)}">
@@ -832,6 +832,21 @@ function scopeRow(scope: ApiKeyScope, checked: boolean, kind: 'read' | 'write'):
`
}
/**
* Every interpolation into the consent-page template goes through this,
* including the form's own action attribute (url.pathname + url.search).
*
* On that one: only redirect_uri/client_id/scope are validated upstream, so any
* extra query parameter a caller appends is reflected into the attribute.
* CodeQL reports it as js/reflected-xss. It was not a live exploit, because
* WHATWG URL parsing already percent-encodes " < > in the query component and
* an injected tag therefore arrives inert. It is escaped anyway for two
* reasons: & is NOT in that encode set, so the unescaped form emitted raw
* ampersands in an attribute (invalid HTML), and the safety of the page
* otherwise rests on a parser normalisation invariant that nothing in this file
* states or tests. Escaping & as &amp; is correct here: the browser decodes it
* back on submit, so the query string round-trips intact.
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')