Skip to content

[Bug]: Umami analytics reverse-proxy forwards first-party request cookies to the upstream, breaking its "cookieless / strips cookies" guarantee #597

Description

@philluiz2323

Summary

handleAnalyticsProxy (the first-party reverse proxy for the self-hosted Umami
tracker, added in #595) strips the response set-cookie header but does
not strip the request cookie header. So the browser's first-party
cookies for the proxy origin (gittensory.aethereal.dev) — including any
session / auth / OAuth-state cookie whose path matches /stats/* — are forwarded
verbatim to the Umami upstream (tasty.aethereal.dev) on every
GET /stats/script.js and POST /stats/api/send.

This directly contradicts the design as documented in the same change.

The stated invariant

The #595 commit message:

The proxy is a strict path allowlist … strips cookies/hop-by-hop headers,
forwards the real client IP for geolocation, and fails closed (502)…

and the file header comment ("cookieless analytics"). The response side honors
it; the request side does not.

Evidence

// apps/gittensory-ui/src/lib/analytics-proxy.ts:26
const STRIP_REQUEST_HEADERS = new Set([
  "host", "connection", "keep-alive", "transfer-encoding", "upgrade",
  "cf-connecting-ip", "cf-ipcountry", "cf-ray", "cf-visitor",
  "x-forwarded-host", "x-forwarded-proto",
]);                                   // <-- no "cookie"

// :43  (response side, correctly stripped)
const STRIP_RESPONSE_HEADERS = new Set([
  "connection", "keep-alive", "transfer-encoding", "content-encoding",
  "content-length",
  "set-cookie", // cookieless analytics: never relay cookies to the client
]);

// :71  forwards every request header except the strip-list -> cookie is forwarded
const headers = new Headers();
request.headers.forEach((value, key) => {
  if (!STRIP_REQUEST_HEADERS.has(key.toLowerCase())) headers.set(key, value);
});

Reachability

Live in production. apps/gittensory-ui/src/server.ts runs the proxy ahead of
SSR for every request:

const analytics = await handleAnalyticsProxy(request);
if (analytics) return analytics;

When a logged-in visitor loads any page, the browser fetches
/stats/script.js and the tracker POSTs /stats/api/send — both same-origin
requests that the browser decorates with the origin's cookies. Those cookies are
then forwarded to tasty.aethereal.dev.

gittensory.aethereal.dev does set first-party cookies (e.g. the GitHub OAuth
state cookie via buildGitHubOAuthStateCookie, and the authenticated session),
so this is not hypothetical.

Impact

  • Privacy guarantee broken. Umami is deployed precisely for "cookieless, no
    PII" analytics; forwarding the visitor's cookies to the analytics host
    undermines that and lands those cookies in the analytics server's request
    logs.
  • Expanded credential exposure. Session / auth cookies now traverse to, and
    may be logged by, a second host. A compromise or log leak of the analytics
    host would expose live gittensory session cookies — a surface that should
    not exist for an analytics beacon.

Test status

Not locked in — there is no test for handleAnalyticsProxy (no analytics-proxy
test file). No test asserts the request header strip-list.

Suggested fix

Strip cookie from forwarded requests, mirroring the response-side set-cookie
strip:

const STRIP_REQUEST_HEADERS = new Set([
  "host", "connection", "keep-alive", "transfer-encoding", "upgrade",
  "cookie", // cookieless analytics: never forward first-party cookies upstream
  "cf-connecting-ip", "cf-ipcountry", "cf-ray", "cf-visitor",
  "x-forwarded-host", "x-forwarded-proto",
]);

Add a unit test for handleAnalyticsProxy asserting that a request with a
Cookie header reaches the upstream fetch with no cookie header (and,
for parity, that set-cookie is absent from the returned response).

Secondary note (same function, lower severity)

x-forwarded-for is not in the strip-list, and the proxy appends the real
cf-connecting-ip after any client-supplied x-forwarded-for
(existing ? ${existing}, ${clientIp}`` at :78–79). A client can therefore
prepend a spoofed IP that Umami may treat as the visitor's address for
geolocation. Setting x-forwarded-for to the trusted `cf-connecting-ip` only
(overwrite, not append) closes this and is consistent with the cookie fix.

Distinct from prior reports

Not a duplicate of #461 (that restricted the public GitHub stats API proxy
to known repos). This is the UI Umami tracker reverse-proxy and a
request-header forwarding/strip defect, in newly-added #595 code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    slopAI slop and/or attempts to game additional points via manipulation or alt profiles.

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions