OAuth2 login (clickhouse-client): loopback /start leaks CSRF state and OIDC discovery response is unbounded
Follow-up audit of PR #1606 after the first-round fixes. Two Medium defects remain.
Defect 1 — Medium: Loopback /start leaks full auth URL (including CSRF state) to any local process
Impact: on a shared host, a local unprivileged process can read the in-flight OAuth state via the loopback server and race a forged /callback before the legitimate browser redirect — account-confusion / login hijack during --login=browser.
Affected code: src/Client/OAuthFlowRunner.cpp — AuthCodeHandler::handleRequest, runOAuthAuthCodeFlow.
if (path == "/start")
{
std::string target;
{
std::lock_guard<std::mutex> lock(state.mtx);
target = state.auth_url;
}
response.redirect(target, Poco::Net::HTTPResponse::HTTP_FOUND);
}
std::string auth_url
= creds.auth_uri
+ "?response_type=code"
// ...
+ "&state=" + csrf_state;
Why it's a defect: callback acceptance relies on received_state == csrf_state, but /start redirects to state.auth_url, which embeds csrf_state. Any local process that discovers the loopback port can GET /start, read Location:, and deliver a matching /callback?code=...&state=... ahead of the legitimate redirect.
Steps to reproduce:
- Start
clickhouse-client --login=browser on a shared host.
- Local process on the same host finds the loopback port and issues
GET /start.
- That process reads the auth URL +
state from the Location: header.
- It drives the OAuth flow (or races the real browser) and delivers
/callback?code=<attacker-code>&state=<leaked-state> first.
- Client accepts the callback (state matches) and binds to the attacker's identity.
Expected: /start must not disclose the auth URL or state to an unauthenticated local caller.
Actual: /start returns HTTP 302 with Location: containing the full auth URL and state=<csrf_state> to any caller on the loopback socket.
Fix direction: remove the server-side redirect indirection and launch the browser directly with the auth URL (URL never leaves the process). If /start must stay, gate it behind a one-time unguessable launch token required on both /start and /callback.
Regression test direction: loopback-handler test asserting GET /start does not return a state-bearing redirect, and that a callback crafted from information obtainable via /start cannot complete the flow.
Defect 2 — Medium: OIDC discovery response is unbounded (memory-exhaustion DoS)
Impact: a malicious or misconfigured discovery endpoint can force unbounded memory growth in clickhouse-client during --login=device with discovery enabled, up to OOM termination.
Affected code: src/Client/OAuthProviderPolicy.cpp — fetchDeviceEndpointFromIssuer.
auto & stream = session.receiveResponse(response);
Poco::StreamCopier::copyToString(stream, body);
// ...
auto result = parser.parse(body);
Why it's a defect: discovery uses unbounded Poco::StreamCopier::copyToString and parses afterward. No byte-size guard on this path, even though postOAuthForm in the same PR already implements a bounded-copy pattern.
Steps to reproduce:
- Configure
--oauth-credentials with no device_authorization_uri (forces discovery) and an issuer pointing at an attacker-controlled / misbehaving endpoint.
- Run
clickhouse-client --login=device.
- Endpoint serves a very large (or chunked-forever) body at
.well-known/openid-configuration.
- Observe RSS of the client growing proportional to response size.
Expected: discovery fetch enforces a hard maximum body size and fails fast with AUTHENTICATION_FAILED on overflow, consistent with postOAuthForm.
Actual: full response body is buffered into std::string with no cap before parsing.
Fix direction: apply the same bounded-copy pattern used in postOAuthForm (hard max bytes + explicit AUTHENTICATION_FAILED on overflow). Consider centralizing the helper so all OAuth HTTP reads share the same limit.
Regression test direction: mock discovery endpoint returning a payload over the limit; assert bounded failure without unbounded RSS growth. Covers both generic OIDC and Google discovery paths.
References
OAuth2 login (clickhouse-client): loopback
/startleaks CSRF state and OIDC discovery response is unboundedFollow-up audit of PR #1606 after the first-round fixes. Two Medium defects remain.
Defect 1 — Medium: Loopback
/startleaks full auth URL (including CSRFstate) to any local processImpact: on a shared host, a local unprivileged process can read the in-flight OAuth
statevia the loopback server and race a forged/callbackbefore the legitimate browser redirect — account-confusion / login hijack during--login=browser.Affected code:
src/Client/OAuthFlowRunner.cpp—AuthCodeHandler::handleRequest,runOAuthAuthCodeFlow.std::string auth_url = creds.auth_uri + "?response_type=code" // ... + "&state=" + csrf_state;Why it's a defect: callback acceptance relies on
received_state == csrf_state, but/startredirects tostate.auth_url, which embedscsrf_state. Any local process that discovers the loopback port canGET /start, readLocation:, and deliver a matching/callback?code=...&state=...ahead of the legitimate redirect.Steps to reproduce:
clickhouse-client --login=browseron a shared host.GET /start.statefrom theLocation:header./callback?code=<attacker-code>&state=<leaked-state>first.Expected:
/startmust not disclose the auth URL orstateto an unauthenticated local caller.Actual:
/startreturns HTTP 302 withLocation:containing the full auth URL andstate=<csrf_state>to any caller on the loopback socket.Fix direction: remove the server-side redirect indirection and launch the browser directly with the auth URL (URL never leaves the process). If
/startmust stay, gate it behind a one-time unguessable launch token required on both/startand/callback.Regression test direction: loopback-handler test asserting
GET /startdoes not return a state-bearing redirect, and that a callback crafted from information obtainable via/startcannot complete the flow.Defect 2 — Medium: OIDC discovery response is unbounded (memory-exhaustion DoS)
Impact: a malicious or misconfigured discovery endpoint can force unbounded memory growth in
clickhouse-clientduring--login=devicewith discovery enabled, up to OOM termination.Affected code:
src/Client/OAuthProviderPolicy.cpp—fetchDeviceEndpointFromIssuer.Why it's a defect: discovery uses unbounded
Poco::StreamCopier::copyToStringand parses afterward. No byte-size guard on this path, even thoughpostOAuthFormin the same PR already implements a bounded-copy pattern.Steps to reproduce:
--oauth-credentialswith nodevice_authorization_uri(forces discovery) and an issuer pointing at an attacker-controlled / misbehaving endpoint.clickhouse-client --login=device..well-known/openid-configuration.Expected: discovery fetch enforces a hard maximum body size and fails fast with
AUTHENTICATION_FAILEDon overflow, consistent withpostOAuthForm.Actual: full response body is buffered into
std::stringwith no cap before parsing.Fix direction: apply the same bounded-copy pattern used in
postOAuthForm(hard max bytes + explicitAUTHENTICATION_FAILEDon overflow). Consider centralizing the helper so all OAuth HTTP reads share the same limit.Regression test direction: mock discovery endpoint returning a payload over the limit; assert bounded failure without unbounded RSS growth. Covers both generic OIDC and Google discovery paths.
References