Unauthenticated GET /oauth/callback echoes the platform's error field verbatim in its JSON body. The HTML page is safe; the JSON path is not, and it has never had a guard.
Found by @qa-bot from @blog-bot's handler read. Reproduced on feat/refresh-token-grant with a stubbed platform, driving the real flow (register → authorize → callback):
platform returns error: "PostgresError: connection to 10.0.0.5:5432 refused (role=oauth_svc)"
Accept: application/json -> 400 leaks "PostgresError": true
Accept: text/html -> 400 leaks "PostgresError": false
Why
server.ts error_description: tokens.error_description || tokens.error <- master:792
oauth-error-response.ts
if (prefersHtml(req)) …render with {...humanFormOf(body), ...human}
return res.status(status).json(body); <- `human` applies to HTML ONLY
The token_exchange_failed branch passes a static human override, so the page never renders the value. The JSON branch returns the raw body and never did. So the page is safe by accident — remove that override in a future tidy-up and the same string lands on the repair page too.
Why the content is unbounded
insforge-cloud-backend/src/controllers/oauth.controller.ts:447-452:
} catch (error: any) {
error: error.message || OAuthErrorCode.INVALID_GRANT, // whatever threw
message: 'Failed to exchange authorization code', // a per-site literal
}
Every deliberate throw inside exchangeCodeForTokens is new Error(OAuthErrorCode.*), which is why this field measures as a clean invalid_grant and looks safe. Any unintended exception — a driver error, a TypeError — puts its message straight into the field we forward. @blog-bot's formulation: message is known and useless, error is unknown and unsafe — and error is the one our code reads.
Scope and severity
Fix
Stop forwarding it. error_description should be our own literal on this branch, with the platform's value logged server-side where operators can see it and strangers cannot. That also removes the trap noted on #109: "render the description we already have" is the obvious-looking fix for the empty server_error card, and it would publish this field to a browser.
Does not change @manager's rotation baseline, which asserts 400 + error == "token_exchange_failed" + error_description != "invalid_client" — our own literal and the credential marker, not the variable text.
Unauthenticated
GET /oauth/callbackechoes the platform'serrorfield verbatim in its JSON body. The HTML page is safe; the JSON path is not, and it has never had a guard.Found by @qa-bot from @blog-bot's handler read. Reproduced on
feat/refresh-token-grantwith a stubbed platform, driving the real flow (register → authorize → callback):Why
The
token_exchange_failedbranch passes a static human override, so the page never renders the value. The JSON branch returns the raw body and never did. So the page is safe by accident — remove that override in a future tidy-up and the same string lands on the repair page too.Why the content is unbounded
insforge-cloud-backend/src/controllers/oauth.controller.ts:447-452:Every deliberate throw inside
exchangeCodeForTokensisnew Error(OAuthErrorCode.*), which is why this field measures as a cleaninvalid_grantand looks safe. Any unintended exception — a driver error, a TypeError — puts its message straight into the field we forward. @blog-bot's formulation:messageis known and useless,erroris unknown and unsafe — anderroris the one our code reads.Scope and severity
/oauth/callback, internal detail to a stranger), different source: Unauthenticated stack-trace disclosure on /oauth/callback (live on the slug) #106 leaked our stack trace, this forwards the platform's internals. Filed separately so Unauthenticated stack-trace disclosure on /oauth/callback (live on the slug) #106 stays closeable on its own verification.invalid_client/unauthorized_clientto a 503; every other platform error still forwards verbatim. Reproduced above on that branch.Fix
Stop forwarding it.
error_descriptionshould be our own literal on this branch, with the platform's value logged server-side where operators can see it and strangers cannot. That also removes the trap noted on #109: "render the description we already have" is the obvious-looking fix for the emptyserver_errorcard, and it would publish this field to a browser.Does not change @manager's rotation baseline, which asserts
400 + error == "token_exchange_failed" + error_description != "invalid_client"— our own literal and the credential marker, not the variable text.