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:
@@ -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('&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
|
||||
|
||||
@@ -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 & 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, '&')
|
||||
|
||||
Reference in New Issue
Block a user