edge: per-ledger allowlist, nosniff, and no status responses in the shared cache
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+242
−31
docs/superpowers/specs/2026-08-03-open-ledgers-design.md+6 −2internal/edge/edge.go+18 −1internal/edge/status.go+59 −28internal/edge/status_test.go+159 −0
diff --git a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md| index 329ca6329..9e879d7ba 100644 |
| --- a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md |
| +++ b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md |
| @@ -153,8 +153,12 @@ The edge's `Kind=="status"` route (`internal/edge/status.go`) currently |
| serves a fixed allowlisted key→path map from the `status/` prefix with a 60s |
| cache and stale-serves-while-store-hiccups. It gains one prefix rule: |
| `GET /ledgers/<sqid>/<app>/...` maps to the same bucket key **iff** the |
| -ledger opt-in manifest (§4.1) marks that app public — checked from a cached |
| -copy on the same 60s tick, fail-closed (no manifest → 404). JSON and blobs |
| +ledger opt-in manifest (§4.1) marks that app public AND, for everything |
| +below the app level, names that ledger in its `ledgers` array — the array is |
| +the per-ledger allowlist, not merely an index, because an account accrues |
| +chains privately and publishing is per ledger by name (an empty array |
| +publishes nothing but `ledgers.json` itself). Checked from a cached copy on |
| +the same 60s tick, fail-closed (no manifest → 404). JSON and blobs |
| only, plus the static ledger pages (§4.3) which ship inside the status |
| site's existing page set. No new timers, no publish train: an appended entry |
| is publicly readable on the next cache tick. The fleet renderer's lease and |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 8c5372eb7..1dc161356 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -318,7 +318,20 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // Cache check happens before the hibernation branch below — a hit must |
| // never reach the activator, since avoiding pointless wakes for |
| // bot/crawler/repeat traffic is the entire point of this cache. |
| - cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) |
| + // |
| + // Kind=="status" is exempt from the shared cache in both directions: the |
| + // status host caches for itself (status.go's per-key store cache) and its |
| + // /ledgers/ objects carry a long immutable Cache-Control so browsers and |
| + // CDNs cache them. Admitting them HERE would be a different thing |
| + // entirely — this LRU is process-global and shared across every host, and |
| + // a public ledger chain is unbounded in length, so an anonymous walker |
| + // could evict every other tenant's cached responses at will. An evicted |
| + // entry for a hibernating route means the next request wakes the instance |
| + // instead of being answered from cache, which is the invariant this cache |
| + // exists to hold: the status host caches for itself; its responses never |
| + // enter the shared response cache. |
| + cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) && |
| + up.route.Kind != "status" |
| var key CacheKey |
| if cacheable { |
| key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery} |
| @@ -405,6 +418,10 @@ func (e *Edge) cacheMaxBody() int { |
| // |
| // The stored TTL is clamped to DefaultCacheMaxTTL so a lost purge always |
| // self-heals within a bounded window. |
| +// |
| +// One refusal is NOT made here because it has to be made earlier, where the |
| +// route is still in hand: Kind=="status" responses are never admitted at |
| +// all. See the cacheable computation in ServeHTTP for why. |
| func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) { |
| if e.cache == nil || !rec.cacheable || rec.truncated { |
| return |
diff --git a/internal/edge/status.go b/internal/edge/status.go| index e951d5cd3..777581e62 100644 |
| --- a/internal/edge/status.go |
| +++ b/internal/edge/status.go |
| @@ -18,6 +18,7 @@ import ( |
| "log" |
| "net/http" |
| "path" |
| + "slices" |
| "strings" |
| "sync" |
| "time" |
| @@ -128,13 +129,21 @@ func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // HTTPS with the very keys the store uses, no URL scheme to keep in sync. |
| // |
| // Everything here is fail-closed: the ONLY way to get a body out of this |
| -// handler is an app whose manifest reads, parses, and says public:true. No |
| -// manifest, an unreadable one, an unparseable one, or public:false all end |
| -// in the same plain 404 as an app that was never created — a caller must |
| -// not be able to probe which apps exist, let alone which have ledgers. A |
| -// missing object under a public app is a 404 too: the 503 "nothing honest |
| -// left to serve" contract above belongs to the fleet page's own three |
| -// objects, which are the page, not arbitrary reads. |
| +// handler is an app whose manifest reads, parses, says public:true, and — |
| +// for anything below the app level — names that ledger in its Ledgers list. |
| +// No manifest, an unreadable one, an unparseable one, public:false, or a |
| +// ledger the manifest doesn't name all end in the same plain 404 as an app |
| +// that was never created: a caller must not be able to probe which apps |
| +// exist, let alone which of their ledgers do. A missing object under a |
| +// public app is a 404 too: the 503 "nothing honest left to serve" contract |
| +// above belongs to the fleet page's own three objects, which are the page, |
| +// not arbitrary reads. |
| +// |
| +// Manifest.Ledgers is read as an ALLOWLIST, not as a convenience index: an |
| +// account accrues ledgers privately (the console writes chains whether or |
| +// not anything is published), so "the app is public" cannot be allowed to |
| +// mean "every chain it has ever written is public". Publishing is per |
| +// ledger, by name; an empty list publishes nothing but the index itself. |
| // |
| // Ledger objects fall into two cache classes, because unlike the fleet |
| // page's three fixed objects a public ledger has unboundedly many: |
| @@ -173,8 +182,8 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st |
| return |
| } |
| } |
| - servable, immutable := ledgerShape(parts[3:]) |
| - if !servable { |
| + obj, ok := ledgerShape(parts[3:]) |
| + if !ok { |
| http.NotFound(w, r) |
| return |
| } |
| @@ -190,14 +199,23 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st |
| http.NotFound(w, r) |
| return |
| } |
| + // obj.ledger is "" only for ledgers.json, the app's OWN published index, |
| + // which the app is publishing by setting public:true. Everything else |
| + // names a ledger and must be on the allowlist. |
| + if obj.ledger != "" && !slices.Contains(m.Ledgers, obj.ledger) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| key := strings.TrimPrefix(p, "/") |
| - ctype := "application/json" |
| - if !strings.HasSuffix(key, ".json") { |
| - ctype = "application/octet-stream" // blobs are opaque bytes |
| - } |
| + ctype := obj.ctype |
| + // Sniffing is what would turn an opaque blob into something a browser |
| + // executes in this origin's context; the ledger's bytes are arbitrary |
| + // tenant content, so pin the declared type on every response, both |
| + // classes. |
| + w.Header().Set("X-Content-Type-Options", "nosniff") |
| - if immutable { |
| + if obj.immutable { |
| // Uncached class: straight to the store, and let the caller cache |
| // it forever — an entry key commits to its own bytes (the ts-sha |
| // key) and a blob key IS its sha256, so neither can ever change |
| @@ -232,28 +250,41 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st |
| // is the 60s server-side TTL's job, not a client's. |
| const ledgerImmutableCacheControl = "public, max-age=31536000, immutable" |
| +// ledgerObject is everything the path shape alone decides about a servable |
| +// ledger object — before any manifest is consulted. |
| +type ledgerObject struct { |
| + // ctype comes from the SHAPE, never from the key's suffix: a blob is |
| + // named by its sha256 and a tenant may well upload one whose sha ends |
| + // ".json". Blobs are opaque bytes, and the shape is what knows that. |
| + ctype string |
| + // immutable selects the cache class documented on serveLedger. |
| + immutable bool |
| + // ledger is the ledger name this object belongs to, or "" for the |
| + // app-level index (ledgers.json) — the allowlist gate keys off it. |
| + ledger string |
| +} |
| + |
| // ledgerShape is the object-shape allowlist inside one app's ledger subtree, |
| // given the path segments AFTER ledgers/<sqid>/<app>/. The ledger primitive |
| // writes exactly four shapes (internal/ledger/ledger.go's key builders) and |
| // the edge publishes exactly those: living under a public app's prefix is |
| // not, by itself, a reason to hand an object to the internet. Same allowlist |
| -// spirit as statusRoutes, one level down. |
| -// |
| -// immutable splits the two cache classes documented on serveLedger — true |
| -// for content-addressed entries and blobs, false for the mutable |
| -// ledgers.json/HEAD.json pair. It is only meaningful when servable is true. |
| -func ledgerShape(rest []string) (servable, immutable bool) { |
| +// spirit as statusRoutes, one level down. One switch decides shape, content |
| +// type, cache class and owning ledger together, so those four cannot drift |
| +// apart as shapes are added. |
| +func ledgerShape(rest []string) (ledgerObject, bool) { |
| + const jsonCT, blobCT = "application/json", "application/octet-stream" |
| switch { |
| - case len(rest) == 1: // ledgers/<sqid>/<app>/ledgers.json |
| - return rest[0] == "ledgers.json", false |
| - case len(rest) == 2: // .../<ledger>/HEAD.json |
| - return rest[1] == "HEAD.json", false |
| - case len(rest) == 3 && rest[1] == "entries": // .../<ledger>/entries/<ts>-<sha>.json |
| - return strings.HasSuffix(rest[2], ".json"), true |
| + case len(rest) == 1 && rest[0] == "ledgers.json": // ledgers/<sqid>/<app>/ledgers.json |
| + return ledgerObject{ctype: jsonCT}, true |
| + case len(rest) == 2 && rest[1] == "HEAD.json": // .../<ledger>/HEAD.json |
| + return ledgerObject{ctype: jsonCT, ledger: rest[0]}, true |
| + case len(rest) == 3 && rest[1] == "entries" && strings.HasSuffix(rest[2], ".json"): |
| + return ledgerObject{ctype: jsonCT, immutable: true, ledger: rest[0]}, true |
| case len(rest) == 3 && rest[1] == "blobs": // .../<ledger>/blobs/<sha> |
| - return true, true |
| + return ledgerObject{ctype: blobCT, immutable: true, ledger: rest[0]}, true |
| } |
| - return false, false |
| + return ledgerObject{}, false |
| } |
| // get implements the cache contract documented on statusHandler: fresh cache |
diff --git a/internal/edge/status_test.go b/internal/edge/status_test.go| index beb152abe..c66260550 100644 |
| --- a/internal/edge/status_test.go |
| +++ b/internal/edge/status_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/ledger" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -77,6 +78,11 @@ func TestLedgerServingPublic(t *testing.T) { |
| if cc := rr.Header().Get("Cache-Control"); cc != want[1] { |
| t.Errorf("GET %s Cache-Control = %q, want %q", path, cc, want[1]) |
| } |
| + // Ledger bytes are arbitrary tenant content served from the status |
| + // host's own origin: every shape must refuse content sniffing. |
| + if xcto := rr.Header().Get("X-Content-Type-Options"); xcto != "nosniff" { |
| + t.Errorf("GET %s X-Content-Type-Options = %q, want nosniff", path, xcto) |
| + } |
| } |
| if body := getStatus(t, h, "/ledgers/bab/platform/carbon/blobs/"+blobSHA).Body.String(); body != "blobby" { |
| t.Errorf("blob body = %q, want %q", body, "blobby") |
| @@ -123,6 +129,90 @@ func TestLedgerServingFailClosed(t *testing.T) { |
| } |
| } |
| +// appendTo adds one entry to another ledger of the same app, so a test can |
| +// have a published and an unpublished chain side by side. |
| +func appendTo(t *testing.T, st release.Store, name string) string { |
| + t.Helper() |
| + a, err := ledger.Append(context.Background(), st, "bab", "platform", name, "w", |
| + json.RawMessage(`{"n":2}`), |
| + func() time.Time { return time.Date(2026, 8, 3, 22, 0, 0, 0, time.UTC) }) |
| + if err != nil { |
| + t.Fatalf("append %s: %v", name, err) |
| + } |
| + return a.Key |
| +} |
| + |
| +// TestLedgerAllowlistIsPerLedger: Manifest.Ledgers is an allowlist, not an |
| +// index. An account accrues chains privately, so public:true on the app must |
| +// not publish every chain it has ever written — only the ones it names. |
| +func TestLedgerAllowlistIsPerLedger(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + seedLedger(t, st, true) // manifest publishes "carbon" only |
| + ironEntry := appendTo(t, st, "iron") |
| + ironSHA, err := ledger.PutBlob(context.Background(), st, "bab", "platform", "iron", []byte("ironblob")) |
| + if err != nil { |
| + t.Fatalf("put iron blob: %v", err) |
| + } |
| + h := newStatusHandler(st) |
| + |
| + if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK { |
| + t.Fatalf("published ledger = %d, want 200", rr.Code) |
| + } |
| + absent := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json") |
| + for _, path := range []string{ |
| + "/ledgers/bab/platform/iron/HEAD.json", |
| + "/" + ironEntry, |
| + "/ledgers/bab/platform/iron/blobs/" + ironSHA, |
| + } { |
| + rr := getStatus(t, h, path) |
| + if rr.Code != http.StatusNotFound { |
| + t.Errorf("unpublished ledger GET %s = %d, want 404", path, rr.Code) |
| + continue |
| + } |
| + if rr.Body.String() != absent.Body.String() { |
| + t.Errorf("GET %s 404 body %q differs from an absent app's %q", |
| + path, rr.Body.String(), absent.Body.String()) |
| + } |
| + } |
| + |
| + // An empty list publishes nothing but the app's own index. |
| + if err := ledger.WriteManifest(context.Background(), st, "bab", "platform", |
| + ledger.Manifest{Public: true, Ledgers: nil, Title: "CARLOS"}); err != nil { |
| + t.Fatalf("rewrite manifest: %v", err) |
| + } |
| + h2 := newStatusHandler(st) // fresh handler: don't wait out the 60s TTL |
| + if rr := getStatus(t, h2, "/ledgers/bab/platform/ledgers.json"); rr.Code != http.StatusOK { |
| + t.Errorf("ledgers.json with an empty list = %d, want 200 (the app's own index)", rr.Code) |
| + } |
| + for _, path := range []string{ |
| + "/ledgers/bab/platform/carbon/HEAD.json", |
| + "/ledgers/bab/platform/iron/HEAD.json", |
| + } { |
| + if rr := getStatus(t, h2, path); rr.Code != http.StatusNotFound { |
| + t.Errorf("GET %s with an empty allowlist = %d, want 404", path, rr.Code) |
| + } |
| + } |
| +} |
| + |
| +// TestLedgerBlobContentTypeComesFromShape: a blob is named by its sha256 and |
| +// nothing stops a tenant uploading one whose name ends ".json" — the content |
| +// type must come from the path SHAPE, not from a suffix probe on the key. |
| +func TestLedgerBlobContentTypeComesFromShape(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + seedLedger(t, st, true) |
| + if _, err := st.Put(context.Background(), |
| + "ledgers/bab/platform/carbon/blobs/deadbeef.json", []byte(`{"not":"json-typed"}`), ""); err != nil { |
| + t.Fatalf("seed blob: %v", err) |
| + } |
| + rr := getStatus(t, newStatusHandler(st), "/ledgers/bab/platform/carbon/blobs/deadbeef.json") |
| + if rr.Code != http.StatusOK { |
| + t.Fatalf("blob = %d, want 200", rr.Code) |
| + } |
| + if ct := rr.Header().Get("Content-Type"); ct != "application/octet-stream" { |
| + t.Errorf("blob Content-Type = %q, want application/octet-stream", ct) |
| + } |
| +} |
| + |
| // TestLedgerServingNeverLeavesPrefix: a public manifest buys access to that |
| // app's own ledger subtree and nothing else — no encoded traversal reaches a |
| // key outside it. |
| @@ -316,6 +406,75 @@ func TestLedgerServingStaleManifestHonored(t *testing.T) { |
| } |
| } |
| +// statusEdgeWithCache wires a full Edge (Host dispatch, shared response |
| +// cache) in front of one status route — testEdgeWithStore's shape, but |
| +// returning the *Edge so a test can look inside e.cache. |
| +func statusEdgeWithCache(t *testing.T) (release.Store, *Edge) { |
| + t.Helper() |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "status.test", Kind: "status", Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("parse public key: %v", err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + return store, e |
| +} |
| + |
| +// TestStatusResponsesNeverEnterSharedCache: the shared response cache is |
| +// process-global and shared across every host, and a public ledger chain is |
| +// unbounded in length — so a stranger walking one must not be able to admit |
| +// (and thereby evict) anything in it. An evicted entry for a hibernating |
| +// route is a wake that the cache existed to prevent. The client-facing |
| +// immutable Cache-Control still goes out: browsers and CDNs do the caching. |
| +func TestStatusResponsesNeverEnterSharedCache(t *testing.T) { |
| + store, e := statusEdgeWithCache(t) |
| + entryKey, blobSHA := seedLedger(t, store, true) |
| + if _, err := store.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil { |
| + t.Fatalf("seed fleet: %v", err) |
| + } |
| + |
| + for _, path := range []string{ |
| + "/" + entryKey, |
| + "/ledgers/bab/platform/carbon/blobs/" + blobSHA, |
| + "/ledgers/bab/platform/carbon/HEAD.json", |
| + "/ledgers/bab/platform/ledgers.json", |
| + "/fleet.json", |
| + } { |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test"+path, nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", path, rec.Code) |
| + } |
| + if _, ok := e.cache.get(CacheKey{Host: "status.test", Method: "GET", Path: path}); ok { |
| + t.Errorf("GET %s entered the shared edge response cache", path) |
| + } |
| + } |
| + |
| + // The header a client sees is unchanged — only the edge's own admission |
| + // is refused. |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/"+entryKey, nil)) |
| + if cc := rec.Header().Get("Cache-Control"); cc != ledgerImmutableCacheControl { |
| + t.Errorf("entry Cache-Control = %q, want %q", cc, ledgerImmutableCacheControl) |
| + } |
| +} |
| + |
| // TestLedgerBranchLeavesFleetRoutesAlone: the /ledgers/ prefix branch must |
| // not intercept the three fleet paths, which keep their own 503-when-nothing- |
| // cached contract. |