carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
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.
69func 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.
76func (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.
89func (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).
119func (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.
142func 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".
165func 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.
204func (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.
219type trailerCookieBody struct {
220 io.ReadCloser
221 close func()
222 done bool
223}
224
225func (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