Skip to content

OAuth2 login (clickhouse-client): loopback /start leaks CSRF state and OIDC discovery response is unbounded #1691

@Selfeer

Description

@Selfeer

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.cppAuthCodeHandler::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:

  1. Start clickhouse-client --login=browser on a shared host.
  2. Local process on the same host finds the loopback port and issues GET /start.
  3. That process reads the auth URL + state from the Location: header.
  4. It drives the OAuth flow (or races the real browser) and delivers /callback?code=<attacker-code>&state=<leaked-state> first.
  5. 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.cppfetchDeviceEndpointFromIssuer.

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:

  1. Configure --oauth-credentials with no device_authorization_uri (forces discovery) and an issuer pointing at an attacker-controlled / misbehaving endpoint.
  2. Run clickhouse-client --login=device.
  3. Endpoint serves a very large (or chunked-forever) body at .well-known/openid-configuration.
  4. 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

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions