Skip to content

Propagate HTTP Basic Auth userinfo through the service proxy - #448

Open
mkitti wants to merge 1 commit into
JaneliaSciComp:mainfrom
mkitti:basic-auth-service-proxy
Open

Propagate HTTP Basic Auth userinfo through the service proxy#448
mkitti wants to merge 1 commit into
JaneliaSciComp:mainfrom
mkitti:basic-auth-service-proxy

Conversation

@mkitti

@mkitti mkitti commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • upstream_from_service_url previously rejected any published service_url containing HTTP Basic Auth userinfo (http://user:pass@node:port/) outright, since _UPSTREAM_RE requires a bare host:port netloc -- the resolve endpoint would 403 (surfaced as nginx's own 503 page) even though the backend was healthy and reachable.
  • build_proxied_service_url separately discarded any userinfo when rewriting to the browser-facing HTTPS URL, so even a permissive upstream check wouldn't have let the credential reach the browser.
  • This lets service_url carry userinfo end to end: stripped before it ever reaches nginx's proxy_pass target (which must never see credentials), and forwarded verbatim to the final browser-facing URL so opening the link auto-authenticates via the browser's native Basic-Auth-from-URL behavior -- the same UX apps using ${FG_SERVICE_TOKEN} as a query-string token already get.

Discovered via JaneliaSciComp/janelia-mojo-sandbox, whose ttyd-backed classroom terminal publishes exactly this shape of URL and hit a confusing 503 despite the backend being fully healthy the whole time.

What's in it

  • upstream_from_service_url: splits userinfo off the netloc (_split_userinfo) and discards it unconditionally before the existing _UPSTREAM_RE shape check runs against the host:port remainder only. Userinfo is never validated here -- it's inert once discarded -- and every existing SSRF/injection guarantee (safe-host check, zone/network allowlists, the strict regex) still applies to exactly the same string as before.
  • build_proxied_service_url: extracts userinfo the same way and reattaches it verbatim to the rewritten netloc (no split into username/password, no percent-decode/re-encode -- treated like path/query/fragment already are). Adds one narrow check, _is_safe_userinfo, rejecting control characters, since this function's output can end up in a browser context unlike the discarded value in upstream_from_service_url. On a bad userinfo, drops just the credential and still returns the rest of the URL rather than refusing to publish the job's link at all.
  • docs/ServiceProxy.md: documents userinfo as a supported alternative to ${FG_SERVICE_TOKEN} in Residual risks, with its browser-handling caveats relative to a query-string token.

Test plan

  • tests/test_service_proxy_urls.py: added userinfo-stripping and control-character cases for both functions; flipped the one existing case that asserted userinfo was rejected (it now resolves). All 72 cases pass.
  • tests/test_service_proxy.py: added an end-to-end roundtrip proving nginx's X-Fg-Upstream never carries credentials while the job-detail response does. All 26 cases pass.
  • Full tests/ run: no regressions outside these two files (27 unrelated pre-existing failures in this environment are pytest-asyncio/frontend-build gaps, not related to this change).

Known follow-up (deliberately not in this PR)

JobDB.service_url currently persists the raw service_url (including any userinfo) to the database, since resolve_service_upstream needs a fast, single indexed read and can't do a live worker RPC per proxied request. The credential riding along in that same column is incidental to the current single-string-column data model, not something the resolve path actually needs (it only ever extracts host:port). A follow-up PR could strip userinfo before the DB write in get_job and keep the raw, credential-bearing value only in that request's local scope for building the immediate browser-facing response -- so credentials would never touch the database or its backups at all.

🤖 Generated with Claude Code

A published service_url may carry userinfo (http://user:pass@node:port/)
for a service that enforces its own HTTP Basic Auth rather than a
query-string token. upstream_from_service_url previously rejected any
such URL outright -- its _UPSTREAM_RE regex requires a bare host:port
netloc, so userinfo failed the shape check and the resolve endpoint
403'd (surfaced to users as nginx's own 503 page), even though the
backend was healthy and reachable. build_proxied_service_url separately
discarded any userinfo when rewriting to the browser-facing HTTPS URL,
so even a permissive upstream check wouldn't have helped the credential
reach the browser.

- upstream_from_service_url: split userinfo off the netloc first
  (_split_userinfo) and discard it unconditionally before the existing
  _UPSTREAM_RE shape check runs against the host:port remainder only.
  Userinfo is never validated here -- it's inert once discarded, and
  every existing SSRF/injection guarantee (safe-host check, zone/network
  allowlists, the strict regex) still applies to exactly the same string
  as before.
- build_proxied_service_url: extract userinfo the same way and reattach
  it verbatim to the rewritten netloc (no split into username/password,
  no percent-decode/re-encode -- treated like path/query/fragment
  already are). Adds one narrow check, _is_safe_userinfo, rejecting
  control characters -- this function's output can end up in a browser
  context, unlike the discarded value in upstream_from_service_url. On a
  bad userinfo, drops just the credential and still returns the rest of
  the URL rather than refusing to publish the job's link at all.

Discovered via JaneliaSciComp/janelia-mojo-sandbox, whose ttyd-backed
classroom terminal published exactly this shape of URL and got a
confusing 503 despite the backend being fully healthy.

Tests: tests/test_service_proxy_urls.py gains userinfo-stripping and
control-character cases for both functions; tests/test_service_proxy.py
gains an end-to-end roundtrip proving nginx's X-Fg-Upstream never
carries credentials while the job-detail response does. All existing
cases pass unchanged except the one flipped case (userinfo used to be
an explicit rejection case, now resolves).

docs/ServiceProxy.md: documents userinfo as a supported alternative to
${FG_SERVICE_TOKEN} in Residual risks, with its browser-handling caveats
relative to a query-string token.
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.

1 participant