Skip to content

Dashboard session cookie is scoped to .<baseDomain>, so app subdomains receive it #6

Description

@neuromaxer

Summary

The dashboard session cookie is scoped to .<baseDomain>, so every app subdomain receives it. App content is LLM-authored code running on a hostname that shares the cookie's domain scope, which makes the browser attach a dashboard credential to requests that app can cause.

Found while designing an equivalent preview feature in a downstream consumer (openorange), where a review rule already says app hosts must never see the dashboard cookie — which prompted checking how appx does it.

Where

internal/server/server.go:66:

if cfg.BaseDomain != "" && net.ParseIP(cfg.BaseDomain) == nil {
    a.Cookie.Domain = "." + cfg.BaseDomain
}

A leading-dot Domain means the cookie is sent to the domain and all subdomains. Since app URLs are <name>.<baseDomain> and <name>-dev.<baseDomain> (router.go:145-152), those hosts are in scope.

What is already right

Worth stating, because the current code is clearly not careless:

  • The cookie is HttpOnly (auth.go:71, auth_handlers.go:69), so app JavaScript cannot read it via document.cookie.
  • The subdomain proxy strips it before forwarding upstream (router.go:196, req.Header.Del("Cookie")), so the app's own server never receives it.
  • SameSite=Lax limits cross-site sends, and auth runs before the proxy.
  • The app is reached through an authenticated proxy at all, rather than being exposed directly.

So this is not "the cookie leaks to the app process". It is narrower and subtler.

The residual risk

The browser still attaches the cookie to any request to <app>.<baseDomain>, and HttpOnly does not prevent that — it only stops JS from reading the value. Consequences:

  1. The app's origin can make authenticated requests to its own host. fetch('/api/...', {credentials: 'include'}) from app code goes to <app>.<baseDomain>/api/..., carrying the session. Whether that reaches anything sensitive depends on what the subdomain router exposes on app hosts, but the request is authenticated by construction.
  2. SameSite=Lax does not help here. <app>.<baseDomain> and <baseDomain> are the same site (same registrable domain), so Lax imposes no restriction between them. Lax protects against unrelated third-party sites, not sibling subdomains.
  3. Cookie-shadowing / jar manipulation. Sibling subdomains can write cookies on the parent domain scope. An app-served page can set a Domain=.<baseDomain> cookie with the session cookie's name, and cookie precedence rules make which value wins depend on path/creation order — a known session-fixation vector, and it does not require reading the original value.
  4. The blast radius scales with the feature. Today app hosts serve only proxied app content. The moment anything else is mounted on an app host — a status endpoint, an asset route, a websocket — it inherits an authenticated session it never asked for.

The actor here matters: apps are built by an LLM from user prose. Not malicious by design, but "make my app call an API with the user's session" is a plausible prompt away, and the confused-deputy shape is exactly what cookie scoping is supposed to prevent.

Suggested direction

The general principle is that a host serving untrusted content should not be in the credential's scope at all. Options, roughly in order of preference:

  1. Host-only cookie for the dashboard — drop the Domain attribute so the cookie is scoped to the dashboard host alone. App subdomains then receive nothing. This is the clean fix; it requires the dashboard and apps not to depend on a shared cookie, which — given the proxy already strips it — looks true today. Worth confirming the login flow does not rely on the cookie surviving a subdomain hop.
  2. Serve apps from a separate registrable domain (e.g. <name>.appx-apps.example rather than <name>.example). Different site, so no shared cookie scope and SameSite becomes meaningful. Costs a second domain and cert.
  3. App-scoped tokens — if app hosts ever need authorization of their own, give them a short-lived token bound to one app, rather than the dashboard session.
  4. __Host- prefix for the session cookie, which browsers enforce as host-only and path / with Secure. Mechanically enforces (1) and blocks the shadowing in (3) above, since __Host- cookies cannot be set with a Domain.

(1) plus (4) together would close both the attachment and the shadowing vectors with no infrastructure change.

Reproducing

# with appx running on a hostname (not an IP)
curl -si https://<baseDomain>/api/auth/login -d '...' | grep -i set-cookie
#   Set-Cookie: appx_session=...; Domain=.<baseDomain>; HttpOnly; Secure; SameSite=Lax
#                                 ^^^^^^^^^^^^^^^^^^^ sent to every subdomain

Then load any app subdomain with devtools open: the request to <name>-dev.<baseDomain> carries appx_session.

Note on the comment at that line

The existing comment explains why Domain is only set for hostnames and not IPs (RFC 6265 rejection breaking login), which is correct and worth keeping. It does not address whether the subdomain scope is desirable — that appears to be an unexamined consequence of needing subdomain routing to work, rather than a deliberate decision, which is why I am raising it rather than assuming it is intended.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions