Skip to content

fix: Require LK_UPSTREAM in the proxy image, and stop leaking upstream credentials - #18

Merged
harshaneel merged 3 commits into
mainfrom
hg/proxy-require-upstream
Aug 5, 2026
Merged

fix: Require LK_UPSTREAM in the proxy image, and stop leaking upstream credentials#18
harshaneel merged 3 commits into
mainfrom
hg/proxy-require-upstream

Conversation

@harshaneel

Copy link
Copy Markdown
Owner

Closes #14.

Summary

  • :proxy now requires LK_UPSTREAM and exits if it is unset. It defaulted to http://127.0.0.1:8080/v1, this container's own loopback, which can never hold a model server in an image with no inference engine. Forgetting the variable produced a container that started clean and then failed every request against an address that could not work. The proxy Dockerfile stage sets an internal LK_REQUIRE_UPSTREAM marker; startup exits when the upstream came from the default rather than a flag or the environment. Setting the loopback explicitly still works, which is what --network host needs.
  • The model-bundled images are untouched. Their entrypoint passes --upstream directly, so the source is a flag and they never see the marker. Verified: rebuilt bundled image has the marker unset, source flag, healthy in 28s.
  • The resolved upstream and its source are logged at startup, and the /health 503 body names the upstream it could not reach. Nothing previously reported the upstream, so a misconfiguration gave no signal at all.

Credential hardening (found by the three required reviews)

The reviews surfaced that an operator can put credentials in the upstream URL (http://user:pass@host, or ?api_key=...), and the URL was reaching clients and logs in several places. All are now closed:

  • Failed upstream calls returned the credential to the client. Every proxied route interpolated Go's transport error into the response, and Go redacts a password there but not a username, so a key placed in the username reached the caller. All six upstream-reach sites (OpenAI chat + models, Gemini generate + models list + model get + count-tokens, Anthropic) now report the redacted upstream instead of the raw error.
  • A malformed credential URL leaked through log.Fatal, because url.Parse's error echoes the raw input. server.New no longer wraps it.
  • RedactUpstream was incomplete. It now reduces any URL to scheme, host and path, dropping userinfo, query and fragment, and returns a constant for a hostless or unparseable URL (including the opaque http:user:pass@host form).
  • An explicit --upstream "" silently reverted to the loopback default. It is now rejected.

Deliberate, non-blocking disclosure

The /health body and the upstream-failure error messages name the upstream host and port (credentials stripped). Anyone who can reach the port learns the internal upstream hostname on failure. This is consistent with :proxy already authenticating none of its callers by design, is documented in the README's security section, and buys real diagnosability. If you would rather not disclose the host at all, say so and I will gate it.

Test Plan

  • make lint clean, all unit tests pass.
  • New table tests: flag/env/default precedence including explicit-default and explicit-empty; the require predicate; RedactUpstream against userinfo, password-only userinfo, query, fragment, opaque, scheme-relative and unparseable inputs.
  • TestUpstreamErrorResponsesDoNotLeakCredentials drives all eight failing routes against a key-as-username upstream and asserts the key never appears in the 502 body. It fails if the fix is reverted.
  • /health credential test asserts no password in the body.
  • End-to-end against a real llama.cpp (not a stub): a :proxy container pointed at a real llama-server round-trips OpenAI, Gemini, Anthropic, Gemini streaming (SSE), and Gemini count-tokens (the llama.cpp /tokenize path). First time the proxy has been exercised against a real engine rather than a stubbed upstream.
  • Live container checks: :proxy exits non-zero when LK_UPSTREAM is unset, starts when set, starts when the loopback is set explicitly; no credential substring in the startup log, any failing-route body, or /health when the URL carries one.

🤖 Generated with Claude Code

harshaneel and others added 3 commits August 5, 2026 00:22
The proxy image inherited an LK_UPSTREAM default of http://127.0.0.1:8080/v1.
That is this container's own loopback, and the image contains no inference
engine, so the default could never work there. Forgetting the variable produced
a container that started cleanly and then failed every request, and nothing
logged the upstream, so there was no signal pointing at the cause.

The proxy stage now sets LK_REQUIRE_UPSTREAM, and startup exits when the
upstream came from the default rather than from a flag or the environment.
Passing the loopback URL explicitly still works, which is what --network host
needs. The model-bundled images are unaffected because their entrypoint passes
--upstream directly.

Also logs the resolved upstream and its source, and names the unreachable
upstream in the /health 503 body. Both redact any userinfo in the URL, since an
operator can put credentials there and neither a log line nor an unauthenticated
response should carry them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The proxy redacted userinfo only on /health and the startup log, and only when
it was in the userinfo. Three paths still leaked:

- A failed upstream call returned Go's transport error to the client. Go
  redacts a password there but not a username, so a key placed in the username
  reached the caller. This affected every proxied route.
- A malformed credential URL reached the log through url.Parse's error, which
  echoes the raw input, so log.Fatal printed the password.
- Credentials in a query string, and the opaque http:user:pass@host form, were
  not stripped by RedactUpstream at all.

RedactUpstream now reduces a URL to scheme, host and path, dropping userinfo,
query and fragment, and returns a constant for a hostless or unparseable URL.
The upstream-failure responses report the redacted upstream rather than the raw
error, and server.New no longer wraps the parse error. An explicit empty
upstream is now rejected rather than silently replaced with the loopback
default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GET /v1beta/models and /v1beta/models/{id} reach upstream through
fetchUpstreamJSON, whose error wraps the URL, and both interpolated it raw
into the client response. They now report the redacted upstream like the other
four routes, and the leak test covers all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@harshaneel
harshaneel merged commit c9c4d1d into main Aug 5, 2026
3 checks passed
@harshaneel harshaneel mentioned this pull request Aug 6, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LK_UPSTREAM default is unreachable by construction in the :proxy image

1 participant