edge: the store door — release.Store over HTTPS, account-scoped (spec F1-F4)
The five release.Store operations served on the edge's reserved tunnel hostname under /store/, authenticated by the fleet token the box already holds (TunnelHub.VerifyDynamic, one call per request) and ACL'd server-side from the fleet's registry row: read control/serial, bootstrap/carlos, its account's accounts/<sqid>.json and apps/<sqid>/**, plus its own fleets/<fleet>/** (which every CAS write must read first); write and delete fleets/<fleet>/** and nothing else. Every refusal — wrong token, a static tunnel name, another tenant's key, a missing key, a malformed key — is one byte-identical 404 after a single constant-time verify, so the keyspace is never an oracle. canonicalKey refuses "..", ".", empty segments, leading/trailing slashes, control bytes and >1024-byte keys BEFORE the ACL, which is prefix-string exact. CAS maps straight onto the Store contract: If-None-Match:* -> create-only, If-Match:<etag> -> replace-only, neither -> 400, conflict -> 412, delete -> idempotent 204. The door reuses the edge's own deployment-store handle (EnableStatic's, the one static and status serving already share) — no second S3 client — and takes its fleet->account map from the same reload pass that feeds the hub's dynamic auth table, so credentials and tenancy can never come from two different reads of the registry. It exists only where both halves are configured, and is reachable only on the reserved hostname: /register stays the hub's, and no registry route can ever reach it. Also folds in the orphaned comment fragment left in VerifyDynamic's doc by the previous commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+1116
−2
internal/edge/edge.go+76 −0internal/edge/storedoor.go+424 −0internal/edge/storedoor_test.go+615 −0internal/edge/tunnel.go+1 −2
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index dd84325ee..219e8697e 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -108,6 +108,22 @@ type Edge struct { |
| // to its socket/addr, or goes unserved if it has neither — the same |
| // nil-means-disabled convention as act/cache/staticStore. |
| tunnels *TunnelHub |
| + // door, when non-nil, is the fleet store door (storedoor.go): the five |
| + // release.Store operations served over HTTPS under /store/ on the SAME |
| + // reserved hostname the hub answers on, so a fleet box needs no AWS |
| + // credentials on the deployment bucket. It exists only when BOTH halves |
| + // it needs are configured — the hub (its only credential source) and the |
| + // deployment store (EnableStatic's handle, reused, never a second |
| + // client) — so armStoreDoor is called from both EnableXxx, whichever |
| + // lands second. nil = /store/ falls through to the hub's own 404, the |
| + // same nil-means-disabled convention as act/cache/staticStore. |
| + door *storeDoor |
| + // fleetAccounts is the reload-time fleet→account-sqid snapshot the door's |
| + // ACL is derived from, refreshed on the SAME pass that feeds the hub's |
| + // dynamic auth table (Reload, below) so a box's credentials and its |
| + // tenancy can never come from two different reads of the registry. |
| + // Atomic: written by Reload while requests are being served. |
| + fleetAccounts atomic.Pointer[map[string]string] |
| // cache, when non-nil, answers cacheable GET/HEAD requests without ever |
| // invoking the hibernation activator or the backend — the mitigation |
| // this whole feature exists for. nil disables caching entirely. |
| @@ -163,6 +179,7 @@ func (e *Edge) EnableStatic(store release.Store, pub ed25519.PublicKey, cacheDir |
| e.staticPub = pub |
| e.staticCacheDir = cacheDir |
| e.staticRecheck = staticRecheck |
| + e.armStoreDoor() |
| } |
| // EnableCache wires a response cache into the edge. Called once at startup |
| @@ -178,6 +195,39 @@ func (e *Edge) EnableCache(cache *Cache) { |
| // Reload afterwards, tunnel routes stay skipped from the last table build. |
| func (e *Edge) EnableTunnels(hub *TunnelHub) { |
| e.tunnels = hub |
| + e.armStoreDoor() |
| +} |
| + |
| +// armStoreDoor builds (or clears) the fleet store door from whatever is |
| +// configured now. Called from both EnableStatic and EnableTunnels because the |
| +// door needs one thing from each and the two are wired in either order at |
| +// startup (enableStaticFromEnv runs first today, but a caller composing an |
| +// Edge by hand — every test does — must not have to know that). A deployment |
| +// with no release pubkey configured therefore has no store handle and so no |
| +// door: /store/ then reaches the hub, which 404s it, byte-identically. |
| +func (e *Edge) armStoreDoor() { |
| + if e.tunnels == nil || e.staticStore == nil { |
| + e.door = nil |
| + return |
| + } |
| + e.door = &storeDoor{hub: e.tunnels, store: e.staticStore, fleetAccount: e.fleetAccount} |
| +} |
| + |
| +// fleetAccount resolves an authenticated fleet name to its owning account's |
| +// sqid from the last reload's snapshot. A fleet the snapshot does not carry |
| +// (created since the last reload, or deleted) has no tenancy, so the door |
| +// answers it nothing — the same convergence bound as its channel credentials, |
| +// which come from the same pass. |
| +func (e *Edge) fleetAccount(fleet string) (string, bool) { |
| + m := e.fleetAccounts.Load() |
| + if m == nil { |
| + return "", false |
| + } |
| + sqid, ok := (*m)[fleet] |
| + if !ok || sqid == "" { |
| + return "", false |
| + } |
| + return sqid, true |
| } |
| // SetStaticRecheck tunes how long a static route trusts a resolved channel |
| @@ -384,6 +434,22 @@ func (e *Edge) Reload() error { |
| return err |
| } |
| e.tunnels.SetDynamicAuth(auth) |
| + // The store door's tenancy map, from the SAME pass: a box's |
| + // credentials and the account its reads are scoped to must never come |
| + // from two different reads of the registry. ListFleets goes over a |
| + // fresh connection (like FleetChannelAuth above), so a fleet the |
| + // console created moments ago is visible here rather than stuck |
| + // behind a stale WAL snapshot on the long-lived handle. Same |
| + // all-or-nothing error contract as everything else in Reload. |
| + fleets, err := e.reg.ListFleets() |
| + if err != nil { |
| + return err |
| + } |
| + accounts := make(map[string]string, len(fleets)) |
| + for _, f := range fleets { |
| + accounts[f.Name] = f.Account |
| + } |
| + e.fleetAccounts.Store(&accounts) |
| } |
| e.upstreams.Store(&m) |
| // Reconcile the activator with the new route set: a host un-flagged (or |
| @@ -463,6 +529,16 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // hub ends its handshake in a hijack, so it must be handed the REAL |
| // ResponseWriter, never a recordingResponseWriter wrapping it. |
| if e.tunnels != nil && host == e.tunnels.Host() { |
| + // The fleet store door (storedoor.go) shares this hostname, under |
| + // /store/ — the same host gate as registration, which is the whole |
| + // reason it is reachable at all: it is not a registry route, so no |
| + // other host can ever reach it. /register and everything else stay |
| + // the hub's. Ahead of the hub rather than behind it because the hub |
| + // 404s any path that is not /register. |
| + if e.door != nil && strings.HasPrefix(r.URL.Path, storeDoorPrefix) { |
| + e.door.ServeHTTP(w, r) |
| + return |
| + } |
| e.tunnels.ServeHTTP(w, r) |
| return |
| } |
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| new file mode 100644 |
| index 000000000..308e632fc |
| --- /dev/null |
| +++ b/internal/edge/storedoor.go |
| @@ -0,0 +1,424 @@ |
| +// The store door: release.Store spoken over HTTPS on the edge's reserved |
| +// tunnel hostname, so a fleet box needs no AWS credentials on the platform's |
| +// deployment bucket at all (spec 2026-08-11-fleet-api-surface-design.md, |
| +// F1-F4; issue #73). |
| +// |
| +// One mechanism, not N endpoints: the five Store operations, authenticated |
| +// per request by the credential the box already holds (its fleet tunnel |
| +// token, verified through TunnelHub.VerifyDynamic — the same hash table |
| +// detach/rotate already converge in seconds), and ACL'd server-side from the |
| +// fleet's registry row. The box's own loops — adoption, fast-converge, |
| +// config, status, metrics, bindings — run unchanged against the client half |
| +// (Task 3). |
| +// |
| +// Everything a caller is not entitled to is a byte-identical 404: wrong |
| +// token, static (non-fleet) name, another tenant's key, a key that does not |
| +// exist, a key shaped like a traversal. The keyspace must never be an |
| +// oracle, so no response distinguishes them, and no refusal ever costs more |
| +// than one constant-time verify. |
| +package edge |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "log" |
| + "net/http" |
| + "os" |
| + "strconv" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + // storeDoorPrefix is what edge.go routes to this handler on the reserved |
| + // tunnel hostname; /register (the tunnel handshake) stays with the hub. |
| + storeDoorPrefix = "/store/" |
| + storeObjectPrefix = "/store/o/" |
| + storeListPath = "/store/list" |
| + storeDirsPath = "/store/dirs" |
| + |
| + // maxStoreKeyBytes is the pre-ACL key ceiling (spec: keys >1024 bytes are |
| + // refused before the ACL runs). S3's own limit. |
| + maxStoreKeyBytes = 1024 |
| + // maxStoreBodyBytes bounds one PUT. Everything a box may write lives |
| + // under fleets/<own>/ — heartbeats, metrics, alerts, bindings, shipped |
| + // logs — none of which approach this; the ceiling exists so a |
| + // compromised box cannot make the edge buffer arbitrary memory. |
| + maxStoreBodyBytes = 32 << 20 |
| +) |
| + |
| +// storeDoor serves release.Store over HTTPS for fleet boxes (spec F1-F4). |
| +// |
| +// It holds the deployment-store handle the edge ALREADY has (EnableStatic's, |
| +// shared with the static and status serving classes) — never a second S3 |
| +// client, and never anything the request can influence: no client-supplied |
| +// AWS header is read, and no bucket identity is ever exposed (F8: artifact |
| +// bytes stream through the edge rather than being redirected to a presigned |
| +// URL). |
| +type storeDoor struct { |
| + hub *TunnelHub // VerifyDynamic: the one auth path, called once per request |
| + store release.Store // the deployment bucket, edge's own handle |
| + // fleetAccount maps an authenticated fleet name to its owning account's |
| + // sqid — the whole basis of the read ACL. Backed by the edge's |
| + // reload-time snapshot (Edge.fleetAccount), refreshed on the same pass |
| + // that feeds the hub's FleetChannelAuth table, so a fleet's identity and |
| + // its credentials can never come from two different reads of the |
| + // registry. |
| + fleetAccount func(fleet string) (sqid string, ok bool) |
| +} |
| + |
| +// ServeHTTP is the whole door. The order is load-bearing: |
| +// |
| +// 1. auth — exactly once, before anything else, for every path and method; |
| +// 2. fleet → account (the ACL's identity); |
| +// 3. key canonicalization (traversal, empty segments, oversize); |
| +// 4. the ACL; |
| +// 5. only then the request's own well-formedness (a PUT's precondition) and |
| +// the store. |
| +// |
| +// Steps 1-4 all answer http.NotFound, so a caller cannot learn from a |
| +// response which of them refused it — in particular a 400 for a missing |
| +// precondition is only ever reachable on a key this fleet may actually |
| +// write, never as a probe for whether such a key exists. |
| +// |
| +// The CAS mapping onto release.Store's contract (store.go), exactly: |
| +// |
| +// If-None-Match: * → Put(ifMatch: "") create-only |
| +// If-Match: <etag> → Put(ifMatch: <etag>) replace-only |
| +// neither → 400 (there is no unconditional Put in the contract) |
| +// ErrCASConflict → 412 |
| +// Get not-found → 404 (os.ErrNotExist-wrapped, per the contract) |
| +// Delete → 204, always, for an in-scope key (idempotent) |
| +// |
| +// Etags are opaque strings on this wire, passed through in both directions |
| +// exactly as the backing store produced them (DirStore's bare sha256 hex, |
| +// S3's quoted tag): the door never parses or rewrites one, so the client's |
| +// If-Match always carries the same bytes its Get was handed. |
| +func (d *storeDoor) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + // Auth, once. Same two headers as the tunnel dial (F1), same table (F2): |
| + // only fleet channels ("<fleet>/<label>") can pass — VerifyDynamic |
| + // refuses slash-less static names outright, so a CARLOS_TUNNEL_CLIENTS |
| + // credential can never open this door. The token is never logged, never |
| + // echoed, and never compared in the clear. |
| + name := r.Header.Get("Carlos-Tunnel-Name") |
| + token, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") |
| + if name == "" || !hasBearer { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + fleet, ok := d.hub.VerifyDynamic(name, token) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + sqid, ok := d.fleetAccount(fleet) |
| + if !ok || sqid == "" { |
| + // Authenticated, but the fleet's registry row is not in the edge's |
| + // current snapshot (a fleet deleted between reloads, or a hub whose |
| + // auth table was set by hand). No identity, no ACL, no answer. |
| + http.NotFound(w, r) |
| + return |
| + } |
| + switch { |
| + case strings.HasPrefix(r.URL.Path, storeObjectPrefix): |
| + d.serveObject(w, r, sqid, fleet, strings.TrimPrefix(r.URL.Path, storeObjectPrefix)) |
| + case r.URL.Path == storeListPath: |
| + d.serveListing(w, r, sqid, fleet, false) |
| + case r.URL.Path == storeDirsPath: |
| + d.serveListing(w, r, sqid, fleet, true) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| +} |
| + |
| +// serveObject is Get/Put/Delete on one key. rawKey is the path remainder, |
| +// already percent-decoded by net/http — which is exactly what must be |
| +// canonicalized: an encoded "%2e%2e" arrives here as a real ".." segment. |
| +func (d *storeDoor) serveObject(w http.ResponseWriter, r *http.Request, sqid, fleet, rawKey string) { |
| + key, ok := canonicalKey(rawKey) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + ctx := r.Context() |
| + switch r.Method { |
| + case http.MethodGet: |
| + if !aclRead(sqid, fleet, key) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + body, etag, err := d.store.Get(ctx, key) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) // byte-identical to every refusal above |
| + return |
| + } |
| + d.storeFailed(w, r, "get", key, err) |
| + return |
| + } |
| + if etag != "" { |
| + w.Header().Set("ETag", etag) |
| + } |
| + w.Header().Set("Content-Type", "application/octet-stream") |
| + w.Header().Set("Content-Length", strconv.Itoa(len(body))) |
| + w.Write(body) |
| + case http.MethodPut: |
| + if !aclWrite(fleet, key) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + // Precondition parsing is AFTER the ACL on purpose: a 400 here must |
| + // never be reachable for a key the caller may not write, or the |
| + // difference between 400 and 404 would map the keyspace. |
| + ifMatch, ok := putPrecondition(r) |
| + if !ok { |
| + http.Error(w, "want If-Match: <etag> or If-None-Match: *", http.StatusBadRequest) |
| + return |
| + } |
| + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxStoreBodyBytes)) |
| + if err != nil { |
| + var tooLarge *http.MaxBytesError |
| + if errors.As(err, &tooLarge) { |
| + http.Error(w, "body too large", http.StatusRequestEntityTooLarge) |
| + return |
| + } |
| + http.Error(w, "read body", http.StatusBadRequest) |
| + return |
| + } |
| + etag, err := d.store.Put(ctx, key, body, ifMatch) |
| + if err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + // The client maps this back to release.ErrCASConflict, so a |
| + // heartbeat or binding takeover keeps its exact semantics |
| + // through the proxy (F4). |
| + http.Error(w, "precondition failed", http.StatusPreconditionFailed) |
| + return |
| + } |
| + d.storeFailed(w, r, "put", key, err) |
| + return |
| + } |
| + if etag != "" { |
| + w.Header().Set("ETag", etag) |
| + } |
| + w.WriteHeader(http.StatusOK) |
| + case http.MethodDelete: |
| + if !aclWrite(fleet, key) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err := d.store.Delete(ctx, key); err != nil { |
| + d.storeFailed(w, r, "delete", key, err) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) // idempotent, per the Store contract |
| + default: |
| + http.NotFound(w, r) |
| + } |
| +} |
| + |
| +// serveListing is List (dirs=false) and ListDirs (dirs=true). The requested |
| +// prefix is scope-MAPPED rather than merely checked (aclListScope): a caller |
| +// asking for "apps/" gets its own account's subtree, which is what lets an |
| +// unmodified release.Store caller work through the door. Every returned key |
| +// is then re-checked against the read ACL, so the answer is in scope even if |
| +// a future mapping rule is ever wrong. |
| +func (d *storeDoor) serveListing(w http.ResponseWriter, r *http.Request, sqid, fleet string, dirs bool) { |
| + if r.Method != http.MethodGet { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + prefix, ok := canonicalPrefix(r.URL.Query().Get("prefix")) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + scope, ok := aclListScope(sqid, fleet, prefix) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + out := []string{} // never null on the wire: an empty listing is [] |
| + if dirs { |
| + got, err := d.store.ListDirs(r.Context(), scope) |
| + if err != nil { |
| + d.storeFailed(w, r, "listdirs", scope, err) |
| + return |
| + } |
| + for _, dir := range got { |
| + // A child prefix is in scope exactly when it is under the scope |
| + // the ACL granted; aclRead judges objects, not directories. |
| + if strings.HasPrefix(dir, scope) { |
| + out = append(out, dir) |
| + } |
| + } |
| + } else { |
| + got, err := d.store.List(r.Context(), scope) |
| + if err != nil { |
| + d.storeFailed(w, r, "list", scope, err) |
| + return |
| + } |
| + for _, key := range got { |
| + if aclRead(sqid, fleet, key) { |
| + out = append(out, key) |
| + } |
| + } |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + _ = json.NewEncoder(w).Encode(out) |
| +} |
| + |
| +// storeFailed answers a genuine backing-store failure. It is deliberately NOT |
| +// folded into the 404 shape: a bucket outage that looked like "the object is |
| +// gone" would make a box act on absence (a missing channel pointer, an empty |
| +// listing) instead of retrying. It is no keyspace oracle either — it is only |
| +// reachable once the key has already passed canonicalization and the ACL, so |
| +// the caller learns nothing it was not already entitled to. The log line |
| +// carries the key and the error; never the token, never the auth header. |
| +func (d *storeDoor) storeFailed(w http.ResponseWriter, r *http.Request, op, key string, err error) { |
| + log.Printf("edge: store door: %s %s: %v", op, key, err) |
| + http.Error(w, "store error", http.StatusBadGateway) |
| +} |
| + |
| +// putPrecondition maps the request's conditional headers onto release.Store's |
| +// ifMatch argument. The Store contract has no unconditional Put — "" is |
| +// create-only (If-None-Match: *) and non-empty is replace-only — so a PUT |
| +// carrying neither header is a client bug, refused rather than silently |
| +// turned into a create. Both headers together is equally ill-defined. |
| +// If-None-Match is honoured only as the literal "*": an etag list there would |
| +// mean something this wire does not implement. |
| +func putPrecondition(r *http.Request) (ifMatch string, ok bool) { |
| + im := strings.TrimSpace(r.Header.Get("If-Match")) |
| + inm := strings.TrimSpace(r.Header.Get("If-None-Match")) |
| + switch { |
| + case im != "" && inm != "": |
| + return "", false |
| + case inm == "*": |
| + return "", true |
| + case inm != "": |
| + return "", false |
| + case im != "": |
| + return im, true |
| + default: |
| + return "", false |
| + } |
| +} |
| + |
| +// canonicalKey is the pre-ACL gate: it refuses, rather than cleans, anything |
| +// that could re-point a prefix — "..", "." and empty segments (which covers a |
| +// leading "/", a trailing "/" and any "//"), an empty key, control bytes, and |
| +// anything over maxStoreKeyBytes. Refusing rather than normalizing is the |
| +// point: the ACL below is prefix-string exact, and a key that had to be |
| +// rewritten to be safe is a key whose author meant something else. |
| +// |
| +// It runs BEFORE the ACL, so "apps/<own>/../<other>/x" never reaches a prefix |
| +// comparison that would have approved its first segment. |
| +func canonicalKey(raw string) (string, bool) { |
| + if raw == "" || len(raw) > maxStoreKeyBytes { |
| + return "", false |
| + } |
| + if strings.HasPrefix(raw, "/") || strings.HasSuffix(raw, "/") { |
| + return "", false |
| + } |
| + for i := 0; i < len(raw); i++ { |
| + if raw[i] < 0x20 || raw[i] == 0x7f { |
| + return "", false |
| + } |
| + } |
| + for _, seg := range strings.Split(raw, "/") { |
| + if seg == "" || seg == "." || seg == ".." { |
| + return "", false |
| + } |
| + } |
| + return raw, true |
| +} |
| + |
| +// canonicalPrefix is canonicalKey for a LISTING prefix: same refusals, except |
| +// that a prefix may end in "/" (and usually does — "apps/", "fleets/pi/"). |
| +// The trailing slash is preserved, because aclListScope's mapping rules are |
| +// exact string matches and "apps" must not be allowed to stand in for a |
| +// prefix that would also match "appsomething/". |
| +func canonicalPrefix(raw string) (string, bool) { |
| + if _, ok := canonicalKey(strings.TrimSuffix(raw, "/")); !ok { |
| + return "", false |
| + } |
| + return raw, true |
| +} |
| + |
| +// aclRead is the read half of F3, prefix-string exact against the |
| +// authenticated fleet's own identity: |
| +// |
| +// control/serial the convergence poll |
| +// bootstrap/carlos the sanctioned self-update path (F7) |
| +// accounts/<sqid>.json its owning account's record |
| +// apps/<sqid>/** its owning account's whole app estate |
| +// fleets/<fleet>/** its own corner |
| +// |
| +// The last line is not in F3's enumeration and is not a widening of it: the |
| +// fleet's own prefix is the only place it may WRITE, and every write on this |
| +// wire is a CAS (read the etag, then Put with If-Match). A write scope you |
| +// cannot read is a write scope you cannot use — the binding takeover guard |
| +// and the heartbeat both read their own object first. It grants access to |
| +// nothing beyond what aclWrite already grants. |
| +func aclRead(sqid, fleet, key string) bool { |
| + if sqid == "" || fleet == "" { |
| + return false |
| + } |
| + switch key { |
| + case "control/serial", "bootstrap/carlos", "accounts/" + sqid + ".json": |
| + return true |
| + } |
| + return strings.HasPrefix(key, "apps/"+sqid+"/") || |
| + strings.HasPrefix(key, "fleets/"+fleet+"/") |
| +} |
| + |
| +// aclWrite is the write half of F3 — and DELETE's half too, which is why it |
| +// takes no sqid: a box may touch its own fleet corner and nothing else. In |
| +// particular it can never delete a release artifact, which is the tamper |
| +// surface the whole spec exists to close (F6: retention does not arm on a |
| +// fleet box either). |
| +func aclWrite(fleet, key string) bool { |
| + if fleet == "" { |
| + return false |
| + } |
| + return strings.HasPrefix(key, "fleets/"+fleet+"/") |
| +} |
| + |
| +// aclListScope maps a requested listing prefix onto the prefix the door will |
| +// actually list, or refuses. Mapping rather than refusing is what keeps |
| +// unmodified release.Store callers working: "apps/" is what platform code |
| +// asks for, and a fleet box asking it gets its own account's subtree back |
| +// (spec F3: "list of accounts/ and apps/ answers only the own-account |
| +// subset"). A bare "apps"/"fleets"/"accounts" (no trailing slash) maps the |
| +// same way; anything narrower must already be inside the caller's own |
| +// subtree, and a partial sqid or fleet name ("apps/ab1", "fleets/pix/") is |
| +// refused rather than widened, since S3 prefix matching would otherwise reach |
| +// a sibling tenant. |
| +// |
| +// accounts/ is the one scope that maps to a SHARED prefix: the caller may see |
| +// only its own record there, which serveListing enforces by re-checking every |
| +// returned key against aclRead. |
| +func aclListScope(sqid, fleet, prefix string) (string, bool) { |
| + if sqid == "" || fleet == "" || prefix == "" { |
| + return "", false |
| + } |
| + ownApps := "apps/" + sqid + "/" |
| + ownFleet := "fleets/" + fleet + "/" |
| + switch { |
| + case prefix == "apps" || prefix == "apps/" || prefix == ownApps[:len(ownApps)-1]: |
| + return ownApps, true |
| + case strings.HasPrefix(prefix, ownApps): |
| + return prefix, true |
| + case strings.HasPrefix(prefix, "apps/"): |
| + return "", false |
| + case prefix == "accounts" || prefix == "accounts/": |
| + return "accounts/", true |
| + case prefix == "fleets" || prefix == "fleets/" || prefix == ownFleet[:len(ownFleet)-1]: |
| + return ownFleet, true |
| + case strings.HasPrefix(prefix, ownFleet): |
| + return prefix, true |
| + default: |
| + return "", false |
| + } |
| +} |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| new file mode 100644 |
| index 000000000..b13c238d3 |
| --- /dev/null |
| +++ b/internal/edge/storedoor_test.go |
| @@ -0,0 +1,615 @@ |
| +package edge |
| + |
| +// Task 2: the store door — the ACL is the whole security story, so it is |
| +// tested as a matrix BEFORE any HTTP shape exists: every key class from the |
| +// spec's #73 inventory × operation × own/other tenancy, with the exact keys a |
| +// real box uses. A row that flips here is a tenancy breach, not a test nit. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The two identities every row is judged against: fleet "pi" owned by account |
| +// sqid "ab12". "OTHER"/"zz99" is any other tenant. |
| +const ( |
| + testSqid = "ab12" |
| + testFleet = "pi" |
| +) |
| + |
| +type aclOp int |
| + |
| +const ( |
| + opRead aclOp = iota |
| + opWrite |
| + opDelete |
| +) |
| + |
| +func (o aclOp) String() string { |
| + switch o { |
| + case opRead: |
| + return "read" |
| + case opWrite: |
| + return "write" |
| + default: |
| + return "delete" |
| + } |
| +} |
| + |
| +// aclAllows is the door's decision for one (key, op), canonicalization |
| +// included — exactly the order the handler runs it in: canonicalKey first, so |
| +// a traversal key is refused before any ACL string ever sees it. |
| +func aclAllows(key string, op aclOp) bool { |
| + k, ok := canonicalKey(key) |
| + if !ok { |
| + return false |
| + } |
| + if op == opRead { |
| + return aclRead(testSqid, testFleet, k) |
| + } |
| + return aclWrite(testFleet, k) |
| +} |
| + |
| +func TestStoreDoorACLMatrix(t *testing.T) { |
| + rows := []struct { |
| + key string |
| + op aclOp |
| + want bool |
| + why string |
| + }{ |
| + // --- the shared platform reads the spec allowlists (F3, F7) --- |
| + {"control/serial", opRead, true, "the 2s convergence poll"}, |
| + {"control/serial", opWrite, false, "a box must never bump the platform serial"}, |
| + {"control/serial", opDelete, false, "nor delete it"}, |
| + {"bootstrap/carlos", opRead, true, "F7: the sanctioned self-update path"}, |
| + {"bootstrap/carlos", opWrite, false, "a box must never publish the platform binary"}, |
| + {"control/serial2", opRead, false, "the allowlist is exact, not a prefix"}, |
| + {"control/", opRead, false, "no listing-shaped key stands in for the allowlist"}, |
| + {"bootstrap/carlos.sig", opRead, false, "exact key only"}, |
| + |
| + // --- own account estate --- |
| + {"accounts/ab12.json", opRead, true, "own account record"}, |
| + {"accounts/zz99.json", opRead, false, "another tenant's account record"}, |
| + {"accounts/ab12.json", opWrite, false, "reads only, even of its own account"}, |
| + {"apps/ab12/web/channels/stable.json", opRead, true, "own app channel pointer"}, |
| + {"apps/ab12/web/releases/v1.2.3/manifest.json", opRead, true, "own release manifest"}, |
| + {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opRead, true, "own artifact (F8: streamed, not presigned)"}, |
| + {"apps/ab12/web/config/production.json", opRead, true, "own config bundle"}, |
| + {"apps/ab12/web/restart-gen.json", opRead, true, "own restart generation"}, |
| + {"apps/ab12/web/channels/stable.json", opWrite, false, "a box never promotes"}, |
| + {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opDelete, false, "retention is not a box's job (F6)"}, |
| + |
| + // --- other tenants --- |
| + {"apps/zz99/web/channels/stable.json", opRead, false, "another account's app"}, |
| + {"apps/ab12x/web/channels/stable.json", opRead, false, "sqid prefix must not match a longer sqid"}, |
| + {"apps/ab1/web/channels/stable.json", opRead, false, "nor a shorter one"}, |
| + {"apps/ab12", opRead, false, "the account prefix itself is not an object"}, |
| + |
| + // --- own fleet corner: the only writable place there is --- |
| + {"fleets/pi/bindings/app.example.json", opRead, true, "the binding takeover guard reads before it CASes"}, |
| + {"fleets/pi/bindings/app.example.json", opWrite, true, "fleet bindings are the box's own message"}, |
| + {"fleets/pi/status/boxes/box-a.json", opWrite, true, "own heartbeat"}, |
| + {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opWrite, true, "own metrics"}, |
| + {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opDelete, true, "own 48h metrics prune"}, |
| + {"fleets/other/status/boxes/box-a.json", opRead, false, "another fleet's corner"}, |
| + {"fleets/other/status/boxes/box-a.json", opWrite, false, "and it cannot write there"}, |
| + {"fleets/other/status/boxes/box-a.json", opDelete, false, "nor delete there"}, |
| + {"fleets/pix/status/boxes/box-a.json", opWrite, false, "fleet prefix must not match a longer fleet name"}, |
| + {"fleets/pi", opWrite, false, "the fleet prefix itself is not an object"}, |
| + |
| + // --- shapes canonicalKey must kill before the ACL is consulted --- |
| + {"apps/ab12/../zz99/web/channels/stable.json", opRead, false, "traversal out of own account"}, |
| + {"fleets/pi/../other/status/boxes/box-a.json", opWrite, false, "traversal out of own fleet"}, |
| + {"../etc/passwd", opRead, false, "traversal below the keyspace"}, |
| + {"/control/serial", opRead, false, "leading slash"}, |
| + {"apps//ab12/web/channels/stable.json", opRead, false, "empty segment"}, |
| + {"apps/ab12/./web/channels/stable.json", opRead, false, "dot segment"}, |
| + {"", opRead, false, "empty key"}, |
| + } |
| + for _, row := range rows { |
| + got := aclAllows(row.key, row.op) |
| + if got != row.want { |
| + t.Errorf("acl %s %q = %v, want %v (%s)", row.op, row.key, got, row.want, row.why) |
| + } |
| + } |
| +} |
| + |
| +// TestCanonicalKeyRefusesShapes pins the pre-ACL gate on its own: the ACL is |
| +// prefix-string exact, which is only safe because nothing that could re-point |
| +// a prefix ever reaches it. |
| +func TestCanonicalKeyRefusesShapes(t *testing.T) { |
| + bad := []string{ |
| + "", |
| + "/apps/ab12/x", |
| + "apps//ab12/x", |
| + "apps/ab12/../zz99/x", |
| + "..", |
| + "../x", |
| + "apps/ab12/x/..", |
| + "apps/./ab12/x", |
| + "apps/ab12/x/", |
| + "apps/ab12/\x00x", |
| + "apps/ab12/x\ny", |
| + strings.Repeat("a", 1025), |
| + } |
| + for _, k := range bad { |
| + if got, ok := canonicalKey(k); ok { |
| + t.Errorf("canonicalKey(%q) = (%q, true), want refused", k, got) |
| + } |
| + } |
| + good := []string{ |
| + "control/serial", |
| + "bootstrap/carlos", |
| + "accounts/ab12.json", |
| + "apps/ab12/web/releases/v1.2.3/app.tar.gz", |
| + "fleets/pi/bindings/app.example.json", |
| + strings.Repeat("a", 1024), |
| + } |
| + for _, k := range good { |
| + got, ok := canonicalKey(k) |
| + if !ok || got != k { |
| + t.Errorf("canonicalKey(%q) = (%q, %v), want (%q, true)", k, got, ok, k) |
| + } |
| + } |
| +} |
| + |
| +// TestACLListScope: a listing is scope-MAPPED, never scope-checked — asking |
| +// for "apps/" gets you your own account's subtree, not a refusal, because |
| +// that is what makes an unmodified release.Store caller work through the door. |
| +func TestACLListScope(t *testing.T) { |
| + cases := []struct { |
| + prefix string |
| + want string |
| + ok bool |
| + }{ |
| + {"apps/", "apps/ab12/", true}, |
| + {"apps", "apps/ab12/", true}, |
| + {"apps/ab12", "apps/ab12/", true}, |
| + {"apps/ab12/", "apps/ab12/", true}, |
| + {"apps/ab12/web/releases/", "apps/ab12/web/releases/", true}, |
| + {"apps/zz99/", "", false}, |
| + {"apps/ab1", "", false}, |
| + {"apps/ab12x/", "", false}, |
| + {"accounts/", "accounts/", true}, |
| + {"accounts", "accounts/", true}, |
| + {"accounts/zz99.json", "", false}, |
| + {"fleets/", "fleets/pi/", true}, |
| + {"fleets/pi/status/boxes/", "fleets/pi/status/boxes/", true}, |
| + {"fleets/other/", "", false}, |
| + {"control/", "", false}, |
| + {"bootstrap/", "", false}, |
| + {"", "", false}, |
| + {"status/", "", false}, |
| + } |
| + for _, c := range cases { |
| + got, ok := aclListScope(testSqid, testFleet, c.prefix) |
| + if got != c.want || ok != c.ok { |
| + t.Errorf("aclListScope(%q) = (%q, %v), want (%q, %v)", c.prefix, got, ok, c.want, c.ok) |
| + } |
| + } |
| +} |
| + |
| +// ---- handler tests ------------------------------------------------------ |
| + |
| +// The credentials every handler test runs with: a fleet channel |
| +// "<fleet>/<label>" whose token hash sits in the hub's dynamic table (the |
| +// registry-backed source), alongside a STATIC client which must never open |
| +// this door. |
| +const ( |
| + testChannel = testFleet + "/box-a" |
| + testToken = "box-a-bearer-token" |
| + staticName = "hq" |
| + staticToken = "static-client-token" |
| + ownKey = "apps/" + testSqid + "/web/channels/stable.json" |
| + ownFleetKey = "fleets/" + testFleet + "/status/boxes/box-a.json" |
| + otherAccountKey = "apps/zz99/web/channels/stable.json" |
| +) |
| + |
| +// newTestDoor builds a door over a DirStore with the identities above — the |
| +// same wiring edge.go does in production (hub + the edge's own store handle + |
| +// the reload-time fleet→account snapshot). |
| +func newTestDoor(t *testing.T) (*storeDoor, *release.DirStore) { |
| + t.Helper() |
| + hub := NewTunnelHub("tunnel.test", map[string]string{staticName: staticToken}) |
| + hub.SetDynamicAuth(map[string]string{testChannel: sha256Hex(testToken)}) |
| + store := release.NewDirStore(t.TempDir()) |
| + return &storeDoor{hub: hub, store: store, fleetAccount: func(fleet string) (string, bool) { |
| + if fleet == testFleet { |
| + return testSqid, true |
| + } |
| + return "", false |
| + }}, store |
| +} |
| + |
| +// doorReq builds a request with the given credentials; an empty token sends |
| +// no Authorization header at all, an empty name no Carlos-Tunnel-Name. |
| +func doorReq(method, target, name, token, body string) *http.Request { |
| + var r *http.Request |
| + if body == "" { |
| + r = httptest.NewRequest(method, target, nil) |
| + } else { |
| + r = httptest.NewRequest(method, target, strings.NewReader(body)) |
| + } |
| + if name != "" { |
| + r.Header.Set("Carlos-Tunnel-Name", name) |
| + } |
| + if token != "" { |
| + r.Header.Set("Authorization", "Bearer "+token) |
| + } |
| + return r |
| +} |
| + |
| +// doorDo runs one correctly authenticated request through the door. |
| +func doorDo(d *storeDoor, method, target, body string, hdr map[string]string) *httptest.ResponseRecorder { |
| + r := doorReq(method, target, testChannel, testToken, body) |
| + for k, v := range hdr { |
| + r.Header.Set(k, v) |
| + } |
| + w := httptest.NewRecorder() |
| + d.ServeHTTP(w, r) |
| + return w |
| +} |
| + |
| +func seedObject(t *testing.T, s *release.DirStore, key, body string) string { |
| + t.Helper() |
| + etag, err := s.Put(context.Background(), key, []byte(body), "") |
| + if err != nil { |
| + t.Fatalf("seed %s: %v", key, err) |
| + } |
| + return etag |
| +} |
| + |
| +func TestStoreDoorGetServesOwnEstate(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + etag := seedObject(t, store, ownKey, `{"version":"v1"}`) |
| + seedObject(t, store, "control/serial", "42") |
| + seedObject(t, store, "bootstrap/carlos", "ELF...") |
| + |
| + for _, key := range []string{ownKey, "control/serial", "bootstrap/carlos"} { |
| + w := doorDo(d, "GET", "/store/o/"+key, "", nil) |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", key, w.Code) |
| + } |
| + } |
| + w := doorDo(d, "GET", "/store/o/"+ownKey, "", nil) |
| + if got := w.Body.String(); got != `{"version":"v1"}` { |
| + t.Errorf("body = %q, want the stored object", got) |
| + } |
| + if got := w.Header().Get("ETag"); got != etag { |
| + t.Errorf("ETag = %q, want the backing store's %q (etags are opaque, passed through)", got, etag) |
| + } |
| +} |
| + |
| +// TestStoreDoorCASRoundTrip pins F4 on the wire: there is no unconditional |
| +// Put, a lost race is 412, and a won race hands back the new etag. |
| +func TestStoreDoorCASRoundTrip(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + path := "/store/o/" + ownFleetKey |
| + |
| + if w := doorDo(d, "PUT", path, "first", nil); w.Code != http.StatusBadRequest { |
| + t.Fatalf("PUT with no precondition = %d, want 400 (the Store contract has no blind write)", w.Code) |
| + } |
| + if _, _, err := store.Get(context.Background(), ownFleetKey); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("a refused PUT still created the object") |
| + } |
| + if w := doorDo(d, "PUT", path, "x", map[string]string{"If-Match": "abc", "If-None-Match": "*"}); w.Code != http.StatusBadRequest { |
| + t.Errorf("PUT with both preconditions = %d, want 400", w.Code) |
| + } |
| + |
| + w := doorDo(d, "PUT", path, "first", map[string]string{"If-None-Match": "*"}) |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("create-only PUT = %d, want 200", w.Code) |
| + } |
| + e1 := w.Header().Get("ETag") |
| + if e1 == "" { |
| + t.Fatal("create-only PUT returned no ETag") |
| + } |
| + if w := doorDo(d, "PUT", path, "again", map[string]string{"If-None-Match": "*"}); w.Code != http.StatusPreconditionFailed { |
| + t.Errorf("create-only PUT over an existing object = %d, want 412", w.Code) |
| + } |
| + if w := doorDo(d, "PUT", path, "stale", map[string]string{"If-Match": "0000"}); w.Code != http.StatusPreconditionFailed { |
| + t.Errorf("replace-only PUT with a stale etag = %d, want 412", w.Code) |
| + } |
| + w2 := doorDo(d, "PUT", path, "second", map[string]string{"If-Match": e1}) |
| + if w2.Code != http.StatusOK { |
| + t.Fatalf("replace-only PUT with the fresh etag = %d, want 200", w2.Code) |
| + } |
| + if e2 := w2.Header().Get("ETag"); e2 == "" || e2 == e1 { |
| + t.Errorf("ETag after replace = %q, want a new one (was %q)", e2, e1) |
| + } |
| + body, _, err := store.Get(context.Background(), ownFleetKey) |
| + if err != nil || string(body) != "second" { |
| + t.Fatalf("stored body = %q, %v; want \"second\"", body, err) |
| + } |
| +} |
| + |
| +func TestStoreDoorDeleteIsScopedAndIdempotent(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + seedObject(t, store, ownFleetKey, "{}") |
| + seedObject(t, store, ownKey, "{}") |
| + |
| + for i := 0; i < 2; i++ { |
| + if w := doorDo(d, "DELETE", "/store/o/"+ownFleetKey, "", nil); w.Code != http.StatusNoContent { |
| + t.Fatalf("DELETE own fleet key (attempt %d) = %d, want 204", i+1, w.Code) |
| + } |
| + } |
| + if w := doorDo(d, "DELETE", "/store/o/"+ownKey, "", nil); w.Code != http.StatusNotFound { |
| + t.Fatalf("DELETE a release object = %d, want 404 (retention is not a box's job)", w.Code) |
| + } |
| + if _, _, err := store.Get(context.Background(), ownKey); err != nil { |
| + t.Fatalf("the refused DELETE removed the object anyway: %v", err) |
| + } |
| +} |
| + |
| +// TestStoreDoorRefusalsAreByteIdentical is the spec's central security |
| +// invariant: bad auth, a static (non-fleet) name, another tenant's key, a |
| +// missing in-scope key and a malformed key must be INDISTINGUISHABLE — same |
| +// status, same headers, same bytes — or the door becomes a keyspace oracle. |
| +func TestStoreDoorRefusalsAreByteIdentical(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + seedObject(t, store, otherAccountKey, "someone else's") |
| + seedObject(t, store, "fleets/other/status/boxes/box-a.json", "another fleet's") |
| + |
| + req := func(name, token, method, target string) *http.Request { |
| + return doorReq(method, target, name, token, "") |
| + } |
| + cases := map[string]*http.Request{ |
| + "wrong token": req(testChannel, "not-the-token", "GET", "/store/o/"+ownKey), |
| + "no auth at all": req("", "", "GET", "/store/o/"+ownKey), |
| + "static name + token": req(staticName, staticToken, "GET", "/store/o/control/serial"), |
| + "unknown fleet": req("nosuch/box", testToken, "GET", "/store/o/control/serial"), |
| + "other account's key": req(testChannel, testToken, "GET", "/store/o/"+otherAccountKey), |
| + "other fleet's key": req(testChannel, testToken, "GET", "/store/o/fleets/other/status/boxes/box-a.json"), |
| + "missing in-scope key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/web/nope.json"), |
| + "traversal key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/../zz99/x"), |
| + "empty key": req(testChannel, testToken, "GET", "/store/o/"), |
| + "write out of scope": req(testChannel, testToken, "DELETE", "/store/o/"+otherAccountKey), |
| + "list out of scope": req(testChannel, testToken, "GET", "/store/list?prefix=apps/zz99/"), |
| + "unknown path": req(testChannel, testToken, "GET", "/store/nope"), |
| + "wrong method": req(testChannel, testToken, "POST", "/store/o/"+ownKey), |
| + } |
| + type shape struct { |
| + code int |
| + header string |
| + body string |
| + } |
| + var want *shape |
| + var wantName string |
| + for name, r := range cases { |
| + w := httptest.NewRecorder() |
| + d.ServeHTTP(w, r) |
| + got := shape{w.Code, fmt.Sprintf("%v", w.Header()), w.Body.String()} |
| + if got.code != http.StatusNotFound { |
| + t.Errorf("%s: status = %d, want 404", name, got.code) |
| + } |
| + if strings.Contains(got.body, testToken) || strings.Contains(got.header, testToken) { |
| + t.Errorf("%s: response echoes the bearer token", name) |
| + } |
| + if want == nil { |
| + g := got |
| + want, wantName = &g, name |
| + continue |
| + } |
| + if got != *want { |
| + t.Errorf("%q answers %+v but %q answers %+v — the two refusals are distinguishable", name, got, wantName, *want) |
| + } |
| + } |
| +} |
| + |
| +// TestStoreDoorListScopeStripsOtherTenants: a listing is the easiest place to |
| +// leak a neighbour, so every listing route is checked against a store that |
| +// holds another account's and another fleet's objects. |
| +func TestStoreDoorListScopeStripsOtherTenants(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + seedObject(t, store, ownKey, "{}") |
| + seedObject(t, store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}") |
| + seedObject(t, store, otherAccountKey, "{}") |
| + seedObject(t, store, "accounts/"+testSqid+".json", "{}") |
| + seedObject(t, store, "accounts/zz99.json", "{}") |
| + seedObject(t, store, ownFleetKey, "{}") |
| + seedObject(t, store, "fleets/other/status/boxes/box-a.json", "{}") |
| + |
| + list := func(target string) []string { |
| + t.Helper() |
| + w := doorDo(d, "GET", target, "", nil) |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", target, w.Code) |
| + } |
| + var out []string |
| + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { |
| + t.Fatalf("GET %s: decode %q: %v", target, w.Body.String(), err) |
| + } |
| + return out |
| + } |
| + for _, c := range []struct { |
| + target string |
| + want []string |
| + }{ |
| + {"/store/list?prefix=apps/", []string{ownKey, "apps/" + testSqid + "/web/releases/v1/manifest.json"}}, |
| + {"/store/list?prefix=accounts/", []string{"accounts/" + testSqid + ".json"}}, |
| + {"/store/list?prefix=fleets/", []string{ownFleetKey}}, |
| + {"/store/list?prefix=apps/" + testSqid + "/web/releases/", []string{"apps/" + testSqid + "/web/releases/v1/manifest.json"}}, |
| + {"/store/dirs?prefix=apps/", []string{"apps/" + testSqid + "/web/"}}, |
| + {"/store/dirs?prefix=fleets/", []string{"fleets/" + testFleet + "/status/"}}, |
| + } { |
| + got := list(c.target) |
| + if !slices.Equal(got, c.want) { |
| + t.Errorf("%s = %v, want %v", c.target, got, c.want) |
| + } |
| + } |
| + // An empty listing is [] on the wire, never null: the client decodes into |
| + // a []string either way, but null would invite a nil-vs-empty distinction |
| + // the Store contract does not have. |
| + w := doorDo(d, "GET", "/store/list?prefix=apps/"+testSqid+"/nosuchapp/", "", nil) |
| + if body := strings.TrimSpace(w.Body.String()); body != "[]" { |
| + t.Errorf("empty listing body = %q, want []", body) |
| + } |
| +} |
| + |
| +// doorOpCounter records how many store operations a request performed — |
| +// enough to prove a refused request touches the bucket zero times. |
| +type doorOpCounter struct { |
| + release.Store |
| + ops int |
| +} |
| + |
| +func (c *doorOpCounter) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.ops++ |
| + return c.Store.Get(ctx, key) |
| +} |
| + |
| +func (c *doorOpCounter) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + c.ops++ |
| + return c.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (c *doorOpCounter) List(ctx context.Context, prefix string) ([]string, error) { |
| + c.ops++ |
| + return c.Store.List(ctx, prefix) |
| +} |
| + |
| +func (c *doorOpCounter) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + c.ops++ |
| + return c.Store.ListDirs(ctx, prefix) |
| +} |
| + |
| +func (c *doorOpCounter) Delete(ctx context.Context, key string) error { |
| + c.ops++ |
| + return c.Store.Delete(ctx, key) |
| +} |
| + |
| +// TestStoreDoorAuthRunsExactlyOncePerRequest pins the invariant two ways: the |
| +// handler contains exactly ONE call to VerifyDynamic (no path can skip it, |
| +// none can pay for it twice — and the auth logic itself is never |
| +// re-implemented here), and an unauthenticated request of any shape reaches |
| +// the store zero times. |
| +func TestStoreDoorAuthRunsExactlyOncePerRequest(t *testing.T) { |
| + fset := token.NewFileSet() |
| + f, err := parser.ParseFile(fset, "storedoor.go", nil, 0) |
| + if err != nil { |
| + t.Fatalf("parse storedoor.go: %v", err) |
| + } |
| + calls := 0 |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "VerifyDynamic" { |
| + calls++ |
| + } |
| + return true |
| + }) |
| + if calls != 1 { |
| + t.Errorf("storedoor.go calls VerifyDynamic %d times, want exactly 1 (one auth, one place)", calls) |
| + } |
| + |
| + d, _ := newTestDoor(t) |
| + counting := &doorOpCounter{Store: d.store} |
| + d.store = counting |
| + for _, r := range []*http.Request{ |
| + doorReq("GET", "/store/o/"+ownKey, testChannel, "wrong", ""), |
| + doorReq("PUT", "/store/o/"+ownFleetKey, testChannel, "wrong", "x"), |
| + doorReq("DELETE", "/store/o/"+ownFleetKey, testChannel, "wrong", ""), |
| + doorReq("GET", "/store/list?prefix=apps/", testChannel, "wrong", ""), |
| + doorReq("GET", "/store/dirs?prefix=apps/", testChannel, "wrong", ""), |
| + doorReq("GET", "/store/o/"+ownKey, staticName, staticToken, ""), |
| + doorReq("GET", "/store/o/"+ownKey, "", "", ""), |
| + } { |
| + w := httptest.NewRecorder() |
| + d.ServeHTTP(w, r) |
| + if w.Code != http.StatusNotFound { |
| + t.Errorf("%s %s unauthenticated = %d, want 404", r.Method, r.URL, w.Code) |
| + } |
| + } |
| + if counting.ops != 0 { |
| + t.Errorf("unauthenticated requests performed %d store operation(s), want 0", counting.ops) |
| + } |
| +} |
| + |
| +// TestStoreDoorOnlyOnReservedHost drives the door through the EDGE, not |
| +// directly: it must be unreachable on every host except the reserved tunnel |
| +// hostname, exactly like tunnel registration — and it must not have swallowed |
| +// /register on the way in. |
| +func TestStoreDoorOnlyOnReservedHost(t *testing.T) { |
| + reg := openReg(t) |
| + if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil { |
| + t.Fatalf("UpsertFleet: %v", err) |
| + } |
| + if err := reg.AddFleetBox(registry.FleetBox{Fleet: testFleet, Label: "box-a", TokenHash: sha256Hex(testToken), Status: "active"}); err != nil { |
| + t.Fatalf("AddFleetBox: %v", err) |
| + } |
| + backend, _ := hostEchoBackend(t, "the app, not the door") |
| + if err := reg.Upsert(registry.Route{Host: "app.example", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert route: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + seedObject(t, store, "control/serial", "77") |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + e.EnableTunnels(NewTunnelHub("tunnel.test", nil)) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + get := func(host, path string) (int, string) { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", srv.URL+path, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Host = host |
| + req.Header.Set("Carlos-Tunnel-Name", testChannel) |
| + req.Header.Set("Authorization", "Bearer "+testToken) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("GET %s (host %s): %v", path, host, err) |
| + } |
| + defer resp.Body.Close() |
| + b, _ := io.ReadAll(resp.Body) |
| + return resp.StatusCode, string(b) |
| + } |
| + if code, body := get("tunnel.test", "/store/o/control/serial"); code != http.StatusOK || body != "77" { |
| + t.Fatalf("on the reserved host: %d %q, want 200 \"77\"", code, body) |
| + } |
| + if code, body := get("app.example", "/store/o/control/serial"); code != http.StatusOK || body != "the app, not the door" { |
| + t.Fatalf("on a routed host: %d %q — the door answered on a host it must not serve", code, body) |
| + } |
| + if code, _ := get("nosuch.example", "/store/o/control/serial"); code == http.StatusOK { |
| + t.Fatal("the door answered on an unknown host") |
| + } |
| + // /register stays the hub's on the same hostname: no upgrade headers, so |
| + // a 400 (the hub's wire-contract refusal), never the door's 404. |
| + if code, _ := get("tunnel.test", "/register"); code != http.StatusBadRequest { |
| + t.Fatalf("/register on the reserved host = %d, want 400 (still the hub's)", code) |
| + } |
| +} |
diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go| index 9cf631a03..7f619d995 100644 |
| --- a/internal/edge/tunnel.go |
| +++ b/internal/edge/tunnel.go |
| @@ -259,8 +259,7 @@ func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn, dy |
| // authenticate into fleet "", a name no real fleet has, and every ownership |
| // check downstream compares against the fleet this returns. |
| // |
| -// The |
| -// presented token is never compared in the clear — only its sha256 hex is, |
| +// The presented token is never compared in the clear — only its sha256 hex is, |
| // against the stored hash — so a leaked dump of this table yields no usable |
| // bearer token, and the compare is constant-time so a wrong token leaks no |
| // prefix information by timing. |