| 1 | package edge |
| 2 | |
| 3 | // Task 2: the store door — the ACL is the whole security story, so it is |
| 4 | // tested as a matrix BEFORE any HTTP shape exists: every key class from the |
| 5 | // spec's #73 inventory × operation × own/other tenancy, with the exact keys a |
| 6 | // real box uses. A row that flips here is a tenancy breach, not a test nit. |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "go/ast" |
| 14 | "go/parser" |
| 15 | "go/token" |
| 16 | "io" |
| 17 | "net/http" |
| 18 | "net/http/httptest" |
| 19 | "os" |
| 20 | "path/filepath" |
| 21 | "slices" |
| 22 | "strings" |
| 23 | "testing" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/domains" |
| 26 | "github.com/carlosframework/platform/internal/plans" |
| 27 | "github.com/carlosframework/platform/internal/registry" |
| 28 | "github.com/carlosframework/platform/internal/release" |
| 29 | "time" |
| 30 | ) |
| 31 | |
| 32 | // The two identities every row is judged against: fleet "pi" owned by account |
| 33 | // sqid "ab12". "OTHER"/"zz99" is any other tenant. |
| 34 | const ( |
| 35 | testSqid = "ab12" |
| 36 | testFleet = "pi" |
| 37 | ) |
| 38 | |
| 39 | type aclOp int |
| 40 | |
| 41 | const ( |
| 42 | opRead aclOp = iota |
| 43 | opWrite |
| 44 | opDelete |
| 45 | ) |
| 46 | |
| 47 | func (o aclOp) String() string { |
| 48 | switch o { |
| 49 | case opRead: |
| 50 | return "read" |
| 51 | case opWrite: |
| 52 | return "write" |
| 53 | default: |
| 54 | return "delete" |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | // aclAllows is the door's decision for one (key, op), canonicalization |
| 59 | // included — exactly the order the handler runs it in: canonicalKey first, so |
| 60 | // a traversal key is refused before any ACL string ever sees it. |
| 61 | func aclAllows(key string, op aclOp) bool { |
| 62 | k, ok := canonicalKey(key) |
| 63 | if !ok { |
| 64 | return false |
| 65 | } |
| 66 | if op == opRead { |
| 67 | return aclRead(testSqid, testFleet, k) |
| 68 | } |
| 69 | return aclWrite(testSqid, testFleet, k) |
| 70 | } |
| 71 | |
| 72 | func TestEntitlementsAreNotReadableThroughTheEdgeDoor(t *testing.T) { |
| 73 | for _, key := range []string{ |
| 74 | "store/entitlements/" + testSqid + ".json", |
| 75 | "store/entitlements/other.json", |
| 76 | "accounts/" + testSqid + "/../../store/entitlements/" + testSqid + ".json", |
| 77 | "apps/" + testSqid + "/../../store/entitlements/" + testSqid + ".json", |
| 78 | } { |
| 79 | for _, op := range []aclOp{opRead, opWrite, opDelete} { |
| 80 | if aclAllows(key, op) { |
| 81 | t.Errorf("%s allowed for %q", op, key) |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | for _, prefix := range []string{"store/", "store/entitlements/", "store/entitlements/" + testSqid} { |
| 86 | if _, ok := aclListScope(testSqid, testFleet, prefix); ok { |
| 87 | t.Errorf("listing allowed for %q", prefix) |
| 88 | } |
| 89 | if aclDir(testSqid, testFleet, prefix) { |
| 90 | t.Errorf("directory visible: %q", prefix) |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestStoreDoorACLMatrix(t *testing.T) { |
| 96 | rows := []struct { |
| 97 | key string |
| 98 | op aclOp |
| 99 | want bool |
| 100 | why string |
| 101 | }{ |
| 102 | // --- the shared platform reads the spec allowlists (F3, F7) --- |
| 103 | {"control/serial", opRead, true, "the 2s convergence poll"}, |
| 104 | {"control/serial", opWrite, false, "a box must never bump the platform serial"}, |
| 105 | {"control/serial", opDelete, false, "nor delete it"}, |
| 106 | {"bootstrap/carlos", opRead, true, "F7: the sanctioned self-update path"}, |
| 107 | {"bootstrap/carlos", opWrite, false, "a box must never publish the platform binary"}, |
| 108 | {"control/serial2", opRead, false, "the allowlist is exact, not a prefix"}, |
| 109 | {"control/", opRead, false, "no listing-shaped key stands in for the allowlist"}, |
| 110 | {"bootstrap/carlos.sig", opRead, false, "exact key only"}, |
| 111 | {"control/system/router/stable.json", opRead, true, "the platform channel pointer (system update spec §3)"}, |
| 112 | {"control/system/router/canary.json", opRead, true, "…and the canary one"}, |
| 113 | {"control/system/router/stable.json", opWrite, false, "a box never writes a pointer"}, |
| 114 | {"control/system/router", opRead, false, "the pointer shape is exactly three segments ending .json"}, |
| 115 | {"control/system/router/stable.json/x", opRead, false, "…not four"}, |
| 116 | {"control/system/router/stable.txt", opRead, false, "…and .json"}, |
| 117 | {"bootstrap/router/v0.18.0/carlos-linux-arm64", opRead, true, "a mirrored platform artifact"}, |
| 118 | {"bootstrap/router/v0.18.0/carlos-linux-arm64", opWrite, false, "a box must never publish the platform binary"}, |
| 119 | {"bootstrap/router/v0.18.0", opRead, false, "the artifact shape is exactly three segments"}, |
| 120 | {"bootstrap/router/v0.18.0/a/b", opRead, false, "…not four"}, |
| 121 | {"bootstrap/router.sig", opRead, false, "nothing else under bootstrap/"}, |
| 122 | {"bootstrap/router/../../x", opRead, false, "traversal is refused by canonicalKey before the ACL"}, |
| 123 | {"control/system/../serial", opRead, false, "…on both prefixes"}, |
| 124 | {release.LayoutIntentKey(), opRead, true, "every box needs the fleet's layout intent to converge"}, |
| 125 | {release.LayoutIntentKey(), opWrite, false, "a box must never author the fleet's layout intent"}, |
| 126 | {release.LayoutIntentKey(), opDelete, false, "nor delete it"}, |
| 127 | {"control/layout.json2", opRead, false, "the allowlist is exact, not a prefix"}, |
| 128 | |
| 129 | // --- own account estate --- |
| 130 | // The account manifest is accounts/<sqid>/account.json |
| 131 | // (domains.AccountManifestKey), NOT accounts/<sqid>.json: a box that |
| 132 | // cannot read it sees zero accounts, and provision.Reconcile then |
| 133 | // retires every provisioned route on the box. |
| 134 | {"accounts/ab12/account.json", opRead, true, "own account manifest"}, |
| 135 | {"accounts/zz99/account.json", opRead, false, "another tenant's account manifest"}, |
| 136 | {"accounts/ab12/account.json", opWrite, false, "reads only, even of its own account"}, |
| 137 | {"accounts/ab12.json", opRead, false, "the manifest is a directory deeper; this key is not the account"}, |
| 138 | {"accounts/ab12x/account.json", opRead, false, "sqid prefix must not match a longer sqid"}, |
| 139 | {"accounts/ab12", opRead, false, "the account prefix itself is not an object"}, |
| 140 | {"apps/ab12/web/channels/stable.json", opRead, true, "own app channel pointer"}, |
| 141 | {"apps/ab12/web/releases/v1.2.3/manifest.json", opRead, true, "own release manifest"}, |
| 142 | {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opRead, true, "own artifact (F8: streamed, not presigned)"}, |
| 143 | {"apps/ab12/web/config/production.json", opRead, true, "own config bundle"}, |
| 144 | {"apps/ab12/web/restart-gen.json", opRead, true, "own restart generation"}, |
| 145 | {"apps/ab12/web/channels/stable.json", opWrite, false, "a box never promotes"}, |
| 146 | {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opDelete, false, "retention is not a box's job (F6)"}, |
| 147 | |
| 148 | // --- own account's app LOGS: the second (and only other) write scope --- |
| 149 | // applog.ChunkKey's shape, which is what logshipLoop PUTs and what its |
| 150 | // hourly prune DELETEs. The rows below the allows are the regression |
| 151 | // pins that keep this from becoming a blanket apps/<sqid>/** grant. |
| 152 | {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opWrite, true, "own app's log chunk"}, |
| 153 | {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opDelete, true, "logship prunes its own subtree hourly"}, |
| 154 | {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opRead, true, "reads already covered by the app estate"}, |
| 155 | {"apps/zz99/web/logs/box-a/2026081108/x.jsonl.gz", opWrite, false, "another account's app logs"}, |
| 156 | {"apps/zz99/web/logs/box-a/2026081108/x.jsonl.gz", opDelete, false, "nor delete them"}, |
| 157 | {"apps/ab12x/web/logs/box-a/x.jsonl.gz", opWrite, false, "sqid prefix must not match a longer sqid"}, |
| 158 | {"apps/ab12/web/instances/h.example.json", opWrite, false, "REGRESSION: the logs grant must not mint instance records"}, |
| 159 | {"apps/ab12/web/config/production.json", opWrite, false, "REGRESSION: nor rewrite config"}, |
| 160 | {"apps/ab12/web/restart-gen.json", opWrite, false, "REGRESSION: nor request its own restarts"}, |
| 161 | {"apps/ab12/web/releases/v1.2.3/manifest.json", opWrite, false, "REGRESSION: nor publish a release"}, |
| 162 | {"apps/ab12/logs/box-a/x.jsonl.gz", opWrite, false, "logs must sit under an app segment, not the account root"}, |
| 163 | {"apps/ab12/web/logs", opWrite, false, "the logs prefix itself is not an object"}, |
| 164 | {"apps/ab12/web/logs/", opWrite, false, "nor is the bare prefix with a slash"}, |
| 165 | {"apps/ab12/web/channels/logs/stable.json", opWrite, false, "\"logs\" deeper in the key grants nothing"}, |
| 166 | {"apps/ab12/weblogs/box-a/x.jsonl.gz", opWrite, false, "the segment is logs/, not a suffix of the app name"}, |
| 167 | {"apps/ab12/web/logs/../../zz99/web/logs/x.jsonl.gz", opWrite, false, "traversal out of the logs subtree"}, |
| 168 | {"apps/ab12/web/logs/../channels/stable.json", opWrite, false, "traversal out of logs into channels"}, |
| 169 | |
| 170 | // --- sibling chunk families (errors-and-analytics spec §3.2) --- |
| 171 | {"apps/ab12/web/errors/box-a/2026090710/0000000000000001-42.jsonl.gz", opWrite, true, "own app's error chunk"}, |
| 172 | {"apps/ab12/web/errors/box-a/2026090710/0000000000000001-42.jsonl.gz", opDelete, true, "errship prunes its own subtree hourly"}, |
| 173 | {"apps/ab12/web/analytics/box-a/days/2026-09-07.json", opWrite, true, "own app's analytics day file"}, |
| 174 | {"apps/zz99/web/errors/box-a/x.jsonl.gz", opWrite, false, "another account's error chunks"}, |
| 175 | {"apps/ab12/errors/box-a/x.jsonl.gz", opWrite, false, "errors must sit under an app segment"}, |
| 176 | {"apps/ab12/web/errors", opWrite, false, "the bare family segment is not a chunk"}, |
| 177 | {"apps/ab12/web/errors/", opWrite, false, "nor with a slash"}, |
| 178 | {"apps/ab12/web/other/box-a/x.jsonl.gz", opWrite, false, "the family list is exact: logs, errors, analytics"}, |
| 179 | {"apps/ab12/web/channels/errors/stable.json", opWrite, false, "\"errors\" deeper in the key grants nothing"}, |
| 180 | {"apps/ab12/web/errorsx/box-a/x.jsonl.gz", opWrite, false, "the segment is errors/, not a prefix of a longer name"}, |
| 181 | |
| 182 | // --- own account's promotion records (settings spec §4.1, ruling R17) --- |
| 183 | // Signed digests of the account's own builds, which HeldAuthorized |
| 184 | // reads on a fleet box. One shape, exactly five segments; nothing else |
| 185 | // under releases/ and never a write. |
| 186 | {"releases/ab12/web/promoted/v1.2.3", opRead, true, "own account's promotion record"}, |
| 187 | {"releases/zz99/web/promoted/v1.2.3", opRead, false, "another account's promotion record"}, |
| 188 | {"releases/ab12x/web/promoted/v1.2.3", opRead, false, "sqid must match exactly, not as a prefix"}, |
| 189 | {"releases/ab12/web/promoted/v1.2.3/x", opRead, false, "a sixth segment"}, |
| 190 | {"releases/ab12/web/promoted", opRead, false, "a missing version"}, |
| 191 | {"releases/ab12/web/promoted/", opRead, false, "an empty version"}, |
| 192 | {"releases/ab12/pointer-digests", opRead, false, "the account's digest marker is not granted"}, |
| 193 | {"releases/ab12/web/other/v1.2.3", opRead, false, "segment 3 is exactly promoted"}, |
| 194 | {"releases/ab12/Web!/promoted/v1.2.3", opRead, false, "an invalid app"}, |
| 195 | {"releases/ab12/web/promoted/v1~bad", opRead, false, "an invalid version"}, |
| 196 | {"releases/ab12/web/promoted/v1.2.3", opWrite, false, "a box never writes a promotion record"}, |
| 197 | {"releases/ab12/web/promoted/v1.2.3", opDelete, false, "nor deletes one"}, |
| 198 | {"releases/ab12/web/promoted/../../zz99/web/promoted/v1", opRead, false, "traversal out of own account"}, |
| 199 | |
| 200 | // --- other tenants --- |
| 201 | {"apps/zz99/web/channels/stable.json", opRead, false, "another account's app"}, |
| 202 | {"apps/ab12x/web/channels/stable.json", opRead, false, "sqid prefix must not match a longer sqid"}, |
| 203 | {"apps/ab1/web/channels/stable.json", opRead, false, "nor a shorter one"}, |
| 204 | {"apps/ab12", opRead, false, "the account prefix itself is not an object"}, |
| 205 | |
| 206 | // --- own fleet corner: the only writable place there is --- |
| 207 | {"fleets/pi/bindings/app.example.json", opRead, true, "the binding takeover guard reads before it CASes"}, |
| 208 | {"fleets/pi/bindings/app.example.json", opWrite, true, "fleet bindings are the box's own message"}, |
| 209 | {"fleets/pi/status/boxes/box-a.json", opWrite, true, "own heartbeat"}, |
| 210 | {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opWrite, true, "own metrics"}, |
| 211 | {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opDelete, true, "own 48h metrics prune"}, |
| 212 | {"fleets/other/status/boxes/box-a.json", opRead, false, "another fleet's corner"}, |
| 213 | {"fleets/other/status/boxes/box-a.json", opWrite, false, "and it cannot write there"}, |
| 214 | {"fleets/other/status/boxes/box-a.json", opDelete, false, "nor delete there"}, |
| 215 | {"fleets/pix/status/boxes/box-a.json", opWrite, false, "fleet prefix must not match a longer fleet name"}, |
| 216 | {"fleets/pi", opWrite, false, "the fleet prefix itself is not an object"}, |
| 217 | |
| 218 | // --- domains/ claims: never through aclRead (#215) — serveClaim |
| 219 | // decides per claim body, own account and verified only (see |
| 220 | // TestStoreDoorClaimsAreOwnAccountAndRedacted); console-only write --- |
| 221 | {"domains/app.example.com.json", opRead, false, "claims are served by serveClaim, not the prefix ACL"}, |
| 222 | {"domains/app.example.com.json", opWrite, false, "boxes read claims, only the console writes them"}, |
| 223 | {"domains/app.example.com.json", opDelete, false, "nor delete them"}, |
| 224 | {"domains", opRead, false, "an object AT the bare name, not the claims prefix"}, |
| 225 | |
| 226 | // --- the self-serve storage entitlement is console-only (#381) --- |
| 227 | // store/entitlements/<sqid>.json carries the applicant's note, the |
| 228 | // deciding operator's address and the decline REASON — an operator's |
| 229 | // commentary about a tenant. It lives outside accounts/ precisely so |
| 230 | // that no fleet credential can read it; had it gone to the obvious |
| 231 | // accounts/<sqid>/store.json, the account's own boxes could. |
| 232 | {"store/entitlements/" + testSqid + ".json", opRead, false, "a box never reads its account's entitlement, own sqid or not"}, |
| 233 | {"store/entitlements/zz99.json", opRead, false, "still less another account's"}, |
| 234 | {"store/entitlements/" + testSqid + ".json", opWrite, false, "and never writes one"}, |
| 235 | {"store/entitlements/" + testSqid + ".json", opDelete, false, "nor deletes one"}, |
| 236 | {"store/", opRead, false, "no listing-shaped key reaches the prefix either"}, |
| 237 | {"store", opRead, false, "nor an object at the bare name"}, |
| 238 | |
| 239 | // --- shapes canonicalKey must kill before the ACL is consulted --- |
| 240 | {"apps/ab12/../zz99/web/channels/stable.json", opRead, false, "traversal out of own account"}, |
| 241 | {"fleets/pi/../other/status/boxes/box-a.json", opWrite, false, "traversal out of own fleet"}, |
| 242 | {"../etc/passwd", opRead, false, "traversal below the keyspace"}, |
| 243 | {"/control/serial", opRead, false, "leading slash"}, |
| 244 | {"apps//ab12/web/channels/stable.json", opRead, false, "empty segment"}, |
| 245 | {"apps/ab12/./web/channels/stable.json", opRead, false, "dot segment"}, |
| 246 | {"", opRead, false, "empty key"}, |
| 247 | } |
| 248 | for _, row := range rows { |
| 249 | got := aclAllows(row.key, row.op) |
| 250 | if got != row.want { |
| 251 | t.Errorf("acl %s %q = %v, want %v (%s)", row.op, row.key, got, row.want, row.why) |
| 252 | } |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // TestAclReadLayoutIntentNeedsBothIdentities pins the same empty-sqid, |
| 257 | // empty-fleet guard aclRead already applies to every other key: an |
| 258 | // authenticated identity is required before the allowlist is even |
| 259 | // consulted, so a request that never resolved a fleet row cannot read the |
| 260 | // layout intent either. |
| 261 | func TestAclReadLayoutIntentNeedsBothIdentities(t *testing.T) { |
| 262 | key := release.LayoutIntentKey() |
| 263 | if !aclRead(testSqid, testFleet, key) { |
| 264 | t.Errorf("aclRead(%q, %q, %q) = false, want true", testSqid, testFleet, key) |
| 265 | } |
| 266 | if aclRead("", testFleet, key) { |
| 267 | t.Errorf("aclRead(\"\", %q, %q) = true, want false", testFleet, key) |
| 268 | } |
| 269 | if aclRead(testSqid, "", key) { |
| 270 | t.Errorf("aclRead(%q, \"\", %q) = true, want false", testSqid, key) |
| 271 | } |
| 272 | if aclWrite(testSqid, testFleet, key) { |
| 273 | t.Errorf("aclWrite(%q, %q, %q) = true, want false", testSqid, testFleet, key) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // TestCanonicalKeyRefusesShapes pins the pre-ACL gate on its own: the ACL is |
| 278 | // prefix-string exact, which is only safe because nothing that could re-point |
| 279 | // a prefix ever reaches it. |
| 280 | func TestCanonicalKeyRefusesShapes(t *testing.T) { |
| 281 | bad := []string{ |
| 282 | "", |
| 283 | "/apps/ab12/x", |
| 284 | "apps//ab12/x", |
| 285 | "apps/ab12/../zz99/x", |
| 286 | "..", |
| 287 | "../x", |
| 288 | "apps/ab12/x/..", |
| 289 | "apps/./ab12/x", |
| 290 | "apps/ab12/x/", |
| 291 | "apps/ab12/\x00x", |
| 292 | "apps/ab12/x\ny", |
| 293 | strings.Repeat("a", 1025), |
| 294 | } |
| 295 | for _, k := range bad { |
| 296 | if got, ok := canonicalKey(k); ok { |
| 297 | t.Errorf("canonicalKey(%q) = (%q, true), want refused", k, got) |
| 298 | } |
| 299 | } |
| 300 | good := []string{ |
| 301 | "control/serial", |
| 302 | "bootstrap/carlos", |
| 303 | "accounts/ab12.json", |
| 304 | "apps/ab12/web/releases/v1.2.3/app.tar.gz", |
| 305 | "fleets/pi/bindings/app.example.json", |
| 306 | strings.Repeat("a", 1024), |
| 307 | } |
| 308 | for _, k := range good { |
| 309 | got, ok := canonicalKey(k) |
| 310 | if !ok || got != k { |
| 311 | t.Errorf("canonicalKey(%q) = (%q, %v), want (%q, true)", k, got, ok, k) |
| 312 | } |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // TestACLListScope: a listing is scope-MAPPED, never scope-checked — asking |
| 317 | // for "apps/" gets you your own account's subtree, not a refusal, because |
| 318 | // that is what makes an unmodified release.Store caller work through the door. |
| 319 | func TestACLListScope(t *testing.T) { |
| 320 | cases := []struct { |
| 321 | prefix string |
| 322 | want string |
| 323 | ok bool |
| 324 | }{ |
| 325 | {"apps/", "apps/ab12/", true}, |
| 326 | {"apps", "apps/ab12/", true}, |
| 327 | {"apps/ab12", "apps/ab12/", true}, |
| 328 | {"apps/ab12/", "apps/ab12/", true}, |
| 329 | {"apps/ab12/web/releases/", "apps/ab12/web/releases/", true}, |
| 330 | {"apps/zz99/", "", false}, |
| 331 | {"apps/ab1", "", false}, |
| 332 | {"apps/ab12x/", "", false}, |
| 333 | {"accounts/", "accounts/", true}, |
| 334 | {"accounts", "accounts/", true}, |
| 335 | {"accounts/zz99.json", "", false}, |
| 336 | // DELIBERATE, and the trap worth naming: aclDir is STRICTER than |
| 337 | // aclRead here. aclRead admits accounts/<own-sqid>/** as an object |
| 338 | // read, but the listing scope map admits only the bare, SHARED |
| 339 | // accounts/ prefix — a caller narrowing its listing to its own |
| 340 | // account directory is refused. That is not an oversight to "fix": |
| 341 | // every real caller of this prefix lists accounts/ whole |
| 342 | // (provision.knownAccounts, domains.Reconcile, console.materialize) |
| 343 | // and takes the per-tenant filter serveListing applies, so a |
| 344 | // per-tenant mapping row here would widen the scope map for no |
| 345 | // caller. If one ever appears, adding the row is the deliberate act |
| 346 | // this pin makes visible. |
| 347 | {"accounts/" + testSqid + "/", "", false}, |
| 348 | {"accounts/" + testSqid, "", false}, |
| 349 | // domains/ (#151): the OTHER shared scope — every claim is |
| 350 | // cross-tenant-readable, so (unlike apps/ and fleets/) there is no |
| 351 | // per-tenant subtree to narrow into; only the bare prefix maps. |
| 352 | {"domains/", "domains/", true}, |
| 353 | {"domains", "domains/", true}, |
| 354 | {"domains/app.example.com.json", "", false}, |
| 355 | {"fleets/", "fleets/pi/", true}, |
| 356 | {"fleets/pi/status/boxes/", "fleets/pi/status/boxes/", true}, |
| 357 | {"fleets/other/", "", false}, |
| 358 | {"control/", "", false}, |
| 359 | {"bootstrap/", "", false}, |
| 360 | {"", "", false}, |
| 361 | {"status/", "", false}, |
| 362 | } |
| 363 | for _, c := range cases { |
| 364 | got, ok := aclListScope(testSqid, testFleet, c.prefix) |
| 365 | if got != c.want || ok != c.ok { |
| 366 | t.Errorf("aclListScope(%q) = (%q, %v), want (%q, %v)", c.prefix, got, ok, c.want, c.ok) |
| 367 | } |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | // TestACLDirIsPerTenant pins the ListDirs predicate on its own. A child |
| 372 | // prefix cannot be judged by aclRead (it is a directory, not an object), and |
| 373 | // judging it by "is it under the prefix we listed" would enumerate every |
| 374 | // account on the platform the moment accounts/ is listed. |
| 375 | func TestACLDirIsPerTenant(t *testing.T) { |
| 376 | cases := map[string]bool{ |
| 377 | "accounts/" + testSqid + "/": true, |
| 378 | "accounts/zz99/": false, |
| 379 | "accounts/" + testSqid + "x/": false, |
| 380 | "accounts/": false, |
| 381 | "apps/" + testSqid + "/web/": true, |
| 382 | "apps/zz99/web/": false, |
| 383 | "apps/" + testSqid + "x/web/": false, |
| 384 | "fleets/" + testFleet + "/status/": true, |
| 385 | "fleets/other/status/": false, |
| 386 | "fleets/" + testFleet + "x/y/": false, |
| 387 | "control/": false, |
| 388 | // domains/ (#215): no longer a shared read scope; claims are flat |
| 389 | // and serveClaimListing answers dirs with nothing. |
| 390 | "domains/anything/": false, |
| 391 | } |
| 392 | for dir, want := range cases { |
| 393 | if got := aclDir(testSqid, testFleet, dir); got != want { |
| 394 | t.Errorf("aclDir(%q) = %v, want %v", dir, got, want) |
| 395 | } |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | // The promotion-record grant (ruling R17) is a read of one exact object |
| 400 | // shape: no prefix under releases/ maps to a listing scope or shows as a |
| 401 | // directory, the box's own account's included. |
| 402 | func TestPromotionRecordsAreNotListable(t *testing.T) { |
| 403 | for _, prefix := range []string{ |
| 404 | "releases/", |
| 405 | "releases", |
| 406 | "releases/" + testSqid + "/", |
| 407 | "releases/" + testSqid + "/web/", |
| 408 | "releases/" + testSqid + "/web/promoted/", |
| 409 | "releases/" + testSqid + "/web/promoted", |
| 410 | } { |
| 411 | if got, ok := aclListScope(testSqid, testFleet, prefix); ok { |
| 412 | t.Errorf("aclListScope(%q) = (%q, true), want refused", prefix, got) |
| 413 | } |
| 414 | if aclDir(testSqid, testFleet, strings.TrimSuffix(prefix, "/")+"/") { |
| 415 | t.Errorf("aclDir(%q) = true, want false", prefix) |
| 416 | } |
| 417 | } |
| 418 | key := "releases/" + testSqid + "/web/promoted/v1" |
| 419 | if aclRead("", testFleet, key) || aclRead(testSqid, "", key) { |
| 420 | t.Errorf("aclRead(%q) without both identities = true, want false", key) |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | // ---- handler tests ------------------------------------------------------ |
| 425 | |
| 426 | // The credentials every handler test runs with: a fleet channel |
| 427 | // "<fleet>/<label>" whose token hash sits in the hub's dynamic table (the |
| 428 | // registry-backed source), alongside a STATIC client which must never open |
| 429 | // this door. |
| 430 | const ( |
| 431 | testChannel = testFleet + "/box-a" |
| 432 | testToken = "box-a-bearer-token" |
| 433 | staticName = "hq" |
| 434 | staticToken = "static-client-token" |
| 435 | ownKey = "apps/" + testSqid + "/web/channels/stable.json" |
| 436 | ownFleetKey = "fleets/" + testFleet + "/status/boxes/box-a.json" |
| 437 | otherAccountKey = "apps/zz99/web/channels/stable.json" |
| 438 | ) |
| 439 | |
| 440 | // newTestDoor builds a door over a DirStore with the identities above — the |
| 441 | // same wiring edge.go does in production (hub + the edge's own store handle + |
| 442 | // the reload-time fleet→account snapshot). |
| 443 | func newTestDoor(t *testing.T) (*storeDoor, *release.DirStore) { |
| 444 | t.Helper() |
| 445 | hub := NewTunnelHub("tunnel.test", map[string]string{staticName: staticToken}) |
| 446 | hub.SetDynamicAuth(map[string]string{testChannel: sha256Hex(testToken)}) |
| 447 | store := release.NewDirStore(t.TempDir()) |
| 448 | return &storeDoor{hub: hub, store: store, fleetAccount: func(fleet string) (string, bool) { |
| 449 | if fleet == testFleet { |
| 450 | return testSqid, true |
| 451 | } |
| 452 | return "", false |
| 453 | }}, store |
| 454 | } |
| 455 | |
| 456 | // doorReq builds a request with the given credentials; an empty token sends |
| 457 | // no Authorization header at all, an empty name no Carlos-Tunnel-Name. |
| 458 | func doorReq(method, target, name, token, body string) *http.Request { |
| 459 | var r *http.Request |
| 460 | if body == "" { |
| 461 | r = httptest.NewRequest(method, target, nil) |
| 462 | } else { |
| 463 | r = httptest.NewRequest(method, target, strings.NewReader(body)) |
| 464 | } |
| 465 | if name != "" { |
| 466 | r.Header.Set("Carlos-Tunnel-Name", name) |
| 467 | } |
| 468 | if token != "" { |
| 469 | r.Header.Set("Authorization", "Bearer "+token) |
| 470 | } |
| 471 | return r |
| 472 | } |
| 473 | |
| 474 | // doorDo runs one correctly authenticated request through the door. |
| 475 | func doorDo(d *storeDoor, method, target, body string, hdr map[string]string) *httptest.ResponseRecorder { |
| 476 | r := doorReq(method, target, testChannel, testToken, body) |
| 477 | for k, v := range hdr { |
| 478 | r.Header.Set(k, v) |
| 479 | } |
| 480 | w := httptest.NewRecorder() |
| 481 | d.ServeHTTP(w, r) |
| 482 | return w |
| 483 | } |
| 484 | |
| 485 | func seedObject(t *testing.T, s *release.DirStore, key, body string) string { |
| 486 | t.Helper() |
| 487 | etag, err := s.Put(context.Background(), key, []byte(body), "") |
| 488 | if err != nil { |
| 489 | t.Fatalf("seed %s: %v", key, err) |
| 490 | } |
| 491 | return etag |
| 492 | } |
| 493 | |
| 494 | func TestStoreDoorGetServesOwnEstate(t *testing.T) { |
| 495 | d, store := newTestDoor(t) |
| 496 | etag := seedObject(t, store, ownKey, `{"version":"v1"}`) |
| 497 | seedObject(t, store, "control/serial", "42") |
| 498 | seedObject(t, store, "bootstrap/carlos", "ELF...") |
| 499 | seedObject(t, store, "control/system/router/stable.json", `{"payload":"x"}`) |
| 500 | seedObject(t, store, "bootstrap/router/v1/carlos-linux-arm64", "ELF") |
| 501 | |
| 502 | for _, key := range []string{ownKey, "control/serial", "bootstrap/carlos", "control/system/router/stable.json", "bootstrap/router/v1/carlos-linux-arm64"} { |
| 503 | w := doorDo(d, "GET", "/store/o/"+key, "", nil) |
| 504 | if w.Code != http.StatusOK { |
| 505 | t.Fatalf("GET %s = %d, want 200", key, w.Code) |
| 506 | } |
| 507 | } |
| 508 | w := doorDo(d, "GET", "/store/o/"+ownKey, "", nil) |
| 509 | if got := w.Body.String(); got != `{"version":"v1"}` { |
| 510 | t.Errorf("body = %q, want the stored object", got) |
| 511 | } |
| 512 | if got := w.Header().Get("ETag"); got != etag { |
| 513 | t.Errorf("ETag = %q, want the backing store's %q (etags are opaque, passed through)", got, etag) |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | // TestStoreDoorCASRoundTrip pins F4 on the wire: there is no unconditional |
| 518 | // Put, a lost race is 412, and a won race hands back the new etag. |
| 519 | func TestStoreDoorCASRoundTrip(t *testing.T) { |
| 520 | d, store := newTestDoor(t) |
| 521 | path := "/store/o/" + ownFleetKey |
| 522 | |
| 523 | if w := doorDo(d, "PUT", path, "first", nil); w.Code != http.StatusBadRequest { |
| 524 | t.Fatalf("PUT with no precondition = %d, want 400 (the Store contract has no blind write)", w.Code) |
| 525 | } |
| 526 | if _, _, err := store.Get(context.Background(), ownFleetKey); !errors.Is(err, os.ErrNotExist) { |
| 527 | t.Fatal("a refused PUT still created the object") |
| 528 | } |
| 529 | if w := doorDo(d, "PUT", path, "x", map[string]string{"If-Match": "abc", "If-None-Match": "*"}); w.Code != http.StatusBadRequest { |
| 530 | t.Errorf("PUT with both preconditions = %d, want 400", w.Code) |
| 531 | } |
| 532 | |
| 533 | w := doorDo(d, "PUT", path, "first", map[string]string{"If-None-Match": "*"}) |
| 534 | if w.Code != http.StatusOK { |
| 535 | t.Fatalf("create-only PUT = %d, want 200", w.Code) |
| 536 | } |
| 537 | e1 := w.Header().Get("ETag") |
| 538 | if e1 == "" { |
| 539 | t.Fatal("create-only PUT returned no ETag") |
| 540 | } |
| 541 | if w := doorDo(d, "PUT", path, "again", map[string]string{"If-None-Match": "*"}); w.Code != http.StatusPreconditionFailed { |
| 542 | t.Errorf("create-only PUT over an existing object = %d, want 412", w.Code) |
| 543 | } |
| 544 | if w := doorDo(d, "PUT", path, "stale", map[string]string{"If-Match": "0000"}); w.Code != http.StatusPreconditionFailed { |
| 545 | t.Errorf("replace-only PUT with a stale etag = %d, want 412", w.Code) |
| 546 | } |
| 547 | w2 := doorDo(d, "PUT", path, "second", map[string]string{"If-Match": e1}) |
| 548 | if w2.Code != http.StatusOK { |
| 549 | t.Fatalf("replace-only PUT with the fresh etag = %d, want 200", w2.Code) |
| 550 | } |
| 551 | if e2 := w2.Header().Get("ETag"); e2 == "" || e2 == e1 { |
| 552 | t.Errorf("ETag after replace = %q, want a new one (was %q)", e2, e1) |
| 553 | } |
| 554 | body, _, err := store.Get(context.Background(), ownFleetKey) |
| 555 | if err != nil || string(body) != "second" { |
| 556 | t.Fatalf("stored body = %q, %v; want \"second\"", body, err) |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | func TestStoreDoorDeleteIsScopedAndIdempotent(t *testing.T) { |
| 561 | d, store := newTestDoor(t) |
| 562 | seedObject(t, store, ownFleetKey, "{}") |
| 563 | seedObject(t, store, ownKey, "{}") |
| 564 | |
| 565 | for i := 0; i < 2; i++ { |
| 566 | if w := doorDo(d, "DELETE", "/store/o/"+ownFleetKey, "", nil); w.Code != http.StatusNoContent { |
| 567 | t.Fatalf("DELETE own fleet key (attempt %d) = %d, want 204", i+1, w.Code) |
| 568 | } |
| 569 | } |
| 570 | if w := doorDo(d, "DELETE", "/store/o/"+ownKey, "", nil); w.Code != http.StatusNotFound { |
| 571 | t.Fatalf("DELETE a release object = %d, want 404 (retention is not a box's job)", w.Code) |
| 572 | } |
| 573 | if _, _, err := store.Get(context.Background(), ownKey); err != nil { |
| 574 | t.Fatalf("the refused DELETE removed the object anyway: %v", err) |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | // TestStoreDoorRefusalsAreByteIdentical is the spec's central security |
| 579 | // invariant: bad auth, a static (non-fleet) name, another tenant's key, a |
| 580 | // missing in-scope key and a malformed key must be INDISTINGUISHABLE — same |
| 581 | // status, same headers, same bytes — or the door becomes a keyspace oracle. |
| 582 | func TestStoreDoorRefusalsAreByteIdentical(t *testing.T) { |
| 583 | d, store := newTestDoor(t) |
| 584 | seedObject(t, store, otherAccountKey, "someone else's") |
| 585 | seedObject(t, store, "fleets/other/status/boxes/box-a.json", "another fleet's") |
| 586 | // Seeded so the account-manifest row below refuses a key that really |
| 587 | // EXISTS: the refusal must not be distinguishable from the missing-key |
| 588 | // one, which is only proved against an object that is actually there. |
| 589 | seedObject(t, store, "accounts/zz99/account.json", "another tenant's account") |
| 590 | |
| 591 | req := func(name, token, method, target string) *http.Request { |
| 592 | return doorReq(method, target, name, token, "") |
| 593 | } |
| 594 | cases := map[string]*http.Request{ |
| 595 | "wrong token": req(testChannel, "not-the-token", "GET", "/store/o/"+ownKey), |
| 596 | "no auth at all": req("", "", "GET", "/store/o/"+ownKey), |
| 597 | "static name + token": req(staticName, staticToken, "GET", "/store/o/control/serial"), |
| 598 | "unknown fleet": req("nosuch/box", testToken, "GET", "/store/o/control/serial"), |
| 599 | "other account's key": req(testChannel, testToken, "GET", "/store/o/"+otherAccountKey), |
| 600 | "other fleet's key": req(testChannel, testToken, "GET", "/store/o/fleets/other/status/boxes/box-a.json"), |
| 601 | "missing in-scope key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/web/nope.json"), |
| 602 | "traversal key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/../zz99/x"), |
| 603 | "empty key": req(testChannel, testToken, "GET", "/store/o/"), |
| 604 | "write out of scope": req(testChannel, testToken, "DELETE", "/store/o/"+otherAccountKey), |
| 605 | "list out of scope": req(testChannel, testToken, "GET", "/store/list?prefix=apps/zz99/"), |
| 606 | "unknown path": req(testChannel, testToken, "GET", "/store/nope"), |
| 607 | "wrong method": req(testChannel, testToken, "POST", "/store/o/"+ownKey), |
| 608 | // The two classes Task 2 deferred. The account manifest is the key a |
| 609 | // tenant-enumeration probe would reach for FIRST (accounts/<sqid>/ |
| 610 | // account.json is the one object whose presence answers "does this |
| 611 | // account exist"), and /store/dirs is the whole second listing route |
| 612 | // — its refusals were never pinned to the same shape as /store/list's. |
| 613 | "other account's manifest": req(testChannel, testToken, "GET", "/store/o/accounts/zz99/account.json"), |
| 614 | "dirs out of scope": req(testChannel, testToken, "GET", "/store/dirs?prefix=accounts/zz99/"), |
| 615 | "dirs of another fleet": req(testChannel, testToken, "GET", "/store/dirs?prefix=fleets/other/"), |
| 616 | "dirs unauthenticated": req(testChannel, "not-the-token", "GET", "/store/dirs?prefix=fleets/"), |
| 617 | } |
| 618 | type shape struct { |
| 619 | code int |
| 620 | header string |
| 621 | body string |
| 622 | } |
| 623 | var want *shape |
| 624 | var wantName string |
| 625 | for name, r := range cases { |
| 626 | w := httptest.NewRecorder() |
| 627 | d.ServeHTTP(w, r) |
| 628 | got := shape{w.Code, fmt.Sprintf("%v", w.Header()), w.Body.String()} |
| 629 | if got.code != http.StatusNotFound { |
| 630 | t.Errorf("%s: status = %d, want 404", name, got.code) |
| 631 | } |
| 632 | if strings.Contains(got.body, testToken) || strings.Contains(got.header, testToken) { |
| 633 | t.Errorf("%s: response echoes the bearer token", name) |
| 634 | } |
| 635 | if want == nil { |
| 636 | g := got |
| 637 | want, wantName = &g, name |
| 638 | continue |
| 639 | } |
| 640 | if got != *want { |
| 641 | t.Errorf("%q answers %+v but %q answers %+v — the two refusals are distinguishable", name, got, wantName, *want) |
| 642 | } |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | // TestDoorNeverServesCertMaterial is the permanent tripwire for the |
| 647 | // multi-region ingress design's central promise (bucket-writer-audit.md): |
| 648 | // control/certs/**, control/edge-mesh-key and the housekeeper's own lease |
| 649 | // object are NOT in aclRead's allowlist (compare the doc comment above |
| 650 | // aclRead — control/serial, bootstrap/carlos, domains/, accounts/<sqid>/, |
| 651 | // apps/<sqid>/, fleets/<fleet>/, control/system/<c>/<ch>.json, |
| 652 | // bootstrap/router/<v>/<asset>, and nothing else), so no fleet box can ever |
| 653 | // read a private key, an ACME account key, a challenge token or the |
| 654 | // housekeeper's coordination lock through this door, however it authenticates. |
| 655 | // |
| 656 | // It runs as an AUTHENTICATED fleet client (own sqid/fleet, real bearer |
| 657 | // token) — an unauthenticated 404 would prove nothing about the ACL — across |
| 658 | // all three verbs the door exposes on a single object (GET/PUT/DELETE) plus |
| 659 | // both listing verbs, and it proves the refusal is byte-identical whether the |
| 660 | // key exists in the backing store or not: two of the seven keys below are |
| 661 | // seeded with plausible cert bytes before the loop runs, the rest are left |
| 662 | // absent, and every response — seeded or not — is folded into the same |
| 663 | // byte-identical comparison TestStoreDoorRefusalsAreByteIdentical uses, so an |
| 664 | // existence oracle on cert material would fail exactly the same way a status |
| 665 | // mismatch would. |
| 666 | func TestDoorNeverServesCertMaterial(t *testing.T) { |
| 667 | d, store := newTestDoor(t) |
| 668 | |
| 669 | // The real Task 4/5 key shapes (certsync.go's certKey naming: bare host, |
| 670 | // the autocert account key, an HTTP-01 token response, a tls-alpn-01 |
| 671 | // token cert, a wildcard account key) plus the four other control/ |
| 672 | // entries the multi-region design reserves for the housekeeper alone |
| 673 | // (bucket-writer-audit.md, "Not yet built" table): the mesh key, its |
| 674 | // rotation successor (Task 2's dual-accept — control/edge-mesh-key.next, |
| 675 | // mirrored only while a rotation is in flight, but readable through this |
| 676 | // door at every other time too and refused just as hard), and the |
| 677 | // housekeeper's own election lock. |
| 678 | certKeys := []string{ |
| 679 | "control/certs/example.com", |
| 680 | "control/certs/acme_account+key", |
| 681 | "control/certs/wildcard-account.key", |
| 682 | "control/certs/tok+http-01", |
| 683 | "control/certs/example.com+token", |
| 684 | "control/edge-mesh-key", |
| 685 | "control/edge-mesh-key.next", |
| 686 | "control/housekeeper-lease/lock.json", |
| 687 | // The cert warm's outcome records (#299 §3e, internal/certwarm). |
| 688 | // They carry no key material, so the leak they would be is not the |
| 689 | // same one as the entries above — it is the EXISTENCE oracle this |
| 690 | // test's identical-shape check is really about. A door that answered |
| 691 | // differently for a record that exists would let anyone with a fleet |
| 692 | // token enumerate which of the deployment's custom domains are |
| 693 | // currently failing to get a certificate, tenant boundary and all. |
| 694 | "control/cert-warm/example.com.json", |
| 695 | } |
| 696 | // Seed three of the nine with plausible-looking secret bytes: if the ACL |
| 697 | // ever let a Get through, these are the ones that would leak real |
| 698 | // material instead of a "not found" — and mixing seeded with unseeded |
| 699 | // keys through the identical-shape check below is what proves the door |
| 700 | // isn't just refusing absent keys, it's refusing to distinguish "exists" |
| 701 | // from "doesn't" at all for this whole prefix. The mesh key and its |
| 702 | // rotation successor are seeded as a pair, same secret shape, since a |
| 703 | // leak of either is the same failure. |
| 704 | seedObject(t, store, "control/certs/example.com", "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----") |
| 705 | seedObject(t, store, "control/edge-mesh-key", "fake-mesh-shared-secret") |
| 706 | seedObject(t, store, "control/edge-mesh-key.next", "fake-mesh-shared-secret") |
| 707 | |
| 708 | type shape struct { |
| 709 | code int |
| 710 | header string |
| 711 | body string |
| 712 | } |
| 713 | var want *shape |
| 714 | var wantName string |
| 715 | check := func(name string, w *httptest.ResponseRecorder) { |
| 716 | t.Helper() |
| 717 | if w.Code != http.StatusNotFound { |
| 718 | t.Errorf("%s: status = %d, want 404 — cert material must never transit the door", name, w.Code) |
| 719 | return |
| 720 | } |
| 721 | if strings.Contains(w.Body.String(), "BEGIN CERTIFICATE") || strings.Contains(w.Body.String(), "fake-mesh-shared-secret") { |
| 722 | t.Errorf("%s: response body carries seeded secret bytes", name) |
| 723 | } |
| 724 | got := shape{w.Code, fmt.Sprintf("%v", w.Header()), w.Body.String()} |
| 725 | if want == nil { |
| 726 | g := got |
| 727 | want, wantName = &g, name |
| 728 | return |
| 729 | } |
| 730 | if got != *want { |
| 731 | t.Errorf("%q answers %+v but %q answers %+v — cert refusal is distinguishable from an ordinary refusal (existence oracle)", name, got, wantName, *want) |
| 732 | } |
| 733 | } |
| 734 | |
| 735 | for _, key := range certKeys { |
| 736 | check("GET "+key, doorDo(d, "GET", "/store/o/"+key, "", nil)) |
| 737 | check("DELETE "+key, doorDo(d, "DELETE", "/store/o/"+key, "", nil)) |
| 738 | check("PUT "+key, doorDo(d, "PUT", "/store/o/"+key, "x", map[string]string{"If-None-Match": "*"})) |
| 739 | } |
| 740 | // The listing verbs: a fleet box must never enumerate the cert prefix |
| 741 | // either, whether asking for objects (list) or child directories (dirs). |
| 742 | check("list prefix=control/certs/", doorDo(d, "GET", "/store/list?prefix=control/certs/", "", nil)) |
| 743 | check("dirs prefix=control/certs/", doorDo(d, "GET", "/store/dirs?prefix=control/certs/", "", nil)) |
| 744 | |
| 745 | // The seeded keys must still be untouched in the backing store — a |
| 746 | // refused DELETE that quietly deleted anyway would be worse than a |
| 747 | // refused DELETE that didn't. |
| 748 | if _, _, err := store.Get(context.Background(), "control/certs/example.com"); err != nil { |
| 749 | t.Errorf("the refused DELETE removed control/certs/example.com anyway: %v", err) |
| 750 | } |
| 751 | if _, _, err := store.Get(context.Background(), "control/edge-mesh-key"); err != nil { |
| 752 | t.Errorf("the refused DELETE removed control/edge-mesh-key anyway: %v", err) |
| 753 | } |
| 754 | } |
| 755 | |
| 756 | // TestStoreDoorListScopeStripsOtherTenants: a listing is the easiest place to |
| 757 | // leak a neighbour, so every listing route is checked against a store that |
| 758 | // holds another account's and another fleet's objects. |
| 759 | func TestStoreDoorListScopeStripsOtherTenants(t *testing.T) { |
| 760 | d, store := newTestDoor(t) |
| 761 | seedObject(t, store, ownKey, "{}") |
| 762 | seedObject(t, store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}") |
| 763 | seedObject(t, store, otherAccountKey, "{}") |
| 764 | seedObject(t, store, "accounts/"+testSqid+"/account.json", "{}") |
| 765 | seedObject(t, store, "accounts/zz99/account.json", "{}") |
| 766 | seedObject(t, store, ownFleetKey, "{}") |
| 767 | seedObject(t, store, "fleets/other/status/boxes/box-a.json", "{}") |
| 768 | |
| 769 | list := func(target string) []string { |
| 770 | t.Helper() |
| 771 | w := doorDo(d, "GET", target, "", nil) |
| 772 | if w.Code != http.StatusOK { |
| 773 | t.Fatalf("GET %s = %d, want 200", target, w.Code) |
| 774 | } |
| 775 | var out []string |
| 776 | if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { |
| 777 | t.Fatalf("GET %s: decode %q: %v", target, w.Body.String(), err) |
| 778 | } |
| 779 | return out |
| 780 | } |
| 781 | for _, c := range []struct { |
| 782 | target string |
| 783 | want []string |
| 784 | }{ |
| 785 | {"/store/list?prefix=apps/", []string{ownKey, "apps/" + testSqid + "/web/releases/v1/manifest.json"}}, |
| 786 | {"/store/list?prefix=accounts/", []string{"accounts/" + testSqid + "/account.json"}}, |
| 787 | {"/store/list?prefix=fleets/", []string{ownFleetKey}}, |
| 788 | {"/store/list?prefix=apps/" + testSqid + "/web/releases/", []string{"apps/" + testSqid + "/web/releases/v1/manifest.json"}}, |
| 789 | {"/store/dirs?prefix=apps/", []string{"apps/" + testSqid + "/web/"}}, |
| 790 | {"/store/dirs?prefix=fleets/", []string{"fleets/" + testFleet + "/status/"}}, |
| 791 | // The regression that matters most on the dirs route: accounts/ is a |
| 792 | // SHARED prefix, and ListDirs returns one child per account on the |
| 793 | // platform. Filtering by "is it under the prefix we listed" is a |
| 794 | // tautology there — the answer must be this tenant's own directory |
| 795 | // and nothing else, with zz99 seeded above to prove it. |
| 796 | {"/store/dirs?prefix=accounts/", []string{"accounts/" + testSqid + "/"}}, |
| 797 | } { |
| 798 | got := list(c.target) |
| 799 | if !slices.Equal(got, c.want) { |
| 800 | t.Errorf("%s = %v, want %v", c.target, got, c.want) |
| 801 | } |
| 802 | } |
| 803 | // An empty listing is [] on the wire, never null: the client decodes into |
| 804 | // a []string either way, but null would invite a nil-vs-empty distinction |
| 805 | // the Store contract does not have. |
| 806 | w := doorDo(d, "GET", "/store/list?prefix=apps/"+testSqid+"/nosuchapp/", "", nil) |
| 807 | if body := strings.TrimSpace(w.Body.String()); body != "[]" { |
| 808 | t.Errorf("empty listing body = %q, want []", body) |
| 809 | } |
| 810 | } |
| 811 | |
| 812 | // doorOpCounter records how many store operations a request performed — |
| 813 | // enough to prove a refused request touches the bucket zero times. |
| 814 | type doorOpCounter struct { |
| 815 | release.Store |
| 816 | ops int |
| 817 | } |
| 818 | |
| 819 | func (c *doorOpCounter) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 820 | c.ops++ |
| 821 | return c.Store.Get(ctx, key) |
| 822 | } |
| 823 | |
| 824 | func (c *doorOpCounter) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 825 | c.ops++ |
| 826 | return c.Store.Put(ctx, key, body, ifMatch) |
| 827 | } |
| 828 | |
| 829 | func (c *doorOpCounter) List(ctx context.Context, prefix string) ([]string, error) { |
| 830 | c.ops++ |
| 831 | return c.Store.List(ctx, prefix) |
| 832 | } |
| 833 | |
| 834 | func (c *doorOpCounter) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| 835 | c.ops++ |
| 836 | return c.Store.ListDirs(ctx, prefix) |
| 837 | } |
| 838 | |
| 839 | func (c *doorOpCounter) Delete(ctx context.Context, key string) error { |
| 840 | c.ops++ |
| 841 | return c.Store.Delete(ctx, key) |
| 842 | } |
| 843 | |
| 844 | // TestStoreDoorAuthRunsExactlyOncePerRequest pins the invariant two ways: the |
| 845 | // handler contains exactly ONE call to VerifyDynamic (no path can skip it, |
| 846 | // none can pay for it twice — and the auth logic itself is never |
| 847 | // re-implemented here), and an unauthenticated request of any shape reaches |
| 848 | // the store zero times. |
| 849 | func TestStoreDoorAuthRunsExactlyOncePerRequest(t *testing.T) { |
| 850 | fset := token.NewFileSet() |
| 851 | f, err := parser.ParseFile(fset, "storedoor.go", nil, 0) |
| 852 | if err != nil { |
| 853 | t.Fatalf("parse storedoor.go: %v", err) |
| 854 | } |
| 855 | calls := 0 |
| 856 | ast.Inspect(f, func(n ast.Node) bool { |
| 857 | call, ok := n.(*ast.CallExpr) |
| 858 | if !ok { |
| 859 | return true |
| 860 | } |
| 861 | if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "VerifyDynamic" { |
| 862 | calls++ |
| 863 | } |
| 864 | return true |
| 865 | }) |
| 866 | if calls != 1 { |
| 867 | t.Errorf("storedoor.go calls VerifyDynamic %d times, want exactly 1 (one auth, one place)", calls) |
| 868 | } |
| 869 | |
| 870 | d, _ := newTestDoor(t) |
| 871 | counting := &doorOpCounter{Store: d.store} |
| 872 | d.store = counting |
| 873 | for _, r := range []*http.Request{ |
| 874 | doorReq("GET", "/store/o/"+ownKey, testChannel, "wrong", ""), |
| 875 | doorReq("PUT", "/store/o/"+ownFleetKey, testChannel, "wrong", "x"), |
| 876 | doorReq("DELETE", "/store/o/"+ownFleetKey, testChannel, "wrong", ""), |
| 877 | doorReq("GET", "/store/list?prefix=apps/", testChannel, "wrong", ""), |
| 878 | doorReq("GET", "/store/dirs?prefix=apps/", testChannel, "wrong", ""), |
| 879 | doorReq("GET", "/store/o/"+ownKey, staticName, staticToken, ""), |
| 880 | doorReq("GET", "/store/o/"+ownKey, "", "", ""), |
| 881 | } { |
| 882 | w := httptest.NewRecorder() |
| 883 | d.ServeHTTP(w, r) |
| 884 | if w.Code != http.StatusNotFound { |
| 885 | t.Errorf("%s %s unauthenticated = %d, want 404", r.Method, r.URL, w.Code) |
| 886 | } |
| 887 | } |
| 888 | if counting.ops != 0 { |
| 889 | t.Errorf("unauthenticated requests performed %d store operation(s), want 0", counting.ops) |
| 890 | } |
| 891 | } |
| 892 | |
| 893 | // TestStoreDoorOnlyOnReservedHost drives the door through the EDGE, not |
| 894 | // directly: it must be unreachable on every host except the reserved tunnel |
| 895 | // hostname, exactly like tunnel registration — and it must not have swallowed |
| 896 | // /register on the way in. |
| 897 | func TestStoreDoorOnlyOnReservedHost(t *testing.T) { |
| 898 | reg := openReg(t) |
| 899 | if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil { |
| 900 | t.Fatalf("UpsertFleet: %v", err) |
| 901 | } |
| 902 | if err := reg.AddFleetBox(registry.FleetBox{Fleet: testFleet, Label: "box-a", TokenHash: sha256Hex(testToken), Status: "active"}); err != nil { |
| 903 | t.Fatalf("AddFleetBox: %v", err) |
| 904 | } |
| 905 | backend, _ := hostEchoBackend(t, "the app, not the door") |
| 906 | if err := reg.Upsert(registry.Route{Host: "app.example", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| 907 | t.Fatalf("upsert route: %v", err) |
| 908 | } |
| 909 | e, err := New(reg, nil) |
| 910 | if err != nil { |
| 911 | t.Fatalf("New: %v", err) |
| 912 | } |
| 913 | store := release.NewDirStore(t.TempDir()) |
| 914 | seedObject(t, store, "control/serial", "77") |
| 915 | _, pubB64, err := release.GenerateKey() |
| 916 | if err != nil { |
| 917 | t.Fatal(err) |
| 918 | } |
| 919 | pub, err := release.ParsePublicKey(pubB64, "test") |
| 920 | if err != nil { |
| 921 | t.Fatal(err) |
| 922 | } |
| 923 | e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| 924 | e.EnableTunnels(NewTunnelHub("tunnel.test", nil)) |
| 925 | if err := e.Reload(); err != nil { |
| 926 | t.Fatalf("Reload: %v", err) |
| 927 | } |
| 928 | srv := httptest.NewServer(e) |
| 929 | defer srv.Close() |
| 930 | |
| 931 | get := func(host, path string) (int, string) { |
| 932 | t.Helper() |
| 933 | req, err := http.NewRequest("GET", srv.URL+path, nil) |
| 934 | if err != nil { |
| 935 | t.Fatal(err) |
| 936 | } |
| 937 | req.Host = host |
| 938 | req.Header.Set("Carlos-Tunnel-Name", testChannel) |
| 939 | req.Header.Set("Authorization", "Bearer "+testToken) |
| 940 | resp, err := http.DefaultClient.Do(req) |
| 941 | if err != nil { |
| 942 | t.Fatalf("GET %s (host %s): %v", path, host, err) |
| 943 | } |
| 944 | defer resp.Body.Close() |
| 945 | b, _ := io.ReadAll(resp.Body) |
| 946 | return resp.StatusCode, string(b) |
| 947 | } |
| 948 | if code, body := get("tunnel.test", "/store/o/control/serial"); code != http.StatusOK || body != "77" { |
| 949 | t.Fatalf("on the reserved host: %d %q, want 200 \"77\"", code, body) |
| 950 | } |
| 951 | if code, body := get("app.example", "/store/o/control/serial"); code != http.StatusOK || body != "the app, not the door" { |
| 952 | t.Fatalf("on a routed host: %d %q — the door answered on a host it must not serve", code, body) |
| 953 | } |
| 954 | if code, _ := get("nosuch.example", "/store/o/control/serial"); code == http.StatusOK { |
| 955 | t.Fatal("the door answered on an unknown host") |
| 956 | } |
| 957 | // /register stays the hub's on the same hostname: no upgrade headers, so |
| 958 | // a 400 (the hub's wire-contract refusal), never the door's 404. |
| 959 | if code, _ := get("tunnel.test", "/register"); code != http.StatusBadRequest { |
| 960 | t.Fatalf("/register on the reserved host = %d, want 400 (still the hub's)", code) |
| 961 | } |
| 962 | } |
| 963 | |
| 964 | // TestStoreDoorClaimsAreOwnAccountAndRedacted (#215): a fleet box used to be |
| 965 | // able to read every tenant's hostname claims — who made each one (a |
| 966 | // person's address), its account and app, its path map, and whether anyone |
| 967 | // had proved it. It now reads only its own account's verified claims, |
| 968 | // without created_by; every other claim body answers the door's |
| 969 | // byte-identical 404. The listing still names every claimed hostname. |
| 970 | func TestStoreDoorClaimsAreOwnAccountAndRedacted(t *testing.T) { |
| 971 | d, store := newTestDoor(t) |
| 972 | ctx := context.Background() |
| 973 | put := func(c domains.Claim) { |
| 974 | t.Helper() |
| 975 | if _, _, _, err := domains.AttachClaimProven(ctx, store, c, time.Now(), func() bool { return !c.Pending }); err != nil { |
| 976 | t.Fatal(err) |
| 977 | } |
| 978 | } |
| 979 | put(domains.Claim{Host: "own.example.com", Account: testSqid, App: "web", Route: "web." + testSqid + ".oncarlos.com", CreatedBy: "paul@keymail.dev"}) |
| 980 | put(domains.Claim{Host: "pending.example.com", Account: testSqid, App: "web", Pending: true}) |
| 981 | put(domains.Claim{Host: "theirs.example.com", Account: "zz99", App: "shop", CreatedBy: "sam@keymail.dev"}) |
| 982 | |
| 983 | w := doorDo(d, "GET", "/store/list?prefix=domains/", "", nil) |
| 984 | // Every claimed NAME is listed, whoever holds it: the alias pass decides |
| 985 | // "someone else claimed this" from the listing. Bodies are what is |
| 986 | // withheld (below). |
| 987 | if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != |
| 988 | `["domains/own.example.com.json","domains/pending.example.com.json","domains/theirs.example.com.json"]` { |
| 989 | t.Fatalf("listing = %d %s, want every claimed hostname", w.Code, w.Body.String()) |
| 990 | } |
| 991 | w = doorDo(d, "GET", "/store/o/domains/own.example.com.json", "", nil) |
| 992 | if w.Code != http.StatusOK { |
| 993 | t.Fatalf("own claim = %d", w.Code) |
| 994 | } |
| 995 | var c domains.Claim |
| 996 | if err := json.Unmarshal(w.Body.Bytes(), &c); err != nil { |
| 997 | t.Fatal(err) |
| 998 | } |
| 999 | if c.Host != "own.example.com" || c.Route == "" || c.CreatedBy != "" || strings.Contains(w.Body.String(), "keymail.dev") { |
| 1000 | t.Fatalf("own claim body = %s, want the claim without created_by", w.Body.String()) |
| 1001 | } |
| 1002 | want := doorDo(d, "GET", "/store/o/domains/absent.example.com.json", "", nil) |
| 1003 | for _, key := range []string{"theirs.example.com", "pending.example.com"} { |
| 1004 | got := doorDo(d, "GET", "/store/o/domains/"+key+".json", "", nil) |
| 1005 | if got.Code != http.StatusNotFound || got.Body.String() != want.Body.String() || fmt.Sprint(got.Header()) != fmt.Sprint(want.Header()) { |
| 1006 | t.Errorf("%s = %d %q, want the same 404 an absent claim gets", key, got.Code, got.Body.String()) |
| 1007 | } |
| 1008 | } |
| 1009 | if w := doorDo(d, "GET", "/store/dirs?prefix=domains/", "", nil); strings.TrimSpace(w.Body.String()) != "[]" { |
| 1010 | t.Errorf("dirs under domains/ = %s, want []", w.Body.String()) |
| 1011 | } |
| 1012 | } |
| 1013 | |
| 1014 | // TestFleetBoxCanReadThePlansMarker: a fleet box reads the plans marker |
| 1015 | // (plans 2) to learn whether to read limits at all; the same two-identity |
| 1016 | // guard applies. |
| 1017 | func TestFleetBoxCanReadThePlansMarker(t *testing.T) { |
| 1018 | key := plans.MarkerKey() |
| 1019 | if !aclRead(testSqid, testFleet, key) { |
| 1020 | t.Errorf("aclRead(%q, %q, %q) = false, want true", testSqid, testFleet, key) |
| 1021 | } |
| 1022 | if aclRead("", testFleet, key) || aclRead(testSqid, "", key) { |
| 1023 | t.Error("the plans marker was readable without both identities") |
| 1024 | } |
| 1025 | } |
| 1026 | |