oficina / suite Public

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>
Paul Campbell pushed by paul@keymail.dev dc0d7df18e687744b960a24ccdb68acb27fba627 parent 10c6995
4 files changed, +318 −0
  • subject.go +67 −0
  • subject_test.go +71 −0
  • transport.go +87 −0
  • transport_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")
+ }
+}