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:
- 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.
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.
- 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.
- 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:
- 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.
- 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.
- 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.
__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.
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:A leading-dot
Domainmeans 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:
HttpOnly(auth.go:71,auth_handlers.go:69), so app JavaScript cannot read it viadocument.cookie.router.go:196,req.Header.Del("Cookie")), so the app's own server never receives it.SameSite=Laxlimits cross-site sends, and auth runs before the proxy.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>, andHttpOnlydoes not prevent that — it only stops JS from reading the value. Consequences: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.SameSite=Laxdoes 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.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.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:
Domainattribute 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.<name>.appx-apps.examplerather than<name>.example). Different site, so no shared cookie scope andSameSitebecomes meaningful. Costs a second domain and cert.__Host-prefix for the session cookie, which browsers enforce as host-only and path/withSecure. Mechanically enforces (1) and blocks the shadowing in (3) above, since__Host-cookies cannot be set with aDomain.(1) plus (4) together would close both the attachment and the shadowing vectors with no infrastructure change.
Reproducing
Then load any app subdomain with devtools open: the request to
<name>-dev.<baseDomain>carriesappx_session.Note on the comment at that line
The existing comment explains why
Domainis 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.