Golden fixtures and a conformance harness that is itself tested
The fixtures are the interface (doctrine §10), so they are shared rather than reimplemented per app: custody.json is one case per rung of the sharing ladder, hostile_hosts.json is the host list gate 4 runs. The custody fixture is built to be hard to pass by accident. The same asker appears in every case and deliberately holds a live grant on the private and granted items and a former grant on the deleted one, so a server answering from its grants join alone fails — that is ruling 15 and §4.3's intersection, tested rather than asserted. It carries the two cases the reviews asked for: an item on the team rung that also has a link (team-visible because of the rung), and a link added after ingest (which changes nothing either way). The harness is tested against servers that should fail it. A harness that passes everything is worse than none, and the only way to know is to point it at bugs: a server answering 403 where the contract says 404, a server whose S2 lets a grant beat custody, and a feed row carrying a visibility flag — memoria's draft-1 leak. All three are caught; a correct reference server passes. One real finding while writing this, from mutating the code under the tests: weakening the transport's HasSuffix check to Contains left every hostile-host fixture still refused. The suffix check is not what stops the suffix attack — the exactly-two-labels check is, because acme.docs.oficina.works.evil never gets trimmed and arrives with five labels. The comment now says so, and mutating the label check does fail the fixture, so the gate has teeth where it matters. 29 tests, vet and gofmt clean. Tasks 1-9 done: this is §12 step 2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+625
−9
README.md+37 −5conformance/conformance.go+140 −0conformance/conformance_test.go+177 −0fixtures.go+75 −0fixtures/custody.json+105 −0fixtures/hostile_hosts.json+36 −0fixtures_test.go+47 −0transport.go+8 −4
Intent
Attributed to this commit: 234 g CO₂e (29315105 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-contract-module, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/README.md b/README.md| index 3b5297a..c7bcad6 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -13,15 +13,47 @@ owns the rule that binds the others. |
| - `docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`: the |
| doctrine. The team is the boundary. Inside it the apps are one thing; |
| across it, sharing is membership and nothing moves. Read §1 first. |
| -- The contract module, `amadan.net/oficina/suite`: types, golden |
| - fixtures and a thin client for the calls apps make to each other and to |
| - the home. Not written yet; the doctrine's §12 says when. |
| +- The contract module, `amadan.net/oficina/suite`: types, golden fixtures |
| + and a thin client for the calls apps make to each other. It has no |
| + dependencies, so the pin chain from an app is one deep. |
| +- `fixtures/`: the golden set. `custody.json` is one case per rung of the |
| + sharing ladder; `hostile_hosts.json` is the list of hostnames a sibling |
| + client must refuse. Both are shared so every app runs the same gate |
| + rather than inventing its own. |
| +- `conformance/`: the harness an app points at its own server half. |
| ## How an app uses it |
| An app spec cites the doctrine and lists the rulings it consumes. It never |
| -restates one. An app imports the module, serves its server half, and runs |
| -the conformance suite in CI against the version it pins. |
| +restates one. |
| + |
| +In code, an app imports the module, mounts the three sibling routes on a |
| +handler, and runs the harness against that handler in CI: |
| + |
| +```go |
| +func TestSuiteConformance(t *testing.T) { |
| + conformance.Run(t, myapp.SuiteRoutes()) |
| +} |
| +``` |
| + |
| +For the calls it makes, it takes a client that cannot leave its own team: |
| + |
| +```go |
| +c := suite.NewSiblingClient("acme", nil) // refuses another team's label, |
| + // and never follows a redirect |
| +``` |
| + |
| +## What is built, and what is not |
| + |
| +Built: S1 the change feed, S2 authorization with its total decoder, S3 |
| +content, the typed asker subject, the team-bound client, and gates 1 to 4. |
| +That is the doctrine's §12 step 2, and it does not wait on the home. |
| + |
| +Not built, deliberately: the server half — that arrives with the first app |
| +that serves one, and the fixtures get corrected by the second, which is the |
| +point of §10. Nor call 7, call 8, the roster or anything needing an |
| +assertion; those are §12 steps 4 and 5 and they wait on the home's identity |
| +layer. |
| ## Status |
diff --git a/conformance/conformance.go b/conformance/conformance.go| new file mode 100644 |
| index 0000000..d2ce631 |
| --- /dev/null |
| +++ b/conformance/conformance.go |
| @@ -0,0 +1,140 @@ |
| +// Package conformance drives an app's server half against the doctrine's |
| +// golden fixtures. |
| +// |
| +// An app mounts its suite routes on a handler and hands that handler here. |
| +// The harness fires the fixture's requests at it and reports what the contract |
| +// says should have happened and did not. Doctrine §10: two specs are guesses, |
| +// two implementations are facts — this is where the second implementation |
| +// corrects the first. |
| +package conformance |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "strconv" |
| + "testing" |
| + |
| + "amadan.net/oficina/suite" |
| +) |
| + |
| +// Report is what a run found. An empty Failures is a pass. |
| +type Report struct { |
| + Checked int |
| + Failures []string |
| +} |
| + |
| +func (r *Report) fail(format string, args ...any) { |
| + r.Failures = append(r.Failures, fmt.Sprintf(format, args...)) |
| +} |
| + |
| +// Run drives the handler and fails the test with anything the contract |
| +// forbids. This is what an app calls from its own CI. |
| +func Run(t *testing.T, h http.Handler) { |
| + t.Helper() |
| + report := Check(h) |
| + for _, f := range report.Failures { |
| + t.Error(f) |
| + } |
| + if report.Checked == 0 { |
| + t.Error("conformance ran no checks; a gate that asserts nothing passes everything") |
| + } |
| +} |
| + |
| +// Check drives the handler and returns what it found, without touching a |
| +// *testing.T. It exists so the harness itself can be tested: a harness that |
| +// passes every server is worse than no harness, and the only way to know is to |
| +// point it at servers that should fail. |
| +func Check(h http.Handler) Report { |
| + var r Report |
| + |
| + fixture, err := suite.LoadCustodyFixture() |
| + if err != nil { |
| + r.fail("loading the custody fixture: %v", err) |
| + return r |
| + } |
| + |
| + checkS3(h, fixture, &r) |
| + checkS2(h, fixture, &r) |
| + checkS1(h, &r) |
| + return r |
| +} |
| + |
| +// Gate 3, the content half. Every rung but `team` answers 404, and it answers |
| +// the same 404 as a deleted item — a consumer that could tell "not |
| +// team-visible" from "does not exist" would learn that a private item exists. |
| +func checkS3(h http.Handler, f suite.CustodyFixture, r *Report) { |
| + for _, c := range f.Cases { |
| + url := suite.RouteS3 + "/" + c.ItemID + "?revision=" + strconv.FormatInt(c.Revision, 10) |
| + rec := do(h, "GET", url, nil) |
| + r.Checked++ |
| + |
| + if rec.Code != c.S3Status { |
| + r.fail("S3 %s (%s, rung %s): got %d, want %d", c.ItemID, c.Name, c.Rung, rec.Code, c.S3Status) |
| + } |
| + if rec.Code == 403 { |
| + r.fail("S3 %s: answered 403, and the contract is 404 never 403 — a 403 says the item exists, which is the fact custody hides", c.ItemID) |
| + } |
| + } |
| +} |
| + |
| +// Gate 3, the authorization half. The fixture's asker deliberately holds a |
| +// live grant on the private and granted items and a former grant on the |
| +// deleted one: the custody intersection (§4.3) has to beat the grant, or a |
| +// consumer holding an ingested id learns the item is still there. |
| +func checkS2(h http.Handler, f suite.CustodyFixture, r *Report) { |
| + pairs := make([]suite.ItemRevision, 0, len(f.Cases)) |
| + want := make(map[string]suite.Authorization, len(f.Cases)) |
| + for _, c := range f.Cases { |
| + pairs = append(pairs, suite.ItemRevision{ItemID: c.ItemID, Revision: c.Revision}) |
| + want[c.ItemID] = c.S2Result |
| + } |
| + |
| + if err := suite.CheckS2Batch(pairs); err != nil { |
| + r.fail("custody fixture is over the S2 cap: %v", err) |
| + return |
| + } |
| + |
| + body, err := json.Marshal(suite.S2Request{Asker: f.Asker, Pairs: pairs}) |
| + if err != nil { |
| + r.fail("encoding the S2 question: %v", err) |
| + return |
| + } |
| + |
| + rec := do(h, "POST", suite.RouteS2, body) |
| + got := suite.DecodeS2(pairs, rec.Code, rec.Body.Bytes()) |
| + |
| + for i, p := range pairs { |
| + r.Checked++ |
| + if got[i] != want[p.ItemID] { |
| + r.fail("S2 %s: got %s, want %s", p.ItemID, got[i], want[p.ItemID]) |
| + } |
| + } |
| +} |
| + |
| +// Gate 1 for the feed, and the leak memoria's draft 1 shipped. The decoder |
| +// refuses unknown fields, so a row carrying a title, a body or a visibility |
| +// flag fails here rather than in a store somebody greps a year later. |
| +func checkS1(h http.Handler, r *Report) { |
| + rec := do(h, "GET", suite.RouteS1+"?cursor=", nil) |
| + r.Checked++ |
| + |
| + if _, err := suite.DecodeS1(rec.Code, rec.Body.Bytes()); err != nil { |
| + r.fail("S1: %v", err) |
| + } |
| +} |
| + |
| +func do(h http.Handler, method, url string, body []byte) *httptest.ResponseRecorder { |
| + var rdr *bytes.Reader |
| + if body == nil { |
| + rdr = bytes.NewReader(nil) |
| + } else { |
| + rdr = bytes.NewReader(body) |
| + } |
| + req := httptest.NewRequest(method, url, rdr) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + return rec |
| +} |
diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go| new file mode 100644 |
| index 0000000..ddd0ccb |
| --- /dev/null |
| +++ b/conformance/conformance_test.go |
| @@ -0,0 +1,177 @@ |
| +package conformance_test |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/suite" |
| + "amadan.net/oficina/suite/conformance" |
| +) |
| + |
| +// referenceServer is a correct server half, built straight from the fixture. |
| +// It exists so the harness can be tested against something that should pass — |
| +// and, with its behaviour bent, against things that should fail. |
| +type referenceServer struct { |
| + fixture suite.CustodyFixture |
| + |
| + // The bugs each broken variant introduces. |
| + forbidInsteadOfNotFound bool // answers 403 where the contract says 404 |
| + honourGrantOverCustody bool // answers VISIBLE for a granted item |
| + leakVisibilityInFeed bool // adds a field the feed row may not carry |
| +} |
| + |
| +func (s *referenceServer) find(itemID string) (suite.CustodyCase, bool) { |
| + for _, c := range s.fixture.Cases { |
| + if c.ItemID == itemID { |
| + return c, true |
| + } |
| + } |
| + return suite.CustodyCase{}, false |
| +} |
| + |
| +func (s *referenceServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case strings.HasPrefix(r.URL.Path, suite.RouteS3+"/"): |
| + s.serveS3(w, r) |
| + case r.URL.Path == suite.RouteS2: |
| + s.serveS2(w, r) |
| + case r.URL.Path == suite.RouteS1: |
| + s.serveS1(w, r) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| +} |
| + |
| +func (s *referenceServer) serveS3(w http.ResponseWriter, r *http.Request) { |
| + id := strings.TrimPrefix(r.URL.Path, suite.RouteS3+"/") |
| + c, ok := s.find(id) |
| + if !ok { |
| + w.WriteHeader(404) |
| + return |
| + } |
| + if c.Rung == suite.RungTeam { |
| + w.WriteHeader(200) |
| + w.Write([]byte("body of " + id)) |
| + return |
| + } |
| + if s.forbidInsteadOfNotFound { |
| + w.WriteHeader(403) |
| + return |
| + } |
| + w.WriteHeader(404) |
| +} |
| + |
| +func (s *referenceServer) serveS2(w http.ResponseWriter, r *http.Request) { |
| + var req suite.S2Request |
| + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| + w.WriteHeader(400) |
| + return |
| + } |
| + |
| + type result struct { |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| + Result suite.Authorization `json:"result"` |
| + } |
| + out := struct { |
| + Results []result `json:"results"` |
| + }{} |
| + |
| + for _, p := range req.Pairs { |
| + c, ok := s.find(p.ItemID) |
| + answer := suite.NOT_VISIBLE |
| + switch { |
| + case !ok: |
| + answer = suite.NOT_VISIBLE |
| + case c.Rung == suite.RungTeam: |
| + answer = suite.VISIBLE |
| + case s.honourGrantOverCustody && c.Rung == suite.RungGranted: |
| + // The bug the intersection exists to prevent: answering from the |
| + // grants join alone, so a grantee learns a non-team item exists. |
| + answer = suite.VISIBLE |
| + } |
| + out.Results = append(out.Results, result{ItemID: p.ItemID, Revision: p.Revision, Result: answer}) |
| + } |
| + |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(out) |
| +} |
| + |
| +func (s *referenceServer) serveS1(w http.ResponseWriter, r *http.Request) { |
| + rows := []map[string]any{} |
| + for _, c := range s.fixture.Cases { |
| + row := map[string]any{ |
| + "item_id": c.ItemID, |
| + "revision": c.Revision, |
| + "gone": c.Rung != suite.RungTeam, |
| + "touched_at": "2026-09-05T09:00:00Z", |
| + } |
| + if s.leakVisibilityInFeed { |
| + row["visible_to_team"] = c.Rung == suite.RungTeam |
| + } |
| + rows = append(rows, row) |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(map[string]any{"cursor": "c-" + strconv.Itoa(len(rows)), "rows": rows}) |
| +} |
| + |
| +func newReference(t *testing.T) *referenceServer { |
| + t.Helper() |
| + f, err := suite.LoadCustodyFixture() |
| + if err != nil { |
| + t.Fatalf("loading fixture: %v", err) |
| + } |
| + return &referenceServer{fixture: f} |
| +} |
| + |
| +// A correct server half passes. Without this the harness could fail |
| +// everything and still catch every bug below. |
| +func TestHarnessPassesACorrectServer(t *testing.T) { |
| + report := conformance.Check(newReference(t)) |
| + |
| + if len(report.Failures) != 0 { |
| + t.Errorf("a correct server failed conformance: %v", report.Failures) |
| + } |
| +} |
| + |
| +// Gate 3's real target. A server answering 403 for a non-team item tells the |
| +// caller the item exists, which is what "404, never 403" hides. |
| +func TestHarnessCatches403ForANonTeamItem(t *testing.T) { |
| + s := newReference(t) |
| + s.forbidInsteadOfNotFound = true |
| + |
| + report := conformance.Check(s) |
| + |
| + if len(report.Failures) == 0 { |
| + t.Error("harness passed a server answering 403 where 404 is required") |
| + } |
| +} |
| + |
| +// §4.3's custody intersection. A server answering from its grants join alone |
| +// says VISIBLE for an individually granted item, and a consumer holding that |
| +// item's id learns it still exists. |
| +func TestHarnessCatchesGrantBeatingCustody(t *testing.T) { |
| + s := newReference(t) |
| + s.honourGrantOverCustody = true |
| + |
| + report := conformance.Check(s) |
| + |
| + if len(report.Failures) == 0 { |
| + t.Error("harness passed a server whose S2 ignored the custody intersection") |
| + } |
| +} |
| + |
| +// The leak memoria's draft 1 shipped: a visibility flag on a feed row. |
| +func TestHarnessCatchesAFeedRowCarryingVisibility(t *testing.T) { |
| + s := newReference(t) |
| + s.leakVisibilityInFeed = true |
| + |
| + report := conformance.Check(s) |
| + |
| + if len(report.Failures) == 0 { |
| + t.Error("harness passed a feed row carrying a visibility flag") |
| + } |
| +} |
diff --git a/fixtures.go b/fixtures.go| new file mode 100644 |
| index 0000000..4541ab9 |
| --- /dev/null |
| +++ b/fixtures.go |
| @@ -0,0 +1,75 @@ |
| +package suite |
| + |
| +import ( |
| + "embed" |
| + "encoding/json" |
| + "fmt" |
| +) |
| + |
| +//go:embed fixtures/*.json |
| +var fixtureFS embed.FS |
| + |
| +// Route shapes the server half mounts beside an app's own router. They are |
| +// part of the contract: the conformance harness fires exactly these, so an app |
| +// that mounts them elsewhere fails gate 1 rather than failing in production. |
| +const ( |
| + RouteS1 = "/suite/s1" // GET, ?cursor= |
| + RouteS2 = "/suite/s2" // POST, body is S2Request |
| + RouteS3 = "/suite/s3" // GET, /suite/s3/{item_id}?revision= |
| +) |
| + |
| +// S2Request is the wire shape of an authorization question: a typed asker |
| +// (§3) and up to MaxS2Pairs items. The consumer token rides the transport, |
| +// not the body. |
| +type S2Request struct { |
| + Asker Subject `json:"asker"` |
| + Pairs []ItemRevision `json:"pairs"` |
| +} |
| + |
| +// Rung is where an item sits on the sharing ladder (§4.2, ruling 15). A link |
| +// is not a rung; it is a read-only capability that any rung may carry, and |
| +// custody keys on the rung alone. |
| +type Rung string |
| + |
| +const ( |
| + RungPrivate Rung = "private" |
| + RungGranted Rung = "granted" |
| + RungTeam Rung = "team" |
| + RungDeleted Rung = "deleted" |
| +) |
| + |
| +// CustodyCase is one row of the custody fixture: an item, where it sits, and |
| +// what both calls must answer for the fixture's asker. |
| +type CustodyCase struct { |
| + Name string `json:"name"` |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| + Rung Rung `json:"rung"` |
| + Link string `json:"link"` |
| + S3Status int `json:"s3_status"` |
| + S2Result Authorization `json:"s2_result"` |
| +} |
| + |
| +// CustodyFixture is gate 3's world: one case per rung, plus team-plus-link and |
| +// the after-ingest transitions the reviews asked for. |
| +type CustodyFixture struct { |
| + Team string `json:"team"` |
| + Asker Subject `json:"asker"` |
| + Cases []CustodyCase `json:"cases"` |
| +} |
| + |
| +// LoadCustodyFixture returns the golden custody cases every app runs. |
| +func LoadCustodyFixture() (CustodyFixture, error) { |
| + raw, err := fixtureFS.ReadFile("fixtures/custody.json") |
| + if err != nil { |
| + return CustodyFixture{}, fmt.Errorf("suite: reading custody fixture: %w", err) |
| + } |
| + var f CustodyFixture |
| + if err := json.Unmarshal(raw, &f); err != nil { |
| + return CustodyFixture{}, fmt.Errorf("suite: parsing custody fixture: %w", err) |
| + } |
| + if len(f.Cases) == 0 { |
| + return CustodyFixture{}, fmt.Errorf("suite: custody fixture has no cases") |
| + } |
| + return f, nil |
| +} |
diff --git a/fixtures/custody.json b/fixtures/custody.json| new file mode 100644 |
| index 0000000..6d5865e |
| --- /dev/null |
| +++ b/fixtures/custody.json |
| @@ -0,0 +1,105 @@ |
| +{ |
| + "comment": [ |
| + "Gate 3. One fixture per rung of the sharing ladder (doctrine §4.2,", |
| + "ruling 15), plus the two cases the reviews asked for: an item on the", |
| + "team rung that also carries a link, and an item whose link changed", |
| + "after it was ingested.", |
| + "", |
| + "The asker is the same person for every case, and the item id is quoted", |
| + "in every case, so a server cannot pass by answering differently for", |
| + "different ids. What must differ is the rung; what must not differ is", |
| + "the answer for everything that is not `team`.", |
| + "", |
| + "s3_status is what a content fetch answers. s2_result is what an", |
| + "authorization call answers for `asker`, who deliberately holds a live", |
| + "grant on doc-private and doc-granted and a former grant on doc-deleted:", |
| + "the intersection with custody (§4.3) must beat the grant every time." |
| + ], |
| + |
| + "team": "acme", |
| + "asker": { "kind": "person_ref", "value": "pers_ana" }, |
| + |
| + "cases": [ |
| + { |
| + "name": "team rung is the only thing custody admits", |
| + "item_id": "doc-team", |
| + "revision": 7, |
| + "rung": "team", |
| + "link": null, |
| + "s3_status": 200, |
| + "s2_result": "VISIBLE" |
| + }, |
| + { |
| + "name": "private, and the asker owns it", |
| + "item_id": "doc-private", |
| + "revision": 4, |
| + "rung": "private", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "granted to the asker individually", |
| + "item_id": "doc-granted", |
| + "revision": 9, |
| + "rung": "granted", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "granted to a guest only", |
| + "item_id": "doc-guest", |
| + "revision": 2, |
| + "rung": "granted", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "link-shared but not team-shared: a link is not a rung", |
| + "item_id": "doc-link", |
| + "revision": 5, |
| + "rung": "private", |
| + "link": "read", |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "team-shared and link-shared: team-visible because of the rung", |
| + "item_id": "doc-team-link", |
| + "revision": 3, |
| + "rung": "team", |
| + "link": "read", |
| + "s3_status": 200, |
| + "s2_result": "VISIBLE" |
| + }, |
| + { |
| + "name": "deleted, with a former grant held by the asker", |
| + "item_id": "doc-deleted", |
| + "revision": 6, |
| + "rung": "deleted", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "ingested on the team rung, since moved to private", |
| + "item_id": "doc-was-team", |
| + "revision": 8, |
| + "rung": "private", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "a link added after ingest changes nothing either way", |
| + "item_id": "doc-link-added-late", |
| + "revision": 11, |
| + "rung": "private", |
| + "link": "read", |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + } |
| + ] |
| +} |
diff --git a/fixtures/hostile_hosts.json b/fixtures/hostile_hosts.json| new file mode 100644 |
| index 0000000..ded880c |
| --- /dev/null |
| +++ b/fixtures/hostile_hosts.json |
| @@ -0,0 +1,36 @@ |
| +{ |
| + "comment": [ |
| + "Gate 4. Hosts a sibling client belonging to team `acme` must refuse,", |
| + "and the one it must reach. Doctrine §4.1.", |
| + "", |
| + "The gate counts connections, not errors: a refused host must cost zero", |
| + "connection attempts. The redirect case is the exception the doctrine", |
| + "calls out — a client has to reach an allowed host to be told to", |
| + "redirect, so what is forbidden there is the follow-up hop." |
| + ], |
| + |
| + "team": "acme", |
| + |
| + "refuse": [ |
| + { "host": "acme-evil.docs.oficina.works", "why": "a different team label that merely starts with ours" }, |
| + { "host": "acmex.docs.oficina.works", "why": "a longer label with ours as a prefix" }, |
| + { "host": "acme.docs.oficina.works.evil", "why": "in evil, not in oficina.works, despite reading like it" }, |
| + { "host": "evil.com", "why": "outside the suite domain entirely" }, |
| + { "host": "docs.oficina.works", "why": "no team label at all" }, |
| + { "host": "acme.team.docs.oficina.works", "why": "an extra label between team and app" }, |
| + { "host": "ACME-EVIL.docs.oficina.works", "why": "case must not smuggle a different label through" } |
| + ], |
| + |
| + "reach": [ |
| + { "host": "acme.docs.oficina.works", "why": "our own team's Docs instance" }, |
| + { "host": "acme.sheets.oficina.works", "why": "our own team's Sheets instance" }, |
| + { "host": "acme.docs.oficina.works.", "why": "a fully-qualified trailing dot is the same host, and normalising it is the check's job — refusing it would break a legitimate caller" }, |
| + { "host": "ACME.docs.oficina.works", "why": "hostnames are case-insensitive; our own host shouted is still ours" } |
| + ], |
| + |
| + "redirect": { |
| + "from": "acme.docs.oficina.works", |
| + "to": "acme-evil.docs.oficina.works", |
| + "why": "the first hop is legitimate; following the redirect is not" |
| + } |
| +} |
diff --git a/fixtures_test.go b/fixtures_test.go| new file mode 100644 |
| index 0000000..db69f59 |
| --- /dev/null |
| +++ b/fixtures_test.go |
| @@ -0,0 +1,47 @@ |
| +package suite |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "testing" |
| +) |
| + |
| +type hostCase struct { |
| + Host string `json:"host"` |
| + Why string `json:"why"` |
| +} |
| + |
| +type hostileHosts struct { |
| + Team string `json:"team"` |
| + Refuse []hostCase `json:"refuse"` |
| + Reach []hostCase `json:"reach"` |
| + Comment []string `json:"comment"` |
| +} |
| + |
| +// The hostile-hostname fixture is the shared list, so every app runs gate 4 |
| +// against the same hosts rather than each inventing its own. This test is the |
| +// module holding itself to the fixture it publishes. |
| +func TestHostileHostFixture(t *testing.T) { |
| + raw, err := os.ReadFile("fixtures/hostile_hosts.json") |
| + if err != nil { |
| + t.Fatalf("reading fixture: %v", err) |
| + } |
| + var f hostileHosts |
| + if err := json.Unmarshal(raw, &f); err != nil { |
| + t.Fatalf("parsing fixture: %v", err) |
| + } |
| + if len(f.Refuse) == 0 || len(f.Reach) == 0 { |
| + t.Fatal("fixture has no cases; a gate that asserts nothing passes everything") |
| + } |
| + |
| + for _, c := range f.Refuse { |
| + if err := CheckSiblingHost(f.Team, c.Host); err == nil { |
| + t.Errorf("reached %q, want it refused — %s", c.Host, c.Why) |
| + } |
| + } |
| + for _, c := range f.Reach { |
| + if err := CheckSiblingHost(f.Team, c.Host); err != nil { |
| + t.Errorf("refused %q (%v), want it reached — %s", c.Host, err, c.Why) |
| + } |
| + } |
| +} |
diff --git a/transport.go b/transport.go| index 4dfae4f..b5e43f6 100644 |
| --- a/transport.go |
| +++ b/transport.go |
| @@ -67,14 +67,18 @@ func CheckSiblingHost(team, host string) error { |
| host = strings.TrimSuffix(strings.ToLower(host), ".") |
| suffix := "." + SuiteDomain |
| - // The host must sit inside the suite's registrable domain. Checked as a |
| - // suffix on a labelled boundary, so `acme.docs.oficina.works.evil` — which |
| - // is in `evil`, not in `oficina.works` — cannot pass. |
| + // The host must sit inside the suite's registrable domain. |
| if !strings.HasSuffix(host, suffix) { |
| return fmt.Errorf("suite: host %q is not in %s", host, SuiteDomain) |
| } |
| - // `<team>.<app>` remains once the domain is removed. |
| + // Exactly `<team>.<app>` remains once the domain is removed. **This is |
| + // the check that does the work**, and the one to be careful editing. |
| + // A mutation test weakening the HasSuffix above to a Contains left every |
| + // hostile-host fixture still refused, because `acme.docs.oficina.works.evil` |
| + // does not end with the suffix, so nothing is trimmed and it arrives here |
| + // with five labels rather than two. The suffix check is defence in depth |
| + // and a clearer error message; it is not what stops the suffix attack. |
| labels := strings.Split(strings.TrimSuffix(host, suffix), ".") |
| if len(labels) != 2 { |
| return fmt.Errorf("suite: host %q is not <team>.<app>.%s", host, SuiteDomain) |