| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/http" |
| 6 | "net/http/httputil" |
| 7 | "net/url" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // PurgeHeader is the response header an app sets to invalidate its OWN |
| 16 | // cached entries — the response-header purge channel from |
| 17 | // docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md §4(e)/§6.1, |
| 18 | // the follow-on to the cache spec's §3 "local purge channel". |
| 19 | // |
| 20 | // The app signals the purge on the response it is already returning from the |
| 21 | // write: |
| 22 | // |
| 23 | // HTTP/1.1 302 Found |
| 24 | // Location: /events/42 |
| 25 | // Carlos-Purge: /events/42, /events/42?lang=fr |
| 26 | // |
| 27 | // Why a header rather than a socket, a port, or a token: the tenant-isolation |
| 28 | // invariant ("a tenant may purge only its OWN entries") holds here by |
| 29 | // CONSTRUCTION, not by permission bookkeeping. The host is the one the edge |
| 30 | // already resolved this request to out of its own routing table, and the only |
| 31 | // way to name a different one — Carlos-Purge-Host, below — is resolved against |
| 32 | // that same table and refused unless it lands on the same app AND the same |
| 33 | // account, so there remains no field in which one TENANT could address |
| 34 | // another. There is also nothing to reach: |
| 35 | // no socket, no port, no path, no secret, so "which tenants can reach this |
| 36 | // channel" has no answer beyond "each tenant's own responses." That closes |
| 37 | // the reachability half of the invariant, which the exec-backed control |
| 38 | // socket (purge.go) explicitly does NOT — see its same-uid sibling note — and |
| 39 | // which a DynamicUser unit tenant could never reach at all. |
| 40 | // |
| 41 | // This is the Surrogate-Key/Surrogate-Control (Fastly, Varnish) and |
| 42 | // X-Accel-* (nginx) family: an upstream steering its own cache through |
| 43 | // response metadata. |
| 44 | // |
| 45 | // It is an ADDITIONAL channel, not a replacement: CARLOS_CONTROL_SOCKET and |
| 46 | // ListenPurge are unchanged for exec-backed instances and the two coexist. |
| 47 | // |
| 48 | // # The contract, precisely |
| 49 | // |
| 50 | // - RESPONSE ONLY, and only the FINAL response. The edge reads and strips |
| 51 | // the header off the final response's header block. 1xx informational |
| 52 | // responses (103 Early Hints) and HTTP trailers are neither honoured nor |
| 53 | // stripped — see purgeModifier's note. An app must declare its purges in |
| 54 | // final-response headers, and nowhere else. |
| 55 | // - An app must NEVER name Carlos-Purge in the response's Connection |
| 56 | // header. That marks it hop-by-hop, and net/http/httputil deletes |
| 57 | // hop-by-hop headers BEFORE ModifyResponse runs — the declaration is |
| 58 | // silently eaten and nothing is purged. Pinned by |
| 59 | // TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook. |
| 60 | // - It is never read from a REQUEST, and is scrubbed off every proxied |
| 61 | // request before the app sees it (scrubInboundPurgeHeader). |
| 62 | const PurgeHeader = "Carlos-Purge" |
| 63 | |
| 64 | // PurgeHostHeader lets one response redirect its whole Carlos-Purge |
| 65 | // declaration at one or more SIBLING routes of the same app and account — |
| 66 | // spec §6.4, widened from one host to a list by amadan carlos/platform#173. |
| 67 | // |
| 68 | // HTTP/1.1 302 Found |
| 69 | // Carlos-Purge-Host: acme.go.tito.io, tickets.acme.example |
| 70 | // Carlos-Purge: /events/42 |
| 71 | // |
| 72 | // # Why a list (#173) |
| 73 | // |
| 74 | // A Tito account with custom domains serves its public pages on several hosts |
| 75 | // at once: its canonical {slug}.go.tito.io plus any domain wired to one event |
| 76 | // or series. With one host per declaration a write could only ever reach the |
| 77 | // canonical one, so pages under the others stayed stale for their full |
| 78 | // s-maxage, and the app's only defence was to stop caching them at all. A |
| 79 | // list lets one response reach every host of its own tenancy. Each entry is |
| 80 | // checked exactly as the single host always was; the list adds reach, never a |
| 81 | // weaker check. |
| 82 | // |
| 83 | // # Why the host-confinement above had to be relaxed, and along which seam |
| 84 | // |
| 85 | // Tito's production topology splits one app across two route groups: admin |
| 86 | // UIs and Stripe webhooks ride the root domain (go.tito.io) while the cached |
| 87 | // public pages live on per-tenant routes ({slug}.go.tito.io). Every write |
| 88 | // therefore arrives on a host where nothing relevant is cached, so a purge |
| 89 | // confined to r.Host lands on an empty shelf and the edited page stays stale |
| 90 | // for its full TTL — which is precisely the product promise ("admin changes |
| 91 | // show up immediately") this cache was allowed to exist under. |
| 92 | // |
| 93 | // The seam relaxed is APP + ACCOUNT, both, and it is the boundary the rest of |
| 94 | // the platform already treats as one tenant: the same app, deployed by the |
| 95 | // same account, on the same box. Crucially the header still names no host the |
| 96 | // EDGE does not already serve — the value is resolved against the edge's own |
| 97 | // routing table and refused unless the route it names carries the same App and |
| 98 | // the same Account as the route being served. So the invariant does not become |
| 99 | // permission bookkeeping; it stays structural, just with "its own host" widened |
| 100 | // to "its own app's hosts on this box." |
| 101 | // |
| 102 | // # The contract, precisely |
| 103 | // |
| 104 | // - A LIST of bare hostnames, comma-joined or on several lines (RFC 9110 |
| 105 | // §5.3 — both spellings are the same declaration). Duplicates collapse. |
| 106 | // At most maxPurgeHosts distinct hosts, and at most maxPurgeHeaderBytes |
| 107 | // across every line; beyond either the declaration is malformed. |
| 108 | // - It REPLACES the serving host, it does not add to it. A response that |
| 109 | // wants its own host purged as well lists it, which is always allowed. |
| 110 | // - ALL-OR-NOTHING on shape and tenancy. A malformed entry, an entry of a |
| 111 | // different app or a different account, an over-long list, or a serving |
| 112 | // route that changed while the request was in flight discards the ENTIRE |
| 113 | // purge declaration for this response, every host and every target. |
| 114 | // There is deliberately no fallback to the serving host, and no "honour |
| 115 | // the good entries": a half-honoured purge is worse than a refused one, |
| 116 | // because it clears something and so looks like it worked. |
| 117 | // - An entry this edge does not SERVE — no ready route, exact or wildcard, |
| 118 | // that it would route the name to — is |
| 119 | // skipped (and logged), and the rest of the list is honoured. That is the |
| 120 | // one exception, and it is not a half-honoured purge: an unserved host has |
| 121 | // nothing this edge would serve from cache, and it cannot be checked |
| 122 | // against a tenancy either, so skipping it neither misses a stale page nor |
| 123 | // reaches outside the tenant. It matters because an app lists its hosts |
| 124 | // from its own records, which do not say which box serves which host: a |
| 125 | // domain still waiting on DNS, or one placed on another box, must not |
| 126 | // cost the whole purge. A declaration whose every entry is unserved |
| 127 | // purges nothing, exactly as a lone unknown host always did. |
| 128 | // - It redirects EVERY target in the same response, "*" and the cap-degrade |
| 129 | // path included, to EVERY accepted host. The override decides WHICH |
| 130 | // hosts; §6.1 still decides which entries of each. |
| 131 | // - Resolved against the CURRENT route table at response time (e.current()), |
| 132 | // never a snapshot taken when the proxy was built — a sibling added by the |
| 133 | // very reload that rebuilt this proxy must still validate. |
| 134 | // - Stripped from the response UNCONDITIONALLY, in the same place |
| 135 | // Carlos-Purge is, including when the edge has no cache at all. |
| 136 | // - Never read from a request, and scrubbed off every proxied request |
| 137 | // inbound (scrubInboundPurgeHeader). Reflection matters here for exactly |
| 138 | // the reason it matters for Carlos-Purge: a cross-host purge within one |
| 139 | // app is still a purge an outsider must not be able to trigger. |
| 140 | // - An app must NEVER name EITHER purge header in the response's Connection |
| 141 | // header, and naming this one is the worse of the two. Connection marks a |
| 142 | // header hop-by-hop, and net/http/httputil deletes hop-by-hop headers |
| 143 | // BEFORE ModifyResponse runs, so the edge never sees it. For Carlos-Purge |
| 144 | // that silently loses the declaration and nothing is purged. For |
| 145 | // Carlos-Purge-Host the Carlos-Purge line SURVIVES while its redirect is |
| 146 | // deleted, so the declaration is silently RETARGETED at the serving host — |
| 147 | // a half-honoured purge of exactly the kind the all-or-nothing rule above |
| 148 | // exists to prevent, except that the edge cannot refuse it, because the |
| 149 | // evidence was destroyed before this package was called. It clears the |
| 150 | // wrong host and looks like it worked. Pinned by |
| 151 | // TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost. |
| 152 | // |
| 153 | // # Non-goal, permanently |
| 154 | // |
| 155 | // There is no cross-APP and no cross-ACCOUNT purge, and no mechanism is |
| 156 | // planned that would add one. This header widens a purge's reach within one |
| 157 | // tenant; it is not an addressing scheme. |
| 158 | const PurgeHostHeader = "Carlos-Purge-Host" |
| 159 | |
| 160 | // Bounds on one response's purge declaration. Beyond either, the edge |
| 161 | // degrades to a whole-host purge rather than partially applying — the |
| 162 | // failure direction is deliberately OVER-purge, never under-purge, because |
| 163 | // an extra re-fetch is cheap and the parent spec's decision 3 puts freshness |
| 164 | // above cleverness. |
| 165 | // |
| 166 | // They exist to bound the WORK one response can demand on the request hot |
| 167 | // path: without them a single response could name a million targets and drive |
| 168 | // a million locked cache operations before the client gets its bytes. |
| 169 | // |
| 170 | // Degrading is NOT the cheap path — it is the SIMPLE one. Honouring 64 |
| 171 | // targets is a bounded operation: up to 128 map lookups (each target hits |
| 172 | // the GET and HEAD index groups) plus parsing each target string. PurgeHost |
| 173 | // is bounded too — it rides the byHost index (see cache.go), a single map |
| 174 | // lookup for the whole host's entry set, not a scan of the cache. Neither |
| 175 | // path's cost is the point of the caps; what they genuinely buy is that the |
| 176 | // per-response cost has a CEILING that does not scale with how much the |
| 177 | // header declares, whichever path is taken. So an app gains nothing by |
| 178 | // exceeding the bound except a bigger blast radius on its own cache — the |
| 179 | // coarser whole-host purge, not a more precise one; the bound is a cliff in |
| 180 | // behaviour (targeted -> whole host), not an invitation. |
| 181 | // |
| 182 | // 64 targets is well above what a real write invalidates (a page, its |
| 183 | // paginated index, a couple of locale variants). 8 KiB bounds the joined |
| 184 | // header itself, since 64 targets says nothing about how long each one is. |
| 185 | // |
| 186 | // maxPurgeHosts bounds Carlos-Purge-Host the same way, but it cannot degrade: |
| 187 | // over-purging past the cap would mean acting on hosts nobody validated, so a |
| 188 | // longer list is refused outright. 32 is well above a real account's domain |
| 189 | // count, and it keeps the worst case (32 hosts x 64 targets, two index groups |
| 190 | // each) a few thousand map lookups. An app with more hosts than that should |
| 191 | // not be naming them one response at a time. |
| 192 | const ( |
| 193 | maxPurgeTargets = 64 |
| 194 | maxPurgeHosts = 32 |
| 195 | maxPurgeHeaderBytes = 8 << 10 // 8 KiB |
| 196 | ) |
| 197 | |
| 198 | // scrubInboundPurgeHeader removes Carlos-Purge from a request on its way to |
| 199 | // the app. Both proxy Rewrite funcs call it (unixProxy and tcpProxy have |
| 200 | // separate ones, so this must be applied to each). |
| 201 | // |
| 202 | // The edge already ignores a request-borne Carlos-Purge — it only ever reads |
| 203 | // the header off responses — so this is not about the edge being fooled. It |
| 204 | // is about the APP being fooled into laundering it. An app that reflects |
| 205 | // request headers onto its response (a debug echo, a framework's pass-through |
| 206 | // list, a CORS mirror) would turn a client-supplied Carlos-Purge into an |
| 207 | // app-DECLARED one, and app-declared ones are honoured. That would hand every |
| 208 | // client on the internet the purge channel this design exists to keep |
| 209 | // unreachable, and it would do it through a bug in the tenant's code that the |
| 210 | // edge cannot audit. Deleting the header on the way in makes the laundering |
| 211 | // impossible regardless of how any app behaves. |
| 212 | // |
| 213 | // Carlos-Purge-Host is scrubbed on the same line and for the same reason. It |
| 214 | // is the Critical class too: on its own it purges nothing, but reflected |
| 215 | // ALONGSIDE a reflected (or app-declared) Carlos-Purge it redirects that purge |
| 216 | // at a sibling host — and a cross-host purge inside one app is still a purge |
| 217 | // an outsider must not be able to trigger. |
| 218 | func scrubInboundPurgeHeader(pr *httputil.ProxyRequest) { |
| 219 | pr.Out.Header.Del(PurgeHeader) |
| 220 | pr.Out.Header.Del(PurgeHostHeader) |
| 221 | } |
| 222 | |
| 223 | // purgeModifier builds the httputil.ReverseProxy ModifyResponse hook for one |
| 224 | // route: it applies that response's declared purges and strips the header. |
| 225 | // |
| 226 | // ModifyResponse is the ONLY correct seam for this, and deliberately so: |
| 227 | // |
| 228 | // - It runs before ReverseProxy copies any header onto the real |
| 229 | // ResponseWriter, so the strip is what makes "no client ever sees it" |
| 230 | // true — including on the upgrade path, where the stdlib calls |
| 231 | // ModifyResponse on a 101 before handing the connection to |
| 232 | // handleUpgradeResponse. |
| 233 | // - It runs before the response reaches recordingResponseWriter at all, so |
| 234 | // it is strictly BEFORE maybeStore for the same response. A cacheable GET |
| 235 | // that also purges therefore stores its fresh self rather than purging |
| 236 | // what it just stored, and the stripped header can never be captured into |
| 237 | // a stored response's headers. |
| 238 | // - Every proxied path in Edge.ServeHTTP — the upgrade bypass, the |
| 239 | // hibernation/activator branch (the activator calls this very proxy), the |
| 240 | // cacheable GET branch, and the plain pass-through — funnels through this |
| 241 | // one ReverseProxy. Hooking anywhere in ServeHTTP itself would have to be |
| 242 | // repeated four times and would already be too late to strip. |
| 243 | // |
| 244 | // # What this seam does NOT cover |
| 245 | // |
| 246 | // ModifyResponse is handed the FINAL response only. Two header blocks |
| 247 | // therefore pass through the edge without ever reaching this code, and the |
| 248 | // contract states plainly that neither is a purge channel: |
| 249 | // |
| 250 | // - 1xx INFORMATIONAL responses. A 103 Early Hints block is forwarded to the |
| 251 | // client by the stdlib's 1xx path; ModifyResponse is not called for it. A |
| 252 | // Carlos-Purge on a 103 is therefore neither honoured nor stripped, and |
| 253 | // the client MAY see it. |
| 254 | // - TRAILERS. A header announced via Trailer and sent after the body is not |
| 255 | // in resp.Header when this runs, so it is likewise neither honoured nor |
| 256 | // stripped. |
| 257 | // |
| 258 | // Both are pinned by tests rather than fixed. Fixing them would mean wrapping |
| 259 | // the client's ResponseWriter to filter 1xx and trailer writes, and the |
| 260 | // upgrade path deliberately hands the RAW ResponseWriter to the stdlib — a |
| 261 | // wrapper there re-opens the WebSocket-breakage class this package has |
| 262 | // already paid for. Narrowing the promise is the honest trade: apps declare |
| 263 | // purges in final-response headers, which is where every app already puts |
| 264 | // them. |
| 265 | // |
| 266 | // # Ordering is per-response, not global |
| 267 | // |
| 268 | // "Purge strictly before store" holds for THIS response and no other. A |
| 269 | // different request for the same URL that is already in flight — its response |
| 270 | // received from the app, not yet stored — will still be stored AFTER this |
| 271 | // purge runs, resurrecting a body that predates the write. The window is the |
| 272 | // span between an in-flight response leaving the app and maybeStore running, |
| 273 | // and the staleness it can reintroduce is bounded by the entry's TTL ceiling |
| 274 | // rather than eliminated. This is pre-existing and identical for purge.go's |
| 275 | // socket channel; a fix would need store-time fencing (a per-URL purge |
| 276 | // generation counter checked in Put), which is deferred. |
| 277 | // |
| 278 | // The purge is honoured on ANY status and ANY method: a write is typically a |
| 279 | // POST, which never enters the GET/HEAD caching path, and a POST-redirect-GET |
| 280 | // signals on its 302. This is deliberately decoupled from cacheability, which |
| 281 | // stays 2xx-only. |
| 282 | // |
| 283 | // rt is the route this proxy was built for, captured by VALUE at reload time. |
| 284 | // Three of its fields matter here: |
| 285 | // |
| 286 | // - Host — the key Edge.ServeHTTP resolved r.Host to in the upstream map, |
| 287 | // and therefore byte-identical to the Host component of every cache key |
| 288 | // this request could touch. Taking it from the routing table rather than |
| 289 | // re-reading it off the proxied response means no header, on either side, |
| 290 | // can invent the default purge host. |
| 291 | // - App and Account — the tenancy this response is allowed to purge within. |
| 292 | // A captured copy is exactly right: the identity being asserted is the one |
| 293 | // the edge resolved this request to, not whatever the table says by the |
| 294 | // time the response comes back. |
| 295 | // |
| 296 | // The OVERRIDE side is deliberately the other way round: the named host is |
| 297 | // looked up in e.current() at response time (see resolvePurgeHosts), because |
| 298 | // the route table is live and a sibling added since this proxy was built must |
| 299 | // still validate. e.current() is an atomic pointer load, so reading it from |
| 300 | // this hook needs no lock and cannot tear against a concurrent Reload. |
| 301 | func (e *Edge) purgeModifier(rt registry.Route) func(*http.Response) error { |
| 302 | return func(resp *http.Response) error { |
| 303 | e.applyPurgeHeader(rt, resp.Header) |
| 304 | return nil |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // applyPurgeHeader honours and then removes h's Carlos-Purge and |
| 309 | // Carlos-Purge-Host lines. |
| 310 | // |
| 311 | // Both strips are unconditional — before the cache nil-check, before parsing, |
| 312 | // and even when the values are unusable. These are edge-internal signals; a |
| 313 | // box with caching disabled must not forward them to clients any more than a |
| 314 | // box with a cache does. Carlos-Purge-Host is stripped even when there is no |
| 315 | // Carlos-Purge beside it: a response that names a sibling host and asks for |
| 316 | // nothing is a no-op purge, not a header a client may see. |
| 317 | func (e *Edge) applyPurgeHeader(rt registry.Route, h http.Header) { |
| 318 | // joinHeader, never h.Get: Carlos-Purge is legitimately list-valued and |
| 319 | // may arrive on several lines (RFC 9110 §5.3 — one comma-joined line and |
| 320 | // several lines mean the same thing, and http.Header.Add produces the |
| 321 | // latter). h.Get returns only the FIRST line, which is the exact bug this |
| 322 | // package has paid for on Cache-Control, Vary, and Connection. Here it |
| 323 | // would silently drop every target after the first line — an under-purge, |
| 324 | // i.e. a stale page nobody notices until a customer does. |
| 325 | joined := joinHeader(h, PurgeHeader) |
| 326 | // Every line, for the same reason: Carlos-Purge-Host is a list too, and a |
| 327 | // host on a second line is as much a part of the declaration as the first. |
| 328 | // Reading only the first line would also let a malformed or cross-tenant |
| 329 | // entry on a later line escape the all-or-nothing check. |
| 330 | declared := h.Values(PurgeHostHeader) |
| 331 | h.Del(PurgeHeader) |
| 332 | h.Del(PurgeHostHeader) |
| 333 | if joined == "" || e.cache == nil { |
| 334 | return |
| 335 | } |
| 336 | hosts, ok := e.resolvePurgeHosts(rt, declared) |
| 337 | if !ok { |
| 338 | return |
| 339 | } |
| 340 | e.applyPurgeTargets(rt.Host, hosts, joined) |
| 341 | } |
| 342 | |
| 343 | // resolvePurgeHosts decides which hosts this response's purge targets apply |
| 344 | // to: the serving route's own host, or the hosts a valid Carlos-Purge-Host |
| 345 | // lists. ok=false means the whole declaration is refused (see |
| 346 | // PurgeHostHeader's all-or-nothing note) — the caller must purge nothing at |
| 347 | // all, not fall back. ok=true with no hosts means every listed host was one |
| 348 | // this edge does not serve, and there is nothing to purge. |
| 349 | // |
| 350 | // declared is every Carlos-Purge-Host LINE, already removed from the response. |
| 351 | func (e *Edge) resolvePurgeHosts(rt registry.Route, declared []string) ([]string, bool) { |
| 352 | if len(declared) == 0 { |
| 353 | return []string{rt.Host}, true |
| 354 | } |
| 355 | // The byte cap first, so that nothing below it — the split, the dedupe, |
| 356 | // the log lines — does work proportional to a header the app made huge. |
| 357 | size := 0 |
| 358 | for _, line := range declared { |
| 359 | size += len(line) |
| 360 | } |
| 361 | if size > maxPurgeHeaderBytes { |
| 362 | e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( |
| 363 | "%s declared a %d-byte Carlos-Purge-Host (max %d) — the whole purge declaration is ignored", |
| 364 | rt.Host, size, maxPurgeHeaderBytes)) |
| 365 | return nil, false |
| 366 | } |
| 367 | // Distinct values across every line, comma-split: a comma-joined line and |
| 368 | // several lines are the same list (RFC 9110 §5.3), and naming one host |
| 369 | // twice is one host. |
| 370 | var vals []string |
| 371 | for _, line := range declared { |
| 372 | for _, v := range strings.Split(line, ",") { |
| 373 | if v = strings.TrimSpace(v); v == "" || slices.Contains(vals, v) { |
| 374 | continue |
| 375 | } |
| 376 | if len(vals) == maxPurgeHosts { |
| 377 | e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( |
| 378 | "%s declared more than %d Carlos-Purge-Host values — the whole purge declaration is ignored", |
| 379 | rt.Host, maxPurgeHosts)) |
| 380 | return nil, false |
| 381 | } |
| 382 | vals = append(vals, v) |
| 383 | } |
| 384 | } |
| 385 | if len(vals) == 0 { |
| 386 | // A declaration that names no host is malformed, not absent. |
| 387 | e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( |
| 388 | "%s declared an empty Carlos-Purge-Host — the whole purge declaration is ignored", |
| 389 | rt.Host)) |
| 390 | return nil, false |
| 391 | } |
| 392 | // Every entry's SHAPE before any entry's tenancy, so a malformed entry |
| 393 | // anywhere in the list refuses the lot regardless of where it sits. |
| 394 | for _, host := range vals { |
| 395 | if !validPurgeOverrideHost(host) { |
| 396 | e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( |
| 397 | "%s declared a malformed Carlos-Purge-Host %q — each value must be a bare lowercase hostname, with no scheme, port or path; the whole purge declaration is ignored", |
| 398 | rt.Host, truncateForLog(host))) |
| 399 | return nil, false |
| 400 | } |
| 401 | } |
| 402 | // e.current(), not a captured map: the route table is live, and a sibling |
| 403 | // added after this proxy was built must still validate. ONE snapshot for |
| 404 | // the whole list, so every entry is judged against the same table. |
| 405 | cur := e.current() |
| 406 | hosts := make([]string, 0, len(vals)) |
| 407 | var unserved []string |
| 408 | servingChecked := false |
| 409 | for _, host := range vals { |
| 410 | if host == rt.Host { |
| 411 | // Naming your own host is trivially in-tenancy and needs no lookup, |
| 412 | // so an app can emit the header unconditionally from shared code |
| 413 | // even on the route whose pages it is already allowed to purge. |
| 414 | hosts = append(hosts, host) |
| 415 | continue |
| 416 | } |
| 417 | // lookupIn, not cur[host]: a host served through a wildcard key |
| 418 | // ("*.acme.example") has no exact entry, but the edge routes it and |
| 419 | // caches its pages under the name the browser asked for, which is |
| 420 | // the name listed here. An exact key, another tenant's included, |
| 421 | // still wins over the wildcard, and either way the route found is the |
| 422 | // one judged below. |
| 423 | up, ok := lookupIn(cur, host) |
| 424 | if !ok { |
| 425 | unserved = append(unserved, host) |
| 426 | continue |
| 427 | } |
| 428 | if !servingChecked { |
| 429 | // The SERVING side is a value captured when this proxy was built, |
| 430 | // and a reload can land between that capture and this response — |
| 431 | // the request is in flight, its proxy already resolved, while |
| 432 | // `carlos add` or an adopt re-stamps the row. Re-read it: if the |
| 433 | // serving host has left the table, or its App/Account no longer |
| 434 | // match what was captured, the tenancy this declaration would be |
| 435 | // validated against is one the edge no longer serves, and |
| 436 | // honouring it would purge a sibling on the strength of an |
| 437 | // identity that has expired. Refuse instead — the all-or-nothing |
| 438 | // rule applied to the capture itself. |
| 439 | // |
| 440 | // Only a genuine cross-host entry that the table serves needs it: |
| 441 | // naming your own host stays exactly equivalent to omitting the |
| 442 | // header, and an unserved entry is skipped without being judged. |
| 443 | if live, ok := cur[rt.Host]; !ok || live.route.App != rt.App || |
| 444 | release.RouteAccount(live.route) != release.RouteAccount(rt) { |
| 445 | e.logPurgeIssue("override-stale", rt.Host, fmt.Sprintf( |
| 446 | "%s declared Carlos-Purge-Host %q, but the serving route changed or left the route table while the request was in flight — the whole purge declaration is ignored", |
| 447 | rt.Host, truncateForLog(host))) |
| 448 | return nil, false |
| 449 | } |
| 450 | servingChecked = true |
| 451 | } |
| 452 | if !purgeHostInTenancy(rt, up) { |
| 453 | e.logPurgeIssue("override-tenancy", rt.Host, fmt.Sprintf( |
| 454 | "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge crosses an app boundary only when %q path-routes to the serving route, and never crosses an account boundary; the whole declaration is ignored", |
| 455 | rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account, truncateForLog(host))) |
| 456 | return nil, false |
| 457 | } |
| 458 | hosts = append(hosts, host) |
| 459 | } |
| 460 | if len(unserved) > 0 { |
| 461 | // One line per response, not one per entry, and only after the whole |
| 462 | // list has passed: a refused declaration logs its refusal instead. |
| 463 | // Its own kind, apart from the refusals: a domain waiting on DNS makes |
| 464 | // this line on every write, and sharing a throttle with the refusals |
| 465 | // would bury the rare one an operator needs to see. |
| 466 | e.logPurgeIssue("override-unserved", rt.Host, fmt.Sprintf( |
| 467 | "%s declared %d Carlos-Purge-Host value(s) this edge does not serve, first %q — skipped, the rest of the declaration is honoured", |
| 468 | rt.Host, len(unserved), truncateForLog(unserved[0]))) |
| 469 | } |
| 470 | return hosts, true |
| 471 | } |
| 472 | |
| 473 | // purgeHostInTenancy reports whether the route the serving route rt is |
| 474 | // purging on behalf of, up, is inside rt's tenancy. The serving host itself |
| 475 | // never reaches here; resolvePurgeHosts admits it without a lookup. |
| 476 | func purgeHostInTenancy(rt registry.Route, up upstream) bool { |
| 477 | // Account is compared through release.RouteAccount, not raw, because that |
| 478 | // is how the rest of the platform resolves a route's account — it is the |
| 479 | // sqid that keys the app's bucket objects. An unset account column, or a |
| 480 | // pre-multi-account name-shaped one, resolves to the built-in ops account, |
| 481 | // so a route stamped "" and a route stamped "bab" are the SAME tenant |
| 482 | // everywhere else on this box. A raw comparison never lets a purge cross a |
| 483 | // real account boundary, but it does split one account in two and refuse a |
| 484 | // purge between two routes that demonstrably share one key space. |
| 485 | // |
| 486 | // The path-routing exception, and the ONLY place an override crosses an |
| 487 | // app boundary. |
| 488 | // |
| 489 | // When host splits its traffic by path (route_paths) and one of its |
| 490 | // prefixes selects THIS route, the edge is already sending some of host's |
| 491 | // requests here — and caching the responses under host, because the cache |
| 492 | // key is the hostname the browser asked for while this proxy was built |
| 493 | // from the target's route. Without this branch the serving app cannot |
| 494 | // purge pages it is itself producing: its own Carlos-Purge lands under |
| 495 | // rt.Host and hits nothing, and naming host explicitly is refused by the |
| 496 | // App check below, because path routing exists precisely to cross an app |
| 497 | // boundary (Tito classic and Tito Go are two apps). |
| 498 | // |
| 499 | // Nothing is granted that is not already true: the edge demonstrably |
| 500 | // routes host's traffic to this route, so this route serves pages under |
| 501 | // that hostname. |
| 502 | // |
| 503 | // ACCOUNT is still required, and is checked HERE rather than inherited |
| 504 | // from the reconcile pass that writes path rows. That pass does enforce |
| 505 | // same-account on a target, but a row written by an older binary, or by |
| 506 | // hand, must not be able to reach another tenant's cache — the same |
| 507 | // defence-in-depth reasoning the staleness re-read applies to the serving |
| 508 | // side. |
| 509 | // |
| 510 | // Scoped to the route the prefix map actually names: "host path-routes |
| 511 | // somewhere" is not enough, or any app in the account could purge host by |
| 512 | // naming it. |
| 513 | if rt.App != "" && pathRoutesTo(up, rt.Host) && |
| 514 | release.RouteAccount(up.route) == release.RouteAccount(rt) { |
| 515 | return true |
| 516 | } |
| 517 | // App AND Account, both, and App must be a real one: a route with no App |
| 518 | // has no releases and no provable tenancy, so ""=="" is not a match — two |
| 519 | // unrelated App-less routes on one box must not be able to purge each |
| 520 | // other. |
| 521 | return rt.App != "" && up.route.App == rt.App && |
| 522 | release.RouteAccount(up.route) == release.RouteAccount(rt) |
| 523 | } |
| 524 | |
| 525 | // pathRoutesTo reports whether any prefix on up selects the route at target. |
| 526 | // |
| 527 | // A linear scan of the prefix map, deliberately, rather than a reverse index |
| 528 | // folded at Reload. It runs only for a response that declares a cross-host |
| 529 | // Carlos-Purge-Host — rare — and the map is capped at |
| 530 | // domains.MaxPathsPerClaim, so the worst case is a few thousand string |
| 531 | // compares on a path that already did a cache write. An index would cost a |
| 532 | // field on every upstream and fold work on every reload to save that; revisit |
| 533 | // if purge-declaring responses ever become the common case. |
| 534 | func pathRoutesTo(up upstream, target string) bool { |
| 535 | for _, t := range up.paths { |
| 536 | if t == target { |
| 537 | return true |
| 538 | } |
| 539 | } |
| 540 | return false |
| 541 | } |
| 542 | |
| 543 | // validPurgeOverrideHost reports whether h is shaped like a host key in the |
| 544 | // edge's routing table: a bare hostname, nothing else. |
| 545 | // |
| 546 | // It is a REFUSAL gate, not a normaliser, for the same reason validPurgeTarget |
| 547 | // is. "b.example.com:443", "https://b.example.com" and "b.example.com/x" are |
| 548 | // each a different string from the host key "b.example.com", and stripping |
| 549 | // them down to it is how a purge lands somewhere its author did not name. |
| 550 | // Uppercase is refused rather than folded because the map lookup below is |
| 551 | // byte-exact — folding would honour a spelling the route table never used, and |
| 552 | // refusing keeps the honoured set identical to the set of LOWERCASE literal |
| 553 | // route keys. A route key that does carry an uppercase byte is therefore |
| 554 | // unreachable by override at all: it can never be named here, only served. |
| 555 | // That is fail-closed and accepted — the failure is a purge that does not |
| 556 | // happen, not one that lands somewhere unnamed. |
| 557 | // |
| 558 | // It is a blocklist rather than a hostname allowlist deliberately: an unusual |
| 559 | // but genuine route key (a punycode label, a trailing-dot-free IDN form) must |
| 560 | // stay purgeable, and anything the gate lets through still has to survive the |
| 561 | // route-table lookup, which is the real check. Bytes at or below SP and DEL go |
| 562 | // for the same reason they do in validPurgeTarget — a real hostname has none, |
| 563 | // and refusing them keeps anything log-injection-shaped out. |
| 564 | // |
| 565 | // One class is excluded outright and knowingly: an IP-LITERAL host. ":" is |
| 566 | // refused (it is how a port arrives), which already rules every IPv6 literal |
| 567 | // out, and "["/"]" are refused with it for consistency. Route keys are the |
| 568 | // hosts ServeHTTP matches r.Host against — DNS names, in every deployment this |
| 569 | // platform has — so the exclusion costs nothing real. |
| 570 | // |
| 571 | // "*" is refused too. A wildcard route key ("*.acme.example") is a routing |
| 572 | // pattern, not a name anything is cached under: the cache keys on the host |
| 573 | // the browser asked for. Naming the pattern would pass the tenancy check and |
| 574 | // purge nothing, which looks like it worked. An app names the real hosts, |
| 575 | // which resolvePurgeHosts routes through the wildcard. |
| 576 | func validPurgeOverrideHost(h string) bool { |
| 577 | if h == "" { |
| 578 | return false |
| 579 | } |
| 580 | for i := 0; i < len(h); i++ { |
| 581 | c := h[i] |
| 582 | if c <= ' ' || c == 0x7f { |
| 583 | return false |
| 584 | } |
| 585 | if c >= 'A' && c <= 'Z' { |
| 586 | return false |
| 587 | } |
| 588 | switch c { |
| 589 | case ':', '/', '\\', '@', '?', '#', '%', '[', ']', '*': |
| 590 | return false |
| 591 | } |
| 592 | } |
| 593 | return true |
| 594 | } |
| 595 | |
| 596 | // applyPurgeTargets parses one response's joined Carlos-Purge value once and |
| 597 | // applies it to every host in hosts. from is the serving route's host, and |
| 598 | // keys the log lines: it is the route whose response declared the purge. |
| 599 | // |
| 600 | // hosts has already been decided by resolvePurgeHosts — the serving route's |
| 601 | // own host, or the hosts a valid Carlos-Purge-Host listed. Everything below, |
| 602 | // including "*" and both cap degrades, therefore follows the override by |
| 603 | // construction: degrading to the SERVING host instead would be the worst of |
| 604 | // both, wiping a host nobody asked about while leaving the stale pages the app |
| 605 | // was trying to invalidate. |
| 606 | func (e *Edge) applyPurgeTargets(from string, hosts []string, joined string) { |
| 607 | if len(hosts) == 0 { |
| 608 | return |
| 609 | } |
| 610 | purgeHosts := func() { |
| 611 | for _, host := range hosts { |
| 612 | e.cache.PurgeHost(host) |
| 613 | } |
| 614 | } |
| 615 | if len(joined) > maxPurgeHeaderBytes { |
| 616 | e.logPurgeIssue("oversize", from, fmt.Sprintf( |
| 617 | "%s declared a %d-byte Carlos-Purge (max %d) — purging the whole of %d host(s) instead", |
| 618 | from, len(joined), maxPurgeHeaderBytes, len(hosts))) |
| 619 | purgeHosts() |
| 620 | return |
| 621 | } |
| 622 | targets := make([]string, 0, 8) |
| 623 | for _, t := range strings.Split(joined, ",") { |
| 624 | if t = strings.TrimSpace(t); t != "" { |
| 625 | targets = append(targets, t) |
| 626 | } |
| 627 | } |
| 628 | if len(targets) > maxPurgeTargets { |
| 629 | e.logPurgeIssue("count", from, fmt.Sprintf( |
| 630 | "%s declared %d Carlos-Purge targets (max %d) — purging the whole of %d host(s) instead", |
| 631 | from, len(targets), maxPurgeTargets, len(hosts))) |
| 632 | purgeHosts() |
| 633 | return |
| 634 | } |
| 635 | // Parse every target before touching the cache, so the list is parsed once |
| 636 | // however many hosts it applies to, and so "*" anywhere subsumes the rest. |
| 637 | type key struct { |
| 638 | path, rawQuery string |
| 639 | exact bool |
| 640 | } |
| 641 | keys := make([]key, 0, len(targets)) |
| 642 | rejected, firstBad := 0, "" |
| 643 | for _, t := range targets { |
| 644 | if t == "*" { |
| 645 | // "every cache entry for each host" — and it subsumes any other |
| 646 | // target in the same declaration, so there is nothing left to do. |
| 647 | purgeHosts() |
| 648 | return |
| 649 | } |
| 650 | path, rawQuery, exact, ok := purgeTargetKey(t) |
| 651 | if !ok { |
| 652 | rejected++ |
| 653 | if firstBad == "" { |
| 654 | firstBad = t |
| 655 | } |
| 656 | continue |
| 657 | } |
| 658 | keys = append(keys, key{path, rawQuery, exact}) |
| 659 | } |
| 660 | for _, host := range hosts { |
| 661 | for _, k := range keys { |
| 662 | if k.exact { |
| 663 | e.cache.PurgeURL(host, k.path, k.rawQuery) |
| 664 | } else { |
| 665 | e.cache.PurgePath(host, k.path) |
| 666 | } |
| 667 | } |
| 668 | } |
| 669 | if rejected > 0 { |
| 670 | // One line per response, not one per target: a hostile app could |
| 671 | // otherwise turn every request into 64 log lines. The count is the |
| 672 | // signal; the first offender is the diagnostic. %q quotes any control |
| 673 | // bytes rather than emitting them into the journal. |
| 674 | e.logPurgeIssue("malformed", from, fmt.Sprintf( |
| 675 | "%s: ignored %d malformed Carlos-Purge target(s), first %q — a target must be an absolute path or \"*\", and may never carry a host", |
| 676 | from, rejected, truncateForLog(firstBad))) |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | // logPurgeIssue rate-limits one class of purge complaint for one host, using |
| 681 | // the same first-then-every-300th shape as the registry watcher in edge.go. |
| 682 | // |
| 683 | // Every one of these lines is triggered by a REMOTE party — the app's own |
| 684 | // response — on the request hot path, so an app that emits a malformed |
| 685 | // declaration on every response would otherwise write one journal line per |
| 686 | // request, indefinitely. That is a disk-fill and a log-noise problem at the |
| 687 | // same time, and it drowns the one occurrence an operator needed to see. The |
| 688 | // first is always logged (nothing is silently swallowed) and the running |
| 689 | // count travels with each line, so the rate is visible in the journal. |
| 690 | // |
| 691 | // Counters are per (class, host). Hosts come from the edge's own routing |
| 692 | // table, never from the header, so the map is bounded by the number of routes |
| 693 | // on the box and cannot be grown by a remote party. |
| 694 | func (e *Edge) logPurgeIssue(kind, host, msg string) { |
| 695 | e.logThrottled("purge "+kind+" "+host, msg) |
| 696 | } |
| 697 | |
| 698 | // purgeTargetKey turns one declared target into the cache-key components it |
| 699 | // addresses, or reports ok=false to refuse it outright. |
| 700 | // |
| 701 | // exact distinguishes the two forms the contract defines: |
| 702 | // |
| 703 | // - "/events/42" (NO "?") addresses the PATH: every cached entry under it is |
| 704 | // purged regardless of query string. This is what an app almost always |
| 705 | // means — after a write to /events/42, the cached /events/42?utm_source=x |
| 706 | // that a campaign link populated is just as stale as the bare one, and |
| 707 | // leaving it served the old body until its TTL expired. exact=false. |
| 708 | // - "/events/42?lang=fr" (WITH "?") addresses that one variant exactly, for |
| 709 | // an app that really does mean "only the French one". exact=true. |
| 710 | // - "/events/42?" is the empty-query variant, exactly: a "?" was written, so |
| 711 | // the target is exact and RawQuery is "". It purges the entry stored for a |
| 712 | // request to "/events/42" and no other. The bare form is the way to ask |
| 713 | // for "all of them"; this is the way to ask for "just the unqueried one". |
| 714 | // |
| 715 | // # Percent-encoding: the path is DECODED, the query is RAW |
| 716 | // |
| 717 | // This asymmetry is not a choice, it is what the store side does, and purge |
| 718 | // must key identically or it silently purges nothing. edge.go builds every |
| 719 | // cache key as: |
| 720 | // |
| 721 | // CacheKey{..., Path: r.URL.Path, RawQuery: r.URL.RawQuery} |
| 722 | // |
| 723 | // net/url's URL.Path is the DECODED path ("/caf%C3%A9" on the wire is stored |
| 724 | // under "/café"), while URL.RawQuery is the query byte-for-byte as received, |
| 725 | // never decoded. So a declared target is PathUnescape'd on its path half and |
| 726 | // left verbatim on its query half. A target declaring "?q=caf%C3%A9" matches |
| 727 | // an entry stored from a request whose query was literally "q=caf%C3%A9", and |
| 728 | // does NOT match one whose query was "q=café" — raw compares to raw. |
| 729 | // |
| 730 | // A path that will not decode (a truncated "%A" or "%zz") is refused, not |
| 731 | // guessed at: net/http would have rejected the same bytes in a request line, |
| 732 | // so no cache entry can exist under them anyway. |
| 733 | // |
| 734 | // The decoded bytes are re-checked, because decoding can manufacture exactly |
| 735 | // the shapes the raw gate refuses — "/%2F%2Fhost" decodes to "//host" and |
| 736 | // "/%5Chost" to "/\host". One difference, deliberately: a decoded SPACE is |
| 737 | // allowed where a raw one is refused. A raw space in a header value means a |
| 738 | // malformed declaration, but %20 decodes to a real space that r.URL.Path |
| 739 | // genuinely contains, so refusing it would make "/a b" permanently |
| 740 | // unpurgeable — an under-purge, which is the failure direction this design |
| 741 | // refuses to take. |
| 742 | func purgeTargetKey(t string) (path, rawQuery string, exact, ok bool) { |
| 743 | if !validPurgeTarget(t) { |
| 744 | return "", "", false, false |
| 745 | } |
| 746 | rawPath, rawQuery, exact := strings.Cut(t, "?") |
| 747 | path, err := url.PathUnescape(rawPath) |
| 748 | if err != nil || !validDecodedPurgePath(path) { |
| 749 | return "", "", false, false |
| 750 | } |
| 751 | return path, rawQuery, exact, true |
| 752 | } |
| 753 | |
| 754 | // validPurgeTarget reports whether t is an acceptable purge target: an |
| 755 | // absolute-path URL with an optional query string. |
| 756 | // |
| 757 | // It is a REFUSAL gate, not a normaliser. A target that is absolute-URL-shaped |
| 758 | // ("https://a.example/x"), authority-shaped ("a.example/x"), or |
| 759 | // protocol-relative ("//a.example/x", and "/\a.example/x" — browsers and |
| 760 | // several URL parsers treat a backslash as a slash, so it is the same |
| 761 | // smuggle) is ignored outright and nothing is purged for it. It is never |
| 762 | // stripped down to its path and honoured, because "purge /x" and "purge |
| 763 | // a.example/x" are different requests and the second one is not a request |
| 764 | // this channel accepts. |
| 765 | // |
| 766 | // Bytes at or below SP, and DEL, are refused wholesale: a real path is |
| 767 | // percent-encoded, so a raw space or control byte in a target means the value |
| 768 | // is malformed, and refusing them keeps anything log-injection-shaped out of |
| 769 | // the honoured set. This gate runs on the target AS DECLARED; purgeTargetKey |
| 770 | // re-checks the percent-decoded path with validDecodedPurgePath. |
| 771 | func validPurgeTarget(t string) bool { |
| 772 | if t == "" || t[0] != '/' { |
| 773 | return false |
| 774 | } |
| 775 | if len(t) > 1 && (t[1] == '/' || t[1] == '\\') { |
| 776 | return false |
| 777 | } |
| 778 | for i := 0; i < len(t); i++ { |
| 779 | if c := t[i]; c <= ' ' || c == 0x7f { |
| 780 | return false |
| 781 | } |
| 782 | } |
| 783 | return true |
| 784 | } |
| 785 | |
| 786 | // validDecodedPurgePath re-applies the shape gate to a percent-DECODED path. |
| 787 | // Same refusals as validPurgeTarget with one exception: SP (0x20) is allowed |
| 788 | // here, because a decoded space is a legitimate byte of r.URL.Path (see |
| 789 | // purgeTargetKey). Control bytes and DEL stay refused — "%00", "%0a" and |
| 790 | // friends must not reach the honoured set or the journal. |
| 791 | func validDecodedPurgePath(p string) bool { |
| 792 | if p == "" || p[0] != '/' { |
| 793 | return false |
| 794 | } |
| 795 | if len(p) > 1 && (p[1] == '/' || p[1] == '\\') { |
| 796 | return false |
| 797 | } |
| 798 | for i := 0; i < len(p); i++ { |
| 799 | if c := p[i]; c < ' ' || c == 0x7f { |
| 800 | return false |
| 801 | } |
| 802 | } |
| 803 | return true |
| 804 | } |
| 805 | |
| 806 | // truncateForLog bounds one logged target so an app cannot write an 8 KiB |
| 807 | // line into the journal per request. |
| 808 | func truncateForLog(s string) string { |
| 809 | const max = 128 |
| 810 | if len(s) <= max { |
| 811 | return s |
| 812 | } |
| 813 | return s[:max] + "…" |
| 814 | } |
| 815 | |