package edge import ( "io" "log" "net" "net/http" "os" "strings" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // Cookie scoping on the platform apps domain (discussion #217). // // Every tenant app lives at .., and the apps domain // (oncarlos.com on flagship) is not on the Public Suffix List — Carloku is // below the list's user threshold. So to a browser every tenant shares ONE // registrable domain, and an app that answers // // Set-Cookie: sid=x; Domain=oncarlos.com // // plants that cookie on every other account's apps too: cookie tossing, and // with it session fixation across accounts. Until a PSL entry lands, the edge // is the only party in a position to stop it, so it rewrites such a cookie to // a host-only one on the way out. // // The rule, for a response to a request whose host is under the apps domain: // // - Domain equal to the request host passes. // - Domain equal to, or under, the account's own namespace . passes, so an account can still share cookies across its own // apps. The sqid is the one the request host names in its own labels // (release.HostAccountLabel), and only when the serving route's Account // agrees with it: a route whose row names a different account — or none — // gets no namespace at all, only its host. Strict on purpose; the only // cost of a mismatch is a cookie scoped narrower than asked. // - Anything else — the apps domain itself, another account's namespace, // an unrelated domain — loses its Domain attribute and becomes // host-only. The cookie is otherwise byte-for-byte what the app sent: // name, value, every other attribute, their order and spelling. // // Hosts NOT under the apps domain — custom domains, and the platform's own // console and gate hosts — are left alone: a custom domain is its owner's // registrable domain, and nothing in the platform itself sets a Domain // attribute (checked for #217), so there is nothing platform-side to break. // // Where it runs: the ModifyResponse hook every tenant reverse proxy already // carries (responseModifier, below). That edits the upstream response's OWN // header map before httputil.ReverseProxy copies it to the writer stack, so // the client, the cache recorder above the compressor and the 101 upgrade // path all see only the rewritten form — and no ResponseWriter wrapper with a // shared header map is involved (AGENTS.md). Static and status routes have no // hook: their headers are the edge's own and never carry a tenant cookie. // // Trailers: a Set-Cookie sent as an HTTP trailer arrives after // ModifyResponse, so the body is wrapped and the trailer map rewritten when // the proxy closes it — which it does before copying trailers out. Browsers // ignore cookies in trailers; other clients need not. (A response carrying a // Set-Cookie trailer is never cached: maybeStore's hasTrailerSetCookie.) // // Not covered: Set-Cookie on a 1xx informational response (103 Early Hints), // which httputil forwards without ModifyResponse. Browsers do not apply // cookies from 1xx responses. // appsDomainFromEnv reads PLATFORM_APPS_DOMAIN the way the rest of the edge // does: trimmed, lowercased, no trailing dot. "" disables cookie scoping. func appsDomainFromEnv() string { return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")), ".")) } // responseModifier is the ModifyResponse hook for a tenant route's reverse // proxy: the Carlos-Purge channel (purgeModifier) and the cookie scoping // above, in that order. Neither depends on the other. func (e *Edge) responseModifier(rt registry.Route) func(*http.Response) error { purge := e.purgeModifier(rt) return func(resp *http.Response) error { if err := purge(resp); err != nil { return err } e.scopeCookies(rt, resp) return nil } } // scopeCookies applies the cookie rule to resp's headers, and arranges for // its trailers to get the same treatment once the body has been read. func (e *Edge) scopeCookies(rt registry.Route, resp *http.Response) { host := rt.Host if resp.Request != nil && resp.Request.Host != "" { // The host the visitor asked for: every tenant proxy's Rewrite sets // Out.Host = In.Host, and a path-routed front host differs from the // target route's own Host. The browser scopes cookies by this one. host = resp.Request.Host } allowed, ok := e.cookieScope(rt, host) if !ok { return } if n := rewriteSetCookies(resp.Header, allowed); n > 0 { e.noteCookieRewrite(rt, host) } // 101 Switching Protocols keeps its body: httputil needs it to be the // raw io.ReadWriteCloser, and an upgraded stream has no trailers. Every // other response is wrapped, announced trailers or not: net/http fills // resp.Trailer with UNdeclared trailers too, on HTTP/1.1 and HTTP/2. if resp.StatusCode != http.StatusSwitchingProtocols && resp.Body != nil { resp.Body = &trailerCookieBody{ReadCloser: resp.Body, close: func() { if n := rewriteSetCookies(resp.Trailer, allowed); n > 0 { e.noteCookieRewrite(rt, host) } }} } } // cookieScope reports which Domain values a response for host may keep, or // ok=false when host is not under the apps domain (nothing to enforce). func (e *Edge) cookieScope(rt registry.Route, host string) (allowed func(string) bool, ok bool) { apps := e.appsDomain if apps == "" { return nil, false } if h, _, err := net.SplitHostPort(host); err == nil { host = h } host = strings.ToLower(strings.TrimSuffix(host, ".")) if host != apps && !strings.HasSuffix(host, "."+apps) { return nil, false } ns := "" if sq := release.HostAccountLabel(host, apps); sq != "" && sq == rt.Account { ns = sq + "." + apps } return func(d string) bool { return d == host || (ns != "" && (d == ns || strings.HasSuffix(d, "."+ns))) }, true } // rewriteSetCookies drops the Domain attribute from every Set-Cookie line in // h whose Domain is not allowed, in place, and returns how many it changed. func rewriteSetCookies(h http.Header, allowed func(string) bool) int { lines := h["Set-Cookie"] n := 0 for i, line := range lines { if out, changed := scopeSetCookie(line, allowed); changed { lines[i] = out n++ } } return n } // scopeSetCookie returns line without its Domain attribute(s) when any of // them names a domain allowed rejects, and line unchanged otherwise. // // Parsing follows RFC 6265 §5.2: attributes are the ";"-separated pieces // after the name-value pair, names compare case-insensitively, whitespace // around name and value is ignored, and a leading dot on the value is // dropped. An empty Domain value is ignored by user agents (§5.2.3) — the // cookie is host-only already — so it is left as sent. Browsers disagree on // which of several Domain attributes wins, so one bad one strips them all; // that can only narrow the cookie, never widen it. A trailing dot is trimmed // before comparing, so "oncarlos.com." is judged as "oncarlos.com". func scopeSetCookie(line string, allowed func(string) bool) (string, bool) { parts := strings.Split(line, ";") var domains []int bad := false for i := 1; i < len(parts); i++ { name, value, _ := strings.Cut(parts[i], "=") if !strings.EqualFold(strings.TrimSpace(name), "domain") { continue } domains = append(domains, i) d := strings.ToLower(strings.TrimSpace(value)) d = strings.TrimPrefix(d, ".") d = strings.TrimSuffix(d, ".") if d == "" { continue } if !allowed(d) { bad = true } } if !bad { return line, false } kept := make([]string, 0, len(parts)-len(domains)) next := 0 for i, p := range parts { if next < len(domains) && domains[next] == i { next++ continue } kept = append(kept, p) } return strings.Join(kept, ";"), true } // noteCookieRewrite logs the first rewrite for each route this process // serves, and never again for it: enough for an operator to see which app // does it, without a line per response. Keyed by the route's own host — a // value the box controls, so the set is bounded by the routing table. func (e *Edge) noteCookieRewrite(rt registry.Route, host string) { if _, seen := e.cookieLogged.LoadOrStore(rt.Host, true); seen { return } acct := rt.Account if acct == "" { acct = "-" } log.Printf("edge: route %s (app %q, account %s): a Set-Cookie on %s named a Domain outside the app's own account; removed it so the cookie is host-only (logged once per route)", rt.Host, rt.App, acct, host) } // trailerCookieBody runs close once, when the proxy closes the upstream // body: after the last read, so the trailer map is complete, and before // httputil copies the trailers to the client. type trailerCookieBody struct { io.ReadCloser close func() done bool } func (b *trailerCookieBody) Close() error { err := b.ReadCloser.Close() if !b.done { b.done = true b.close() } return err }