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 "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).
62const 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.
158const 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.
192const (
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.
218func 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.
301func (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.
317func (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.
351func (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.
476func 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.
534func 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.
576func 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.
606func (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.
694func (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.
742func 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.
771func 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.
791func 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.
808func truncateForLog(s string) string {
809 const max = 128
810 if len(s) <= max {
811 return s
812 }
813 return s[:max] + "…"
814}
815