| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "io" |
| 5 | "log" |
| 6 | "net" |
| 7 | "net/http" |
| 8 | "os" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // Cookie scoping on the platform apps domain (discussion #217). |
| 16 | // |
| 17 | // Every tenant app lives at <app>.<sqid>.<apps domain>, and the apps domain |
| 18 | // (oncarlos.com on flagship) is not on the Public Suffix List — Carloku is |
| 19 | // below the list's user threshold. So to a browser every tenant shares ONE |
| 20 | // registrable domain, and an app that answers |
| 21 | // |
| 22 | // Set-Cookie: sid=x; Domain=oncarlos.com |
| 23 | // |
| 24 | // plants that cookie on every other account's apps too: cookie tossing, and |
| 25 | // with it session fixation across accounts. Until a PSL entry lands, the edge |
| 26 | // is the only party in a position to stop it, so it rewrites such a cookie to |
| 27 | // a host-only one on the way out. |
| 28 | // |
| 29 | // The rule, for a response to a request whose host is under the apps domain: |
| 30 | // |
| 31 | // - Domain equal to the request host passes. |
| 32 | // - Domain equal to, or under, the account's own namespace <sqid>.<apps |
| 33 | // domain> passes, so an account can still share cookies across its own |
| 34 | // apps. The sqid is the one the request host names in its own labels |
| 35 | // (release.HostAccountLabel), and only when the serving route's Account |
| 36 | // agrees with it: a route whose row names a different account — or none — |
| 37 | // gets no namespace at all, only its host. Strict on purpose; the only |
| 38 | // cost of a mismatch is a cookie scoped narrower than asked. |
| 39 | // - Anything else — the apps domain itself, another account's namespace, |
| 40 | // an unrelated domain — loses its Domain attribute and becomes |
| 41 | // host-only. The cookie is otherwise byte-for-byte what the app sent: |
| 42 | // name, value, every other attribute, their order and spelling. |
| 43 | // |
| 44 | // Hosts NOT under the apps domain — custom domains, and the platform's own |
| 45 | // console and gate hosts — are left alone: a custom domain is its owner's |
| 46 | // registrable domain, and nothing in the platform itself sets a Domain |
| 47 | // attribute (checked for #217), so there is nothing platform-side to break. |
| 48 | // |
| 49 | // Where it runs: the ModifyResponse hook every tenant reverse proxy already |
| 50 | // carries (responseModifier, below). That edits the upstream response's OWN |
| 51 | // header map before httputil.ReverseProxy copies it to the writer stack, so |
| 52 | // the client, the cache recorder above the compressor and the 101 upgrade |
| 53 | // path all see only the rewritten form — and no ResponseWriter wrapper with a |
| 54 | // shared header map is involved (AGENTS.md). Static and status routes have no |
| 55 | // hook: their headers are the edge's own and never carry a tenant cookie. |
| 56 | // |
| 57 | // Trailers: a Set-Cookie sent as an HTTP trailer arrives after |
| 58 | // ModifyResponse, so the body is wrapped and the trailer map rewritten when |
| 59 | // the proxy closes it — which it does before copying trailers out. Browsers |
| 60 | // ignore cookies in trailers; other clients need not. (A response carrying a |
| 61 | // Set-Cookie trailer is never cached: maybeStore's hasTrailerSetCookie.) |
| 62 | // |
| 63 | // Not covered: Set-Cookie on a 1xx informational response (103 Early Hints), |
| 64 | // which httputil forwards without ModifyResponse. Browsers do not apply |
| 65 | // cookies from 1xx responses. |
| 66 | |
| 67 | // appsDomainFromEnv reads PLATFORM_APPS_DOMAIN the way the rest of the edge |
| 68 | // does: trimmed, lowercased, no trailing dot. "" disables cookie scoping. |
| 69 | func appsDomainFromEnv() string { |
| 70 | return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")), ".")) |
| 71 | } |
| 72 | |
| 73 | // responseModifier is the ModifyResponse hook for a tenant route's reverse |
| 74 | // proxy: the Carlos-Purge channel (purgeModifier) and the cookie scoping |
| 75 | // above, in that order. Neither depends on the other. |
| 76 | func (e *Edge) responseModifier(rt registry.Route) func(*http.Response) error { |
| 77 | purge := e.purgeModifier(rt) |
| 78 | return func(resp *http.Response) error { |
| 79 | if err := purge(resp); err != nil { |
| 80 | return err |
| 81 | } |
| 82 | e.scopeCookies(rt, resp) |
| 83 | return nil |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // scopeCookies applies the cookie rule to resp's headers, and arranges for |
| 88 | // its trailers to get the same treatment once the body has been read. |
| 89 | func (e *Edge) scopeCookies(rt registry.Route, resp *http.Response) { |
| 90 | host := rt.Host |
| 91 | if resp.Request != nil && resp.Request.Host != "" { |
| 92 | // The host the visitor asked for: every tenant proxy's Rewrite sets |
| 93 | // Out.Host = In.Host, and a path-routed front host differs from the |
| 94 | // target route's own Host. The browser scopes cookies by this one. |
| 95 | host = resp.Request.Host |
| 96 | } |
| 97 | allowed, ok := e.cookieScope(rt, host) |
| 98 | if !ok { |
| 99 | return |
| 100 | } |
| 101 | if n := rewriteSetCookies(resp.Header, allowed); n > 0 { |
| 102 | e.noteCookieRewrite(rt, host) |
| 103 | } |
| 104 | // 101 Switching Protocols keeps its body: httputil needs it to be the |
| 105 | // raw io.ReadWriteCloser, and an upgraded stream has no trailers. Every |
| 106 | // other response is wrapped, announced trailers or not: net/http fills |
| 107 | // resp.Trailer with UNdeclared trailers too, on HTTP/1.1 and HTTP/2. |
| 108 | if resp.StatusCode != http.StatusSwitchingProtocols && resp.Body != nil { |
| 109 | resp.Body = &trailerCookieBody{ReadCloser: resp.Body, close: func() { |
| 110 | if n := rewriteSetCookies(resp.Trailer, allowed); n > 0 { |
| 111 | e.noteCookieRewrite(rt, host) |
| 112 | } |
| 113 | }} |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // cookieScope reports which Domain values a response for host may keep, or |
| 118 | // ok=false when host is not under the apps domain (nothing to enforce). |
| 119 | func (e *Edge) cookieScope(rt registry.Route, host string) (allowed func(string) bool, ok bool) { |
| 120 | apps := e.appsDomain |
| 121 | if apps == "" { |
| 122 | return nil, false |
| 123 | } |
| 124 | if h, _, err := net.SplitHostPort(host); err == nil { |
| 125 | host = h |
| 126 | } |
| 127 | host = strings.ToLower(strings.TrimSuffix(host, ".")) |
| 128 | if host != apps && !strings.HasSuffix(host, "."+apps) { |
| 129 | return nil, false |
| 130 | } |
| 131 | ns := "" |
| 132 | if sq := release.HostAccountLabel(host, apps); sq != "" && sq == rt.Account { |
| 133 | ns = sq + "." + apps |
| 134 | } |
| 135 | return func(d string) bool { |
| 136 | return d == host || (ns != "" && (d == ns || strings.HasSuffix(d, "."+ns))) |
| 137 | }, true |
| 138 | } |
| 139 | |
| 140 | // rewriteSetCookies drops the Domain attribute from every Set-Cookie line in |
| 141 | // h whose Domain is not allowed, in place, and returns how many it changed. |
| 142 | func rewriteSetCookies(h http.Header, allowed func(string) bool) int { |
| 143 | lines := h["Set-Cookie"] |
| 144 | n := 0 |
| 145 | for i, line := range lines { |
| 146 | if out, changed := scopeSetCookie(line, allowed); changed { |
| 147 | lines[i] = out |
| 148 | n++ |
| 149 | } |
| 150 | } |
| 151 | return n |
| 152 | } |
| 153 | |
| 154 | // scopeSetCookie returns line without its Domain attribute(s) when any of |
| 155 | // them names a domain allowed rejects, and line unchanged otherwise. |
| 156 | // |
| 157 | // Parsing follows RFC 6265 §5.2: attributes are the ";"-separated pieces |
| 158 | // after the name-value pair, names compare case-insensitively, whitespace |
| 159 | // around name and value is ignored, and a leading dot on the value is |
| 160 | // dropped. An empty Domain value is ignored by user agents (§5.2.3) — the |
| 161 | // cookie is host-only already — so it is left as sent. Browsers disagree on |
| 162 | // which of several Domain attributes wins, so one bad one strips them all; |
| 163 | // that can only narrow the cookie, never widen it. A trailing dot is trimmed |
| 164 | // before comparing, so "oncarlos.com." is judged as "oncarlos.com". |
| 165 | func scopeSetCookie(line string, allowed func(string) bool) (string, bool) { |
| 166 | parts := strings.Split(line, ";") |
| 167 | var domains []int |
| 168 | bad := false |
| 169 | for i := 1; i < len(parts); i++ { |
| 170 | name, value, _ := strings.Cut(parts[i], "=") |
| 171 | if !strings.EqualFold(strings.TrimSpace(name), "domain") { |
| 172 | continue |
| 173 | } |
| 174 | domains = append(domains, i) |
| 175 | d := strings.ToLower(strings.TrimSpace(value)) |
| 176 | d = strings.TrimPrefix(d, ".") |
| 177 | d = strings.TrimSuffix(d, ".") |
| 178 | if d == "" { |
| 179 | continue |
| 180 | } |
| 181 | if !allowed(d) { |
| 182 | bad = true |
| 183 | } |
| 184 | } |
| 185 | if !bad { |
| 186 | return line, false |
| 187 | } |
| 188 | kept := make([]string, 0, len(parts)-len(domains)) |
| 189 | next := 0 |
| 190 | for i, p := range parts { |
| 191 | if next < len(domains) && domains[next] == i { |
| 192 | next++ |
| 193 | continue |
| 194 | } |
| 195 | kept = append(kept, p) |
| 196 | } |
| 197 | return strings.Join(kept, ";"), true |
| 198 | } |
| 199 | |
| 200 | // noteCookieRewrite logs the first rewrite for each route this process |
| 201 | // serves, and never again for it: enough for an operator to see which app |
| 202 | // does it, without a line per response. Keyed by the route's own host — a |
| 203 | // value the box controls, so the set is bounded by the routing table. |
| 204 | func (e *Edge) noteCookieRewrite(rt registry.Route, host string) { |
| 205 | if _, seen := e.cookieLogged.LoadOrStore(rt.Host, true); seen { |
| 206 | return |
| 207 | } |
| 208 | acct := rt.Account |
| 209 | if acct == "" { |
| 210 | acct = "-" |
| 211 | } |
| 212 | 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)", |
| 213 | rt.Host, rt.App, acct, host) |
| 214 | } |
| 215 | |
| 216 | // trailerCookieBody runs close once, when the proxy closes the upstream |
| 217 | // body: after the last read, so the trailer map is complete, and before |
| 218 | // httputil copies the trailers to the client. |
| 219 | type trailerCookieBody struct { |
| 220 | io.ReadCloser |
| 221 | close func() |
| 222 | done bool |
| 223 | } |
| 224 | |
| 225 | func (b *trailerCookieBody) Close() error { |
| 226 | err := b.ReadCloser.Close() |
| 227 | if !b.done { |
| 228 | b.done = true |
| 229 | b.close() |
| 230 | } |
| 231 | return err |
| 232 | } |
| 233 | |