Skip to content

fix(viewer): restore a working auth configuration for UI and ingest - #112

Merged
rdasilveiracabral merged 1 commit into
mainfrom
fix/viewer-auth
Aug 14, 2026
Merged

fix(viewer): restore a working auth configuration for UI and ingest#112
rdasilveiracabral merged 1 commit into
mainfrom
fix/viewer-auth

Conversation

@sklinglernv

@sklinglernv sklinglernv commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

ba42b28 added _require_viewer_authorization, but the token replaces the loopback allowance instead of supplementing it. There is currently no configuration in which both the browser UI and remote ingest work:

UI (browser) Agents over IP/DNS
token unset works on loopback only 403
token set 401 everywhere, localhost included works

The second row is the surprising one: setting a token skips the loopback branch entirely, and the SPA never sends an Authorization header — there is no code in the frontend that could — so the UI locks itself out even from localhost.

Change

Auth is now an OR over three independent credentials (main.py): loopback origin, session cookie, or Authorization: Bearer. Local agents keep ingesting with zero configuration; remote exporters keep using the bearer token.

Browser bootstrap. ?token= is exchanged for an HttpOnly; SameSite=Lax cookie and stripped from the URL via redirect, so only the one-time share link carries the secret — trace deep links shared afterwards are plain URLs. Lax is deliberate: it still rides top-level GET navigations, so a shared link works on click, while withholding the cookie from cross-site POSTs.

start-dev prints the share link when a token is set. The token is percent-encoded — a # in it would otherwise begin the URL fragment, so the browser sends only the part before it and the bootstrap fails with a confusing 401. NOOA_VIEWER_PUBLIC_HOST overrides the printed hostname, because gethostname()/getfqdn()/AI_CANONNAME all return the short name when the qualified one comes from a DNS search domain.

The UI now says when it is unauthorized. Pages caught the error and rendered their empty state, so an unauthorized viewer looked identical to one with no data. assertOk tags 401/403 distinctly and a banner explains the fix.

DNS-rebinding guard, on by default

Making loopback always sufficient reopens a hole that ba42b28 had incidentally closed: a malicious page can publish a short-TTL record, then re-answer DNS with 127.0.0.1. The browser connects here still believing the origin is the attacker's, so same-origin policy lets their script read the response — and the request arrives from loopback, needing no credential. Binding to localhost does not help; loopback is the target. The payoff is every prompt and completion, plus POST /api/playground/inference on your server-side credentials.

The guard rejects browser requests whose Host is not one of ours. Two design points, both learned the hard way:

  • Only browser-shaped requests are checked — those carrying Sec-Fetch-* or Origin. Rebinding is inherently a browser attack, so exempting programmatic clients costs no security and makes it structurally impossible for this check to reject a span export. An earlier revision applied it to every request and silently broke trace ingest.
  • Accepted hosts are a family, not a list: localhost, any IP literal (rebinding needs a name whose DNS the attacker controls), gethostname(), and <gethostname()>.<anything>. That last rule matters — gethostname() returns only the leading label when the qualified name comes from a DNS search domain, so exact comparison rejected real traffic. NOOA_VIEWER_ALLOWED_HOSTS adds CNAMEs/proxies; * disables.

The effective policy is logged at startup so a rejection is diagnosable from the log alone.

Residual gaps, stated plainly: browsers older than Chrome 76 / Firefox 90 / Safari 16.4 do not send Sec-Fetch-* and would skip the check, and header presence is a heuristic for "is a browser", not a guarantee. This is defense in depth, not a proof.

For the reviewer

This deletes test_api_routes_require_configured_bearer_token, which asserted loopback gets 401 once a token is set. That behaviour is the bug; the replacement asserts the opposite. Fifteen tests cover the new contract.

test_cors_rejects_unconfigured_origin now pins Host: localhost — otherwise the rebinding guard would reject it first and the assertion would hold for the wrong reason, silently no longer testing CORS.

Verification

End-to-end against a live viewer, from a non-loopback address over the DNS-qualified hostname:

1. API before bootstrap       -> 401
2. click share link (?token=) -> 303 -> /  (token stripped)
3. API with cookie            -> 200
4. deep link, no token in URL -> 200
5. loopback, no creds         -> 200

And the rebinding guard, authenticated so only the Host differs:

exporter POST via FQDN (no browser headers) -> 200
browser GET via FQDN                        -> 200
browser GET via LAN IP literal              -> 200
REBOUND browser fetch, Host: evil.com       -> 400

A quickstart agent run over the external route landed its spans. 347 tests pass; committed dist/ reproduces byte-identically from a clean npm ci build.

Comment thread src/nooa/viewer/main.py
the UI out entirely.
3. ``Authorization: Bearer`` — remote programmatic clients (exporters).

An earlier revision made the token *replace* the loopback allowance, which

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove historical comments (everywhere in this PR)

ba42b28 added `_require_viewer_authorization`, but made the token *replace*
the loopback allowance rather than supplement it. That left no configuration
in which both the browser UI and remote ingest worked:

  - token unset -> remote clients get 403
  - token set   -> loopback bypass is skipped, and since the SPA never sends
                   an Authorization header, the UI gets 401 from everywhere,
                   localhost included

Make the check an OR over three independent credentials: loopback origin,
session cookie, or bearer token. Local agents keep ingesting with no config;
remote exporters keep using the bearer token.

For the browser, add the bootstrap it was missing: `?token=` is exchanged for
an HttpOnly SameSite=Lax cookie and stripped from the URL by redirect, so only
the one-time share link carries the secret and trace deep links shared later
do not. SameSite=Lax is deliberate — it still rides top-level GET navigations,
so a shared link works on click, while withholding the cookie from cross-site
POSTs.

`start-dev` prints that share link when a token is configured. The token is
percent-encoded: a '#' in it would otherwise start the URL fragment, so the
browser would send only the part before it and the bootstrap would fail with a
confusing 401. NOOA_VIEWER_PUBLIC_HOST overrides the printed hostname, since
gethostname()/getfqdn()/AI_CANONNAME all return the short name when the
qualified one comes from a DNS search domain, and a short name generally does
not resolve from a colleague's machine.

Also surface auth failures in the UI. Pages caught the error and rendered
their empty state, so an unauthorized viewer looked exactly like a viewer with
no data. `assertOk` tags 401/403 distinctly and a banner explains the fix.

Adds an opt-in Host allowlist (NOOA_VIEWER_ALLOWED_HOSTS) for DNS rebinding,
which is reachable via the loopback allowance. It is off by default: a version
that derived the allowlist from gethostname()/getaddrinfo() rejected real
traffic, 400-ing every span POST from a client using the DNS-qualified name.

Note for review: this deletes test_api_routes_require_configured_bearer_token,
which asserted that loopback gets 401 once a token is set. That behaviour is
the bug being fixed; the replacement asserts the opposite, alongside eight
other cases covering the cookie bootstrap and the Host check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Severin Klingler <sklingler@nvidia.com>
@rdasilveiracabral
rdasilveiracabral merged commit 4404329 into main Aug 14, 2026
6 checks passed
@rdasilveiracabral
rdasilveiracabral deleted the fix/viewer-auth branch August 14, 2026 17:04
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.

2 participants