package edge import ( "fmt" "net/http" "net/http/httputil" "net/url" "slices" "strings" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // PurgeHeader is the response header an app sets to invalidate its OWN // cached entries — the response-header purge channel from // docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md §4(e)/§6.1, // the follow-on to the cache spec's §3 "local purge channel". // // The app signals the purge on the response it is already returning from the // write: // // HTTP/1.1 302 Found // Location: /events/42 // Carlos-Purge: /events/42, /events/42?lang=fr // // Why a header rather than a socket, a port, or a token: the tenant-isolation // invariant ("a tenant may purge only its OWN entries") holds here by // CONSTRUCTION, not by permission bookkeeping. The host is the one the edge // already resolved this request to out of its own routing table, and the only // way to name a different one — Carlos-Purge-Host, below — is resolved against // that same table and refused unless it lands on the same app AND the same // account, so there remains no field in which one TENANT could address // another. There is also nothing to reach: // no socket, no port, no path, no secret, so "which tenants can reach this // channel" has no answer beyond "each tenant's own responses." That closes // the reachability half of the invariant, which the exec-backed control // socket (purge.go) explicitly does NOT — see its same-uid sibling note — and // which a DynamicUser unit tenant could never reach at all. // // This is the Surrogate-Key/Surrogate-Control (Fastly, Varnish) and // X-Accel-* (nginx) family: an upstream steering its own cache through // response metadata. // // It is an ADDITIONAL channel, not a replacement: CARLOS_CONTROL_SOCKET and // ListenPurge are unchanged for exec-backed instances and the two coexist. // // # The contract, precisely // // - RESPONSE ONLY, and only the FINAL response. The edge reads and strips // the header off the final response's header block. 1xx informational // responses (103 Early Hints) and HTTP trailers are neither honoured nor // stripped — see purgeModifier's note. An app must declare its purges in // final-response headers, and nowhere else. // - An app must NEVER name Carlos-Purge in the response's Connection // header. That marks it hop-by-hop, and net/http/httputil deletes // hop-by-hop headers BEFORE ModifyResponse runs — the declaration is // silently eaten and nothing is purged. Pinned by // TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook. // - It is never read from a REQUEST, and is scrubbed off every proxied // request before the app sees it (scrubInboundPurgeHeader). const PurgeHeader = "Carlos-Purge" // PurgeHostHeader lets one response redirect its whole Carlos-Purge // declaration at one or more SIBLING routes of the same app and account — // spec §6.4, widened from one host to a list by amadan carlos/platform#173. // // HTTP/1.1 302 Found // Carlos-Purge-Host: acme.go.tito.io, tickets.acme.example // Carlos-Purge: /events/42 // // # Why a list (#173) // // A Tito account with custom domains serves its public pages on several hosts // at once: its canonical {slug}.go.tito.io plus any domain wired to one event // or series. With one host per declaration a write could only ever reach the // canonical one, so pages under the others stayed stale for their full // s-maxage, and the app's only defence was to stop caching them at all. A // list lets one response reach every host of its own tenancy. Each entry is // checked exactly as the single host always was; the list adds reach, never a // weaker check. // // # Why the host-confinement above had to be relaxed, and along which seam // // Tito's production topology splits one app across two route groups: admin // UIs and Stripe webhooks ride the root domain (go.tito.io) while the cached // public pages live on per-tenant routes ({slug}.go.tito.io). Every write // therefore arrives on a host where nothing relevant is cached, so a purge // confined to r.Host lands on an empty shelf and the edited page stays stale // for its full TTL — which is precisely the product promise ("admin changes // show up immediately") this cache was allowed to exist under. // // The seam relaxed is APP + ACCOUNT, both, and it is the boundary the rest of // the platform already treats as one tenant: the same app, deployed by the // same account, on the same box. Crucially the header still names no host the // EDGE does not already serve — the value is resolved against the edge's own // routing table and refused unless the route it names carries the same App and // the same Account as the route being served. So the invariant does not become // permission bookkeeping; it stays structural, just with "its own host" widened // to "its own app's hosts on this box." // // # The contract, precisely // // - A LIST of bare hostnames, comma-joined or on several lines (RFC 9110 // §5.3 — both spellings are the same declaration). Duplicates collapse. // At most maxPurgeHosts distinct hosts, and at most maxPurgeHeaderBytes // across every line; beyond either the declaration is malformed. // - It REPLACES the serving host, it does not add to it. A response that // wants its own host purged as well lists it, which is always allowed. // - ALL-OR-NOTHING on shape and tenancy. A malformed entry, an entry of a // different app or a different account, an over-long list, or a serving // route that changed while the request was in flight discards the ENTIRE // purge declaration for this response, every host and every target. // There is deliberately no fallback to the serving host, and no "honour // the good entries": a half-honoured purge is worse than a refused one, // because it clears something and so looks like it worked. // - An entry this edge does not SERVE — no ready route, exact or wildcard, // that it would route the name to — is // skipped (and logged), and the rest of the list is honoured. That is the // one exception, and it is not a half-honoured purge: an unserved host has // nothing this edge would serve from cache, and it cannot be checked // against a tenancy either, so skipping it neither misses a stale page nor // reaches outside the tenant. It matters because an app lists its hosts // from its own records, which do not say which box serves which host: a // domain still waiting on DNS, or one placed on another box, must not // cost the whole purge. A declaration whose every entry is unserved // purges nothing, exactly as a lone unknown host always did. // - It redirects EVERY target in the same response, "*" and the cap-degrade // path included, to EVERY accepted host. The override decides WHICH // hosts; §6.1 still decides which entries of each. // - Resolved against the CURRENT route table at response time (e.current()), // never a snapshot taken when the proxy was built — a sibling added by the // very reload that rebuilt this proxy must still validate. // - Stripped from the response UNCONDITIONALLY, in the same place // Carlos-Purge is, including when the edge has no cache at all. // - Never read from a request, and scrubbed off every proxied request // inbound (scrubInboundPurgeHeader). Reflection matters here for exactly // the reason it matters for Carlos-Purge: a cross-host purge within one // app is still a purge an outsider must not be able to trigger. // - An app must NEVER name EITHER purge header in the response's Connection // header, and naming this one is the worse of the two. Connection marks a // header hop-by-hop, and net/http/httputil deletes hop-by-hop headers // BEFORE ModifyResponse runs, so the edge never sees it. For Carlos-Purge // that silently loses the declaration and nothing is purged. For // Carlos-Purge-Host the Carlos-Purge line SURVIVES while its redirect is // deleted, so the declaration is silently RETARGETED at the serving host — // a half-honoured purge of exactly the kind the all-or-nothing rule above // exists to prevent, except that the edge cannot refuse it, because the // evidence was destroyed before this package was called. It clears the // wrong host and looks like it worked. Pinned by // TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost. // // # Non-goal, permanently // // There is no cross-APP and no cross-ACCOUNT purge, and no mechanism is // planned that would add one. This header widens a purge's reach within one // tenant; it is not an addressing scheme. const PurgeHostHeader = "Carlos-Purge-Host" // Bounds on one response's purge declaration. Beyond either, the edge // degrades to a whole-host purge rather than partially applying — the // failure direction is deliberately OVER-purge, never under-purge, because // an extra re-fetch is cheap and the parent spec's decision 3 puts freshness // above cleverness. // // They exist to bound the WORK one response can demand on the request hot // path: without them a single response could name a million targets and drive // a million locked cache operations before the client gets its bytes. // // Degrading is NOT the cheap path — it is the SIMPLE one. Honouring 64 // targets is a bounded operation: up to 128 map lookups (each target hits // the GET and HEAD index groups) plus parsing each target string. PurgeHost // is bounded too — it rides the byHost index (see cache.go), a single map // lookup for the whole host's entry set, not a scan of the cache. Neither // path's cost is the point of the caps; what they genuinely buy is that the // per-response cost has a CEILING that does not scale with how much the // header declares, whichever path is taken. So an app gains nothing by // exceeding the bound except a bigger blast radius on its own cache — the // coarser whole-host purge, not a more precise one; the bound is a cliff in // behaviour (targeted -> whole host), not an invitation. // // 64 targets is well above what a real write invalidates (a page, its // paginated index, a couple of locale variants). 8 KiB bounds the joined // header itself, since 64 targets says nothing about how long each one is. // // maxPurgeHosts bounds Carlos-Purge-Host the same way, but it cannot degrade: // over-purging past the cap would mean acting on hosts nobody validated, so a // longer list is refused outright. 32 is well above a real account's domain // count, and it keeps the worst case (32 hosts x 64 targets, two index groups // each) a few thousand map lookups. An app with more hosts than that should // not be naming them one response at a time. const ( maxPurgeTargets = 64 maxPurgeHosts = 32 maxPurgeHeaderBytes = 8 << 10 // 8 KiB ) // scrubInboundPurgeHeader removes Carlos-Purge from a request on its way to // the app. Both proxy Rewrite funcs call it (unixProxy and tcpProxy have // separate ones, so this must be applied to each). // // The edge already ignores a request-borne Carlos-Purge — it only ever reads // the header off responses — so this is not about the edge being fooled. It // is about the APP being fooled into laundering it. An app that reflects // request headers onto its response (a debug echo, a framework's pass-through // list, a CORS mirror) would turn a client-supplied Carlos-Purge into an // app-DECLARED one, and app-declared ones are honoured. That would hand every // client on the internet the purge channel this design exists to keep // unreachable, and it would do it through a bug in the tenant's code that the // edge cannot audit. Deleting the header on the way in makes the laundering // impossible regardless of how any app behaves. // // Carlos-Purge-Host is scrubbed on the same line and for the same reason. It // is the Critical class too: on its own it purges nothing, but reflected // ALONGSIDE a reflected (or app-declared) Carlos-Purge it redirects that purge // at a sibling host — and a cross-host purge inside one app is still a purge // an outsider must not be able to trigger. func scrubInboundPurgeHeader(pr *httputil.ProxyRequest) { pr.Out.Header.Del(PurgeHeader) pr.Out.Header.Del(PurgeHostHeader) } // purgeModifier builds the httputil.ReverseProxy ModifyResponse hook for one // route: it applies that response's declared purges and strips the header. // // ModifyResponse is the ONLY correct seam for this, and deliberately so: // // - It runs before ReverseProxy copies any header onto the real // ResponseWriter, so the strip is what makes "no client ever sees it" // true — including on the upgrade path, where the stdlib calls // ModifyResponse on a 101 before handing the connection to // handleUpgradeResponse. // - It runs before the response reaches recordingResponseWriter at all, so // it is strictly BEFORE maybeStore for the same response. A cacheable GET // that also purges therefore stores its fresh self rather than purging // what it just stored, and the stripped header can never be captured into // a stored response's headers. // - Every proxied path in Edge.ServeHTTP — the upgrade bypass, the // hibernation/activator branch (the activator calls this very proxy), the // cacheable GET branch, and the plain pass-through — funnels through this // one ReverseProxy. Hooking anywhere in ServeHTTP itself would have to be // repeated four times and would already be too late to strip. // // # What this seam does NOT cover // // ModifyResponse is handed the FINAL response only. Two header blocks // therefore pass through the edge without ever reaching this code, and the // contract states plainly that neither is a purge channel: // // - 1xx INFORMATIONAL responses. A 103 Early Hints block is forwarded to the // client by the stdlib's 1xx path; ModifyResponse is not called for it. A // Carlos-Purge on a 103 is therefore neither honoured nor stripped, and // the client MAY see it. // - TRAILERS. A header announced via Trailer and sent after the body is not // in resp.Header when this runs, so it is likewise neither honoured nor // stripped. // // Both are pinned by tests rather than fixed. Fixing them would mean wrapping // the client's ResponseWriter to filter 1xx and trailer writes, and the // upgrade path deliberately hands the RAW ResponseWriter to the stdlib — a // wrapper there re-opens the WebSocket-breakage class this package has // already paid for. Narrowing the promise is the honest trade: apps declare // purges in final-response headers, which is where every app already puts // them. // // # Ordering is per-response, not global // // "Purge strictly before store" holds for THIS response and no other. A // different request for the same URL that is already in flight — its response // received from the app, not yet stored — will still be stored AFTER this // purge runs, resurrecting a body that predates the write. The window is the // span between an in-flight response leaving the app and maybeStore running, // and the staleness it can reintroduce is bounded by the entry's TTL ceiling // rather than eliminated. This is pre-existing and identical for purge.go's // socket channel; a fix would need store-time fencing (a per-URL purge // generation counter checked in Put), which is deferred. // // The purge is honoured on ANY status and ANY method: a write is typically a // POST, which never enters the GET/HEAD caching path, and a POST-redirect-GET // signals on its 302. This is deliberately decoupled from cacheability, which // stays 2xx-only. // // rt is the route this proxy was built for, captured by VALUE at reload time. // Three of its fields matter here: // // - Host — the key Edge.ServeHTTP resolved r.Host to in the upstream map, // and therefore byte-identical to the Host component of every cache key // this request could touch. Taking it from the routing table rather than // re-reading it off the proxied response means no header, on either side, // can invent the default purge host. // - App and Account — the tenancy this response is allowed to purge within. // A captured copy is exactly right: the identity being asserted is the one // the edge resolved this request to, not whatever the table says by the // time the response comes back. // // The OVERRIDE side is deliberately the other way round: the named host is // looked up in e.current() at response time (see resolvePurgeHosts), because // the route table is live and a sibling added since this proxy was built must // still validate. e.current() is an atomic pointer load, so reading it from // this hook needs no lock and cannot tear against a concurrent Reload. func (e *Edge) purgeModifier(rt registry.Route) func(*http.Response) error { return func(resp *http.Response) error { e.applyPurgeHeader(rt, resp.Header) return nil } } // applyPurgeHeader honours and then removes h's Carlos-Purge and // Carlos-Purge-Host lines. // // Both strips are unconditional — before the cache nil-check, before parsing, // and even when the values are unusable. These are edge-internal signals; a // box with caching disabled must not forward them to clients any more than a // box with a cache does. Carlos-Purge-Host is stripped even when there is no // Carlos-Purge beside it: a response that names a sibling host and asks for // nothing is a no-op purge, not a header a client may see. func (e *Edge) applyPurgeHeader(rt registry.Route, h http.Header) { // joinHeader, never h.Get: Carlos-Purge is legitimately list-valued and // may arrive on several lines (RFC 9110 §5.3 — one comma-joined line and // several lines mean the same thing, and http.Header.Add produces the // latter). h.Get returns only the FIRST line, which is the exact bug this // package has paid for on Cache-Control, Vary, and Connection. Here it // would silently drop every target after the first line — an under-purge, // i.e. a stale page nobody notices until a customer does. joined := joinHeader(h, PurgeHeader) // Every line, for the same reason: Carlos-Purge-Host is a list too, and a // host on a second line is as much a part of the declaration as the first. // Reading only the first line would also let a malformed or cross-tenant // entry on a later line escape the all-or-nothing check. declared := h.Values(PurgeHostHeader) h.Del(PurgeHeader) h.Del(PurgeHostHeader) if joined == "" || e.cache == nil { return } hosts, ok := e.resolvePurgeHosts(rt, declared) if !ok { return } e.applyPurgeTargets(rt.Host, hosts, joined) } // resolvePurgeHosts decides which hosts this response's purge targets apply // to: the serving route's own host, or the hosts a valid Carlos-Purge-Host // lists. ok=false means the whole declaration is refused (see // PurgeHostHeader's all-or-nothing note) — the caller must purge nothing at // all, not fall back. ok=true with no hosts means every listed host was one // this edge does not serve, and there is nothing to purge. // // declared is every Carlos-Purge-Host LINE, already removed from the response. func (e *Edge) resolvePurgeHosts(rt registry.Route, declared []string) ([]string, bool) { if len(declared) == 0 { return []string{rt.Host}, true } // The byte cap first, so that nothing below it — the split, the dedupe, // the log lines — does work proportional to a header the app made huge. size := 0 for _, line := range declared { size += len(line) } if size > maxPurgeHeaderBytes { e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( "%s declared a %d-byte Carlos-Purge-Host (max %d) — the whole purge declaration is ignored", rt.Host, size, maxPurgeHeaderBytes)) return nil, false } // Distinct values across every line, comma-split: a comma-joined line and // several lines are the same list (RFC 9110 §5.3), and naming one host // twice is one host. var vals []string for _, line := range declared { for _, v := range strings.Split(line, ",") { if v = strings.TrimSpace(v); v == "" || slices.Contains(vals, v) { continue } if len(vals) == maxPurgeHosts { e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( "%s declared more than %d Carlos-Purge-Host values — the whole purge declaration is ignored", rt.Host, maxPurgeHosts)) return nil, false } vals = append(vals, v) } } if len(vals) == 0 { // A declaration that names no host is malformed, not absent. e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( "%s declared an empty Carlos-Purge-Host — the whole purge declaration is ignored", rt.Host)) return nil, false } // Every entry's SHAPE before any entry's tenancy, so a malformed entry // anywhere in the list refuses the lot regardless of where it sits. for _, host := range vals { if !validPurgeOverrideHost(host) { e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf( "%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", rt.Host, truncateForLog(host))) return nil, false } } // e.current(), not a captured map: the route table is live, and a sibling // added after this proxy was built must still validate. ONE snapshot for // the whole list, so every entry is judged against the same table. cur := e.current() hosts := make([]string, 0, len(vals)) var unserved []string servingChecked := false for _, host := range vals { if host == rt.Host { // Naming your own host is trivially in-tenancy and needs no lookup, // so an app can emit the header unconditionally from shared code // even on the route whose pages it is already allowed to purge. hosts = append(hosts, host) continue } // lookupIn, not cur[host]: a host served through a wildcard key // ("*.acme.example") has no exact entry, but the edge routes it and // caches its pages under the name the browser asked for, which is // the name listed here. An exact key, another tenant's included, // still wins over the wildcard, and either way the route found is the // one judged below. up, ok := lookupIn(cur, host) if !ok { unserved = append(unserved, host) continue } if !servingChecked { // The SERVING side is a value captured when this proxy was built, // and a reload can land between that capture and this response — // the request is in flight, its proxy already resolved, while // `carlos add` or an adopt re-stamps the row. Re-read it: if the // serving host has left the table, or its App/Account no longer // match what was captured, the tenancy this declaration would be // validated against is one the edge no longer serves, and // honouring it would purge a sibling on the strength of an // identity that has expired. Refuse instead — the all-or-nothing // rule applied to the capture itself. // // Only a genuine cross-host entry that the table serves needs it: // naming your own host stays exactly equivalent to omitting the // header, and an unserved entry is skipped without being judged. if live, ok := cur[rt.Host]; !ok || live.route.App != rt.App || release.RouteAccount(live.route) != release.RouteAccount(rt) { e.logPurgeIssue("override-stale", rt.Host, fmt.Sprintf( "%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", rt.Host, truncateForLog(host))) return nil, false } servingChecked = true } if !purgeHostInTenancy(rt, up) { e.logPurgeIssue("override-tenancy", rt.Host, fmt.Sprintf( "%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", rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account, truncateForLog(host))) return nil, false } hosts = append(hosts, host) } if len(unserved) > 0 { // One line per response, not one per entry, and only after the whole // list has passed: a refused declaration logs its refusal instead. // Its own kind, apart from the refusals: a domain waiting on DNS makes // this line on every write, and sharing a throttle with the refusals // would bury the rare one an operator needs to see. e.logPurgeIssue("override-unserved", rt.Host, fmt.Sprintf( "%s declared %d Carlos-Purge-Host value(s) this edge does not serve, first %q — skipped, the rest of the declaration is honoured", rt.Host, len(unserved), truncateForLog(unserved[0]))) } return hosts, true } // purgeHostInTenancy reports whether the route the serving route rt is // purging on behalf of, up, is inside rt's tenancy. The serving host itself // never reaches here; resolvePurgeHosts admits it without a lookup. func purgeHostInTenancy(rt registry.Route, up upstream) bool { // Account is compared through release.RouteAccount, not raw, because that // is how the rest of the platform resolves a route's account — it is the // sqid that keys the app's bucket objects. An unset account column, or a // pre-multi-account name-shaped one, resolves to the built-in ops account, // so a route stamped "" and a route stamped "bab" are the SAME tenant // everywhere else on this box. A raw comparison never lets a purge cross a // real account boundary, but it does split one account in two and refuse a // purge between two routes that demonstrably share one key space. // // The path-routing exception, and the ONLY place an override crosses an // app boundary. // // When host splits its traffic by path (route_paths) and one of its // prefixes selects THIS route, the edge is already sending some of host's // requests here — and caching the responses under host, because the cache // key is the hostname the browser asked for while this proxy was built // from the target's route. Without this branch the serving app cannot // purge pages it is itself producing: its own Carlos-Purge lands under // rt.Host and hits nothing, and naming host explicitly is refused by the // App check below, because path routing exists precisely to cross an app // boundary (Tito classic and Tito Go are two apps). // // Nothing is granted that is not already true: the edge demonstrably // routes host's traffic to this route, so this route serves pages under // that hostname. // // ACCOUNT is still required, and is checked HERE rather than inherited // from the reconcile pass that writes path rows. That pass does enforce // same-account on a target, but a row written by an older binary, or by // hand, must not be able to reach another tenant's cache — the same // defence-in-depth reasoning the staleness re-read applies to the serving // side. // // Scoped to the route the prefix map actually names: "host path-routes // somewhere" is not enough, or any app in the account could purge host by // naming it. if rt.App != "" && pathRoutesTo(up, rt.Host) && release.RouteAccount(up.route) == release.RouteAccount(rt) { return true } // App AND Account, both, and App must be a real one: a route with no App // has no releases and no provable tenancy, so ""=="" is not a match — two // unrelated App-less routes on one box must not be able to purge each // other. return rt.App != "" && up.route.App == rt.App && release.RouteAccount(up.route) == release.RouteAccount(rt) } // pathRoutesTo reports whether any prefix on up selects the route at target. // // A linear scan of the prefix map, deliberately, rather than a reverse index // folded at Reload. It runs only for a response that declares a cross-host // Carlos-Purge-Host — rare — and the map is capped at // domains.MaxPathsPerClaim, so the worst case is a few thousand string // compares on a path that already did a cache write. An index would cost a // field on every upstream and fold work on every reload to save that; revisit // if purge-declaring responses ever become the common case. func pathRoutesTo(up upstream, target string) bool { for _, t := range up.paths { if t == target { return true } } return false } // validPurgeOverrideHost reports whether h is shaped like a host key in the // edge's routing table: a bare hostname, nothing else. // // It is a REFUSAL gate, not a normaliser, for the same reason validPurgeTarget // is. "b.example.com:443", "https://b.example.com" and "b.example.com/x" are // each a different string from the host key "b.example.com", and stripping // them down to it is how a purge lands somewhere its author did not name. // Uppercase is refused rather than folded because the map lookup below is // byte-exact — folding would honour a spelling the route table never used, and // refusing keeps the honoured set identical to the set of LOWERCASE literal // route keys. A route key that does carry an uppercase byte is therefore // unreachable by override at all: it can never be named here, only served. // That is fail-closed and accepted — the failure is a purge that does not // happen, not one that lands somewhere unnamed. // // It is a blocklist rather than a hostname allowlist deliberately: an unusual // but genuine route key (a punycode label, a trailing-dot-free IDN form) must // stay purgeable, and anything the gate lets through still has to survive the // route-table lookup, which is the real check. Bytes at or below SP and DEL go // for the same reason they do in validPurgeTarget — a real hostname has none, // and refusing them keeps anything log-injection-shaped out. // // One class is excluded outright and knowingly: an IP-LITERAL host. ":" is // refused (it is how a port arrives), which already rules every IPv6 literal // out, and "["/"]" are refused with it for consistency. Route keys are the // hosts ServeHTTP matches r.Host against — DNS names, in every deployment this // platform has — so the exclusion costs nothing real. // // "*" is refused too. A wildcard route key ("*.acme.example") is a routing // pattern, not a name anything is cached under: the cache keys on the host // the browser asked for. Naming the pattern would pass the tenancy check and // purge nothing, which looks like it worked. An app names the real hosts, // which resolvePurgeHosts routes through the wildcard. func validPurgeOverrideHost(h string) bool { if h == "" { return false } for i := 0; i < len(h); i++ { c := h[i] if c <= ' ' || c == 0x7f { return false } if c >= 'A' && c <= 'Z' { return false } switch c { case ':', '/', '\\', '@', '?', '#', '%', '[', ']', '*': return false } } return true } // applyPurgeTargets parses one response's joined Carlos-Purge value once and // applies it to every host in hosts. from is the serving route's host, and // keys the log lines: it is the route whose response declared the purge. // // hosts has already been decided by resolvePurgeHosts — the serving route's // own host, or the hosts a valid Carlos-Purge-Host listed. Everything below, // including "*" and both cap degrades, therefore follows the override by // construction: degrading to the SERVING host instead would be the worst of // both, wiping a host nobody asked about while leaving the stale pages the app // was trying to invalidate. func (e *Edge) applyPurgeTargets(from string, hosts []string, joined string) { if len(hosts) == 0 { return } purgeHosts := func() { for _, host := range hosts { e.cache.PurgeHost(host) } } if len(joined) > maxPurgeHeaderBytes { e.logPurgeIssue("oversize", from, fmt.Sprintf( "%s declared a %d-byte Carlos-Purge (max %d) — purging the whole of %d host(s) instead", from, len(joined), maxPurgeHeaderBytes, len(hosts))) purgeHosts() return } targets := make([]string, 0, 8) for _, t := range strings.Split(joined, ",") { if t = strings.TrimSpace(t); t != "" { targets = append(targets, t) } } if len(targets) > maxPurgeTargets { e.logPurgeIssue("count", from, fmt.Sprintf( "%s declared %d Carlos-Purge targets (max %d) — purging the whole of %d host(s) instead", from, len(targets), maxPurgeTargets, len(hosts))) purgeHosts() return } // Parse every target before touching the cache, so the list is parsed once // however many hosts it applies to, and so "*" anywhere subsumes the rest. type key struct { path, rawQuery string exact bool } keys := make([]key, 0, len(targets)) rejected, firstBad := 0, "" for _, t := range targets { if t == "*" { // "every cache entry for each host" — and it subsumes any other // target in the same declaration, so there is nothing left to do. purgeHosts() return } path, rawQuery, exact, ok := purgeTargetKey(t) if !ok { rejected++ if firstBad == "" { firstBad = t } continue } keys = append(keys, key{path, rawQuery, exact}) } for _, host := range hosts { for _, k := range keys { if k.exact { e.cache.PurgeURL(host, k.path, k.rawQuery) } else { e.cache.PurgePath(host, k.path) } } } if rejected > 0 { // One line per response, not one per target: a hostile app could // otherwise turn every request into 64 log lines. The count is the // signal; the first offender is the diagnostic. %q quotes any control // bytes rather than emitting them into the journal. e.logPurgeIssue("malformed", from, fmt.Sprintf( "%s: ignored %d malformed Carlos-Purge target(s), first %q — a target must be an absolute path or \"*\", and may never carry a host", from, rejected, truncateForLog(firstBad))) } } // logPurgeIssue rate-limits one class of purge complaint for one host, using // the same first-then-every-300th shape as the registry watcher in edge.go. // // Every one of these lines is triggered by a REMOTE party — the app's own // response — on the request hot path, so an app that emits a malformed // declaration on every response would otherwise write one journal line per // request, indefinitely. That is a disk-fill and a log-noise problem at the // same time, and it drowns the one occurrence an operator needed to see. The // first is always logged (nothing is silently swallowed) and the running // count travels with each line, so the rate is visible in the journal. // // Counters are per (class, host). Hosts come from the edge's own routing // table, never from the header, so the map is bounded by the number of routes // on the box and cannot be grown by a remote party. func (e *Edge) logPurgeIssue(kind, host, msg string) { e.logThrottled("purge "+kind+" "+host, msg) } // purgeTargetKey turns one declared target into the cache-key components it // addresses, or reports ok=false to refuse it outright. // // exact distinguishes the two forms the contract defines: // // - "/events/42" (NO "?") addresses the PATH: every cached entry under it is // purged regardless of query string. This is what an app almost always // means — after a write to /events/42, the cached /events/42?utm_source=x // that a campaign link populated is just as stale as the bare one, and // leaving it served the old body until its TTL expired. exact=false. // - "/events/42?lang=fr" (WITH "?") addresses that one variant exactly, for // an app that really does mean "only the French one". exact=true. // - "/events/42?" is the empty-query variant, exactly: a "?" was written, so // the target is exact and RawQuery is "". It purges the entry stored for a // request to "/events/42" and no other. The bare form is the way to ask // for "all of them"; this is the way to ask for "just the unqueried one". // // # Percent-encoding: the path is DECODED, the query is RAW // // This asymmetry is not a choice, it is what the store side does, and purge // must key identically or it silently purges nothing. edge.go builds every // cache key as: // // CacheKey{..., Path: r.URL.Path, RawQuery: r.URL.RawQuery} // // net/url's URL.Path is the DECODED path ("/caf%C3%A9" on the wire is stored // under "/café"), while URL.RawQuery is the query byte-for-byte as received, // never decoded. So a declared target is PathUnescape'd on its path half and // left verbatim on its query half. A target declaring "?q=caf%C3%A9" matches // an entry stored from a request whose query was literally "q=caf%C3%A9", and // does NOT match one whose query was "q=café" — raw compares to raw. // // A path that will not decode (a truncated "%A" or "%zz") is refused, not // guessed at: net/http would have rejected the same bytes in a request line, // so no cache entry can exist under them anyway. // // The decoded bytes are re-checked, because decoding can manufacture exactly // the shapes the raw gate refuses — "/%2F%2Fhost" decodes to "//host" and // "/%5Chost" to "/\host". One difference, deliberately: a decoded SPACE is // allowed where a raw one is refused. A raw space in a header value means a // malformed declaration, but %20 decodes to a real space that r.URL.Path // genuinely contains, so refusing it would make "/a b" permanently // unpurgeable — an under-purge, which is the failure direction this design // refuses to take. func purgeTargetKey(t string) (path, rawQuery string, exact, ok bool) { if !validPurgeTarget(t) { return "", "", false, false } rawPath, rawQuery, exact := strings.Cut(t, "?") path, err := url.PathUnescape(rawPath) if err != nil || !validDecodedPurgePath(path) { return "", "", false, false } return path, rawQuery, exact, true } // validPurgeTarget reports whether t is an acceptable purge target: an // absolute-path URL with an optional query string. // // It is a REFUSAL gate, not a normaliser. A target that is absolute-URL-shaped // ("https://a.example/x"), authority-shaped ("a.example/x"), or // protocol-relative ("//a.example/x", and "/\a.example/x" — browsers and // several URL parsers treat a backslash as a slash, so it is the same // smuggle) is ignored outright and nothing is purged for it. It is never // stripped down to its path and honoured, because "purge /x" and "purge // a.example/x" are different requests and the second one is not a request // this channel accepts. // // Bytes at or below SP, and DEL, are refused wholesale: a real path is // percent-encoded, so a raw space or control byte in a target means the value // is malformed, and refusing them keeps anything log-injection-shaped out of // the honoured set. This gate runs on the target AS DECLARED; purgeTargetKey // re-checks the percent-decoded path with validDecodedPurgePath. func validPurgeTarget(t string) bool { if t == "" || t[0] != '/' { return false } if len(t) > 1 && (t[1] == '/' || t[1] == '\\') { return false } for i := 0; i < len(t); i++ { if c := t[i]; c <= ' ' || c == 0x7f { return false } } return true } // validDecodedPurgePath re-applies the shape gate to a percent-DECODED path. // Same refusals as validPurgeTarget with one exception: SP (0x20) is allowed // here, because a decoded space is a legitimate byte of r.URL.Path (see // purgeTargetKey). Control bytes and DEL stay refused — "%00", "%0a" and // friends must not reach the honoured set or the journal. func validDecodedPurgePath(p string) bool { if p == "" || p[0] != '/' { return false } if len(p) > 1 && (p[1] == '/' || p[1] == '\\') { return false } for i := 0; i < len(p); i++ { if c := p[i]; c < ' ' || c == 0x7f { return false } } return true } // truncateForLog bounds one logged target so an app cannot write an 8 KiB // line into the journal per request. func truncateForLog(s string) string { const max = 128 if len(s) <= max { return s } return s[:max] + "…" }