The typed asker subject, the S2 cap, and a client that will not leave the team
Two of the doctrine's client-side barriers, both testable without a server. The asker subject (§3) is typed on the wire: person_ref in suite shape, address in lone shape. NewSubject refuses a kind it cannot key on rather than sending a value the receiver will fail to match — a miss answers NOT_VISIBLE, which callers may act on silently, so an unmatched subject would report a mis-provisioning as a denial. Addresses are lowercased for the same reason: "Ana@Example.com" against a stored "ana@example.com" is a normalisation bug wearing an authorization answer's clothes. A person ref is opaque and passes through untouched. CheckS2Batch refuses an over-cap question instead of truncating it. A silently shortened question answers confidently about the items that survived, and the dropped ones decode as UNKNOWN with nobody learning why. NewSiblingClient is gate 4. It refuses a host whose <team> label differs before the transport dials, so a refused host costs no connection, and it refuses every redirect — an allowed host must be reached to tell us to redirect, and that hop is fine, but following it would let a sibling walk the client across the boundary the guard just enforced. The suffix check runs on a label boundary so acme.docs.oficina.works.evil, which is in evil rather than in oficina.works, cannot pass. CheckSiblingHost is exported because §7.1 needs it too: an HTML form action is not this client, and a body posted to another team's host has already crossed the boundary by the time that host could refuse it, so the hand-off validates its destination before rendering the form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+318
−0
subject.go+67 −0subject_test.go+71 −0transport.go+87 −0transport_test.go+93 −0
diff --git a/subject.go b/subject.go| new file mode 100644 |
| index 0000000..a3d9bca |
| --- /dev/null |
| +++ b/subject.go |
| @@ -0,0 +1,67 @@ |
| +package suite |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// SubjectKind says how to read an asker subject's value. Doctrine §3: the |
| +// meaning is shape-dependent and nothing else on the wire says which shape |
| +// the pair is in, so a receiver that does not key on the kind it is sent |
| +// answers UNKNOWN — loudly — rather than failing to match and answering |
| +// NOT_VISIBLE, which callers are entitled to act on silently. |
| +type SubjectKind string |
| + |
| +const ( |
| + // SubjectPersonRef is the assertion's opaque `sub` (home §5.3). Suite |
| + // shape, where the home is the identity authority. |
| + SubjectPersonRef SubjectKind = "person_ref" |
| + // SubjectAddress is a lowercased address. Lone shape, where the instance |
| + // keys its own members on the address it signed them in with. |
| + SubjectAddress SubjectKind = "address" |
| +) |
| + |
| +// Subject is the named asker an S2 or S6 call carries beside its consumer |
| +// token. S3 carries none, by design (§6). |
| +type Subject struct { |
| + Kind SubjectKind `json:"kind"` |
| + Value string `json:"value"` |
| +} |
| + |
| +// NewSubject builds a subject, refusing anything a receiver could not key on |
| +// and normalising what it can. |
| +func NewSubject(kind SubjectKind, value string) (Subject, error) { |
| + switch kind { |
| + case SubjectPersonRef: |
| + // Opaque. Case may be significant, so it is passed through untouched. |
| + case SubjectAddress: |
| + // Lowercased, because that is what a lone instance stores. Sending |
| + // mixed case to a receiver holding the lowercased form misses, and a |
| + // miss answers NOT_VISIBLE — the silent value — for what is really a |
| + // normalisation bug. |
| + value = strings.ToLower(value) |
| + default: |
| + return Subject{}, fmt.Errorf("suite: unknown asker subject kind %q", kind) |
| + } |
| + |
| + if value == "" { |
| + return Subject{}, fmt.Errorf("suite: empty asker subject value for kind %q", kind) |
| + } |
| + return Subject{Kind: kind, Value: value}, nil |
| +} |
| + |
| +// MaxS2Pairs caps one S2 question. 256 is memoria §6.1's number, carried here |
| +// with the contract: it bounds the work a sibling can be made to do in one |
| +// call, and it bounds the size of a response the total decoder has to keep in |
| +// memory before it can answer. |
| +const MaxS2Pairs = 256 |
| + |
| +// CheckS2Batch refuses an over-cap question rather than truncating it. A |
| +// silently shortened question produces a confident answer about the items |
| +// that survived; the ones dropped decode as UNKNOWN and nobody learns why. |
| +func CheckS2Batch(pairs []ItemRevision) error { |
| + if len(pairs) > MaxS2Pairs { |
| + return fmt.Errorf("suite: %d pairs exceeds the S2 cap of %d", len(pairs), MaxS2Pairs) |
| + } |
| + return nil |
| +} |
diff --git a/subject_test.go b/subject_test.go| new file mode 100644 |
| index 0000000..5f4092d |
| --- /dev/null |
| +++ b/subject_test.go |
| @@ -0,0 +1,71 @@ |
| +package suite |
| + |
| +import "testing" |
| + |
| +// Doctrine §3: the asker subject is typed on the wire, because its meaning is |
| +// shape-dependent — a person ref in suite shape, a lowercased address in lone |
| +// shape — and nothing else in an S2 or S6 call says which shape the pair is |
| +// in. A kind the receiver does not key on must reach the caller as UNKNOWN, |
| +// never as NOT_VISIBLE, which is the one value it may act on silently. |
| +func TestSubjectRejectsUnknownKind(t *testing.T) { |
| + _, err := NewSubject("employee_number", "4471") |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for an unknown subject kind, want a refusal") |
| + } |
| +} |
| + |
| +func TestSubjectRejectsEmptyValue(t *testing.T) { |
| + _, err := NewSubject(SubjectAddress, "") |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for an empty subject value, want a refusal") |
| + } |
| +} |
| + |
| +// An address subject is lowercased, because that is what a lone instance keys |
| +// its members on (§3). Sending "Ana@Example.com" to a receiver storing |
| +// "ana@example.com" misses, and a miss answers NOT_VISIBLE — silently. |
| +func TestSubjectLowercasesAddress(t *testing.T) { |
| + s, err := NewSubject(SubjectAddress, "Ana@Example.COM") |
| + if err != nil { |
| + t.Fatalf("unexpected refusal: %v", err) |
| + } |
| + |
| + if s.Value != "ana@example.com" { |
| + t.Errorf("got %q, want %q", s.Value, "ana@example.com") |
| + } |
| +} |
| + |
| +// A person ref is opaque and is not touched. It is the assertion's `sub` |
| +// (home §5.3), and case may be significant in it. |
| +func TestSubjectLeavesPersonRefAlone(t *testing.T) { |
| + s, err := NewSubject(SubjectPersonRef, "PeRs_7Fa2") |
| + if err != nil { |
| + t.Fatalf("unexpected refusal: %v", err) |
| + } |
| + |
| + if s.Value != "PeRs_7Fa2" { |
| + t.Errorf("got %q, want the person ref unchanged", s.Value) |
| + } |
| +} |
| + |
| +// §6 caps an S2 question at 256 pairs. Over the cap the batch is refused, not |
| +// truncated: a silently shortened question produces a confident answer about |
| +// items nobody asked about, and the missing ones would decode as UNKNOWN |
| +// without anyone learning why. |
| +func TestS2BatchOverCapIsRefused(t *testing.T) { |
| + pairs := make([]ItemRevision, MaxS2Pairs+1) |
| + |
| + if err := CheckS2Batch(pairs); err == nil { |
| + t.Fatalf("got nil error for %d pairs, want a refusal", len(pairs)) |
| + } |
| +} |
| + |
| +func TestS2BatchAtCapIsAccepted(t *testing.T) { |
| + pairs := make([]ItemRevision, MaxS2Pairs) |
| + |
| + if err := CheckS2Batch(pairs); err != nil { |
| + t.Errorf("got %v for %d pairs, want it accepted", err, len(pairs)) |
| + } |
| +} |
diff --git a/transport.go b/transport.go| new file mode 100644 |
| index 0000000..4dfae4f |
| --- /dev/null |
| +++ b/transport.go |
| @@ -0,0 +1,87 @@ |
| +package suite |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "strings" |
| +) |
| + |
| +// SuiteDomain is the registrable domain every suite host sits under. A sibling |
| +// host is `<team>.<app>.oficina.works` (doctrine §4.1). |
| +const SuiteDomain = "oficina.works" |
| + |
| +// DialFunc is the hook the sibling client dials through. Tests pass a |
| +// recorder; production passes nil and gets the standard dialer. |
| +type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) |
| + |
| +// NewSiblingClient returns an HTTP client that will only ever reach hosts |
| +// inside its own team, and will never follow a redirect. |
| +// |
| +// Doctrine §4.1: every sibling client refuses a host whose `<team>` label |
| +// differs from its own, and refuses to follow redirects. Neither is a barrier |
| +// against code execution on an instance — they stop mis-provisioning and |
| +// accidental cross-team dialling, and that is all they are claimed as. |
| +func NewSiblingClient(team string, dial DialFunc) *http.Client { |
| + base := &http.Transport{} |
| + if dial != nil { |
| + base.DialContext = dial |
| + } |
| + |
| + return &http.Client{ |
| + Transport: &teamBoundTransport{team: team, next: base}, |
| + CheckRedirect: func(req *http.Request, via []*http.Request) error { |
| + // An allowed host has to be reached to tell us to redirect; that |
| + // hop is legitimate. Following it is not: a sibling could walk |
| + // this client across the boundary the guard just enforced. |
| + return fmt.Errorf("suite: refusing to follow a redirect to %q", req.URL.Host) |
| + }, |
| + } |
| +} |
| + |
| +type teamBoundTransport struct { |
| + team string |
| + next http.RoundTripper |
| +} |
| + |
| +func (t *teamBoundTransport) RoundTrip(req *http.Request) (*http.Response, error) { |
| + if err := CheckSiblingHost(t.team, req.URL.Hostname()); err != nil { |
| + // Before the transport dials, so a refused host costs no connection. |
| + return nil, err |
| + } |
| + return t.next.RoundTrip(req) |
| +} |
| + |
| +// CheckSiblingHost answers whether `host` is a sibling of `team`'s instances. |
| +// |
| +// It is exported because it guards more than the HTTP client: §7.1's hand-off |
| +// validates a form's destination with it before the form is rendered, since an |
| +// HTML form action is not this client and a body posted to another team's host |
| +// has already crossed the boundary by the time that host could refuse it. |
| +func CheckSiblingHost(team, host string) error { |
| + if team == "" { |
| + return fmt.Errorf("suite: no team configured for the sibling client") |
| + } |
| + |
| + 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. |
| + 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. |
| + labels := strings.Split(strings.TrimSuffix(host, suffix), ".") |
| + if len(labels) != 2 { |
| + return fmt.Errorf("suite: host %q is not <team>.<app>.%s", host, SuiteDomain) |
| + } |
| + |
| + if labels[0] != strings.ToLower(team) { |
| + return fmt.Errorf("suite: host %q is team %q, not %q", host, labels[0], team) |
| + } |
| + return nil |
| +} |
diff --git a/transport_test.go b/transport_test.go| new file mode 100644 |
| index 0000000..58d163b |
| --- /dev/null |
| +++ b/transport_test.go |
| @@ -0,0 +1,93 @@ |
| +package suite |
| + |
| +import ( |
| + "context" |
| + "net" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// recordingDialer stands in for the network. It records every address a |
| +// client tried to reach and refuses to connect, so a test can assert on the |
| +// attempt rather than on the outcome. Gate 4 is about connections not made. |
| +type recordingDialer struct{ dialed []string } |
| + |
| +func (d *recordingDialer) dial(ctx context.Context, network, addr string) (net.Conn, error) { |
| + d.dialed = append(d.dialed, addr) |
| + return nil, errRefusedByTest |
| +} |
| + |
| +var errRefusedByTest = &net.OpError{Op: "dial", Err: errTest("no network in tests")} |
| + |
| +type errTest string |
| + |
| +func (e errTest) Error() string { return string(e) } |
| + |
| +// Gate 4. A host whose <team> label differs from the caller's own is refused |
| +// before any connection is attempted. §4.1: the barrier stops mis-provisioning |
| +// and accidental cross-team dialling, and it is claimed as nothing more. |
| +func TestSiblingClientRefusesForeignTeamLabel(t *testing.T) { |
| + d := &recordingDialer{} |
| + c := NewSiblingClient("acme", d.dial) |
| + |
| + _, err := c.Get("https://acme-evil.docs.oficina.works/s3/doc-1") |
| + |
| + if err == nil { |
| + t.Fatal("got nil error dialling another team's label, want a refusal") |
| + } |
| + if len(d.dialed) != 0 { |
| + t.Errorf("dialled %v, want no connection attempted at all", d.dialed) |
| + } |
| +} |
| + |
| +// The suffix attack: a host that merely ends with something resembling the |
| +// suite's domain. `acme.docs.oficina.works.evil` is not in oficina.works at |
| +// all, and a naive prefix check on the first label would wave it through. |
| +func TestSiblingClientRefusesSuffixAttack(t *testing.T) { |
| + d := &recordingDialer{} |
| + c := NewSiblingClient("acme", d.dial) |
| + |
| + _, err := c.Get("https://acme.docs.oficina.works.evil/s3/doc-1") |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a host outside the suite domain, want a refusal") |
| + } |
| + if len(d.dialed) != 0 { |
| + t.Errorf("dialled %v, want no connection attempted at all", d.dialed) |
| + } |
| +} |
| + |
| +// A same-team sibling is reached. Without this the guard could refuse |
| +// everything and still pass the two tests above. |
| +func TestSiblingClientReachesSameTeamHost(t *testing.T) { |
| + d := &recordingDialer{} |
| + c := NewSiblingClient("acme", d.dial) |
| + |
| + _, _ = c.Get("https://acme.docs.oficina.works/s3/doc-1") |
| + |
| + if len(d.dialed) != 1 { |
| + t.Fatalf("dialled %v, want exactly one connection to the sibling", d.dialed) |
| + } |
| + if !strings.HasPrefix(d.dialed[0], "acme.docs.oficina.works:") { |
| + t.Errorf("dialled %q, want the same-team host", d.dialed[0]) |
| + } |
| +} |
| + |
| +// Redirects are never followed. An allowed host must be reached to be told to |
| +// redirect — that connection is legitimate and gate 4 counts follow-ups, not |
| +// first hops — but following one would let a sibling walk the client across |
| +// the team boundary the first check just enforced. |
| +func TestSiblingClientDoesNotFollowRedirects(t *testing.T) { |
| + c := NewSiblingClient("acme", nil) |
| + |
| + req, err := http.NewRequest("GET", "https://acme.docs.oficina.works/s3/doc-1", nil) |
| + if err != nil { |
| + t.Fatalf("building request: %v", err) |
| + } |
| + via := []*http.Request{req} |
| + |
| + if err := c.CheckRedirect(req, via); err == nil { |
| + t.Error("got nil from CheckRedirect, want every redirect refused") |
| + } |
| +} |