Skip to content
quarry Artifact [13050165ea]
Login

Artifact [13050165ea]

Artifact 13050165eaa8e0e13ee379cfd209e8ab92fda57ae7ec4d1d5a57aef6c2ab601f:


package xfer

import (
	"bytes"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"
)

// challengeFronter models the three cases that matter, so each takes a
// different path through ServeHTTP:
//
//	secret  private   — the gate refuses it
//	open    public    — the gate passes it and the registry knows it
//	absent  no such   — the gate passes it and the registry does not
//
// Neither "open" nor "secret" has a store behind it, so ensure fails for
// both; what separates them is whether the registry has heard of them,
// which is exactly the distinction the challenge must not expose.
func challengeFronter(t *testing.T) *Fronter {
	t.Helper()
	f := New()
	f.SetReadGate(func(_ *http.Request, repo, login string) bool {
		return repo != "secret" || login == "insider"
	})
	f.SetKnown(func(name string) bool { return name == "secret" || name == "open" })
	return f
}

func syncPost(t *testing.T, repo string) *http.Request {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, "/r/"+repo, nil)
	req.SetPathValue("repo", repo)
	req.Header.Set("Content-Type", "application/x-fossil")
	return req
}

// A credential-less caller is CHALLENGED, not dismissed.
//
// fossil's first clone request carries no credential: it sends Basic
// only after a 401, and a login card only on later rounds of a sync
// already under way. Answering 404 meant it was never challenged and
// never authenticated, so a private repository was unclonable by the
// people it admits — the gate's judgement was correct and unreachable
// (b112b205). jva found this by tracing the wire after the gate fix
// looked right and changed nothing.
func TestACredentiallessReadIsChallenged(t *testing.T) {
	f := challengeFronter(t)
	w := httptest.NewRecorder()
	f.ServeHTTP(w, syncPost(t, "secret"))

	if w.Code != http.StatusUnauthorized {
		t.Fatalf("a private repository answered %d to a caller with no credential, want 401: "+
			"no stock client ever authenticates without being asked", w.Code)
	}
	if got := w.Header().Get("WWW-Authenticate"); got == "" {
		t.Error("the challenge carries no WWW-Authenticate header, so no client will retry")
	}
}

// THE PROPERTY THAT MAKES THE CHALLENGE SAFE.
//
// An unknown repository must answer a credential-less caller exactly as
// a private one does, or the difference between 401 and 404 becomes an
// existence oracle — which is the leak that answering 404 was chosen to
// avoid in the first place.
func TestPrivateAndUnknownAreIndistinguishable(t *testing.T) {
	f := challengeFronter(t)

	priv := httptest.NewRecorder()
	f.ServeHTTP(priv, syncPost(t, "secret"))
	absent := httptest.NewRecorder()
	f.ServeHTTP(absent, syncPost(t, "absent"))

	if priv.Code != absent.Code {
		t.Errorf("private answered %d and absent answered %d: the pair tells a stranger "+
			"which repositories exist", priv.Code, absent.Code)
	}
	if priv.Header().Get("WWW-Authenticate") != absent.Header().Get("WWW-Authenticate") {
		t.Error("the challenge header differs between a private and an absent repository")
	}
}

// A caller who OFFERED something and still may not read is told 404 and
// not challenged again: they have had their turn, and repeating the
// challenge would loop.
func TestAnOfferedCredentialIsNotChallengedAgain(t *testing.T) {
	f := challengeFronter(t)
	req := syncPost(t, "secret")
	req.SetBasicAuth("outsider", "whatever")
	w := httptest.NewRecorder()
	f.ServeHTTP(w, req)

	if w.Code == http.StatusUnauthorized {
		t.Error("a caller who already presented a credential was challenged again: " +
			"a client that retries on 401 will loop")
	}
	if w.Code != http.StatusNotFound {
		t.Errorf("answered %d, want 404", w.Code)
	}
}

// A public repository is untouched: an anonymous clone must not start
// asking people to log in.
func TestAPublicReadIsNotChallenged(t *testing.T) {
	f := challengeFronter(t)
	w := httptest.NewRecorder()
	f.ServeHTTP(w, syncPost(t, "open"))

	if w.Code == http.StatusUnauthorized {
		t.Fatal("a public repository challenged an anonymous reader: " +
			"an anonymous clone must not start asking people to log in")
	}
}

// THE ROW THAT WAS MISSING, and the reason a private repository stayed
// unclonable for a week while every test here passed.
//
// The three cases above all assert a REFUSAL. None of them asserts that
// the credential the sync protocol actually carries — a login card on
// the first request — gets a reader IN. That is the same gap that caused
// the eleven-hour write outage: the shipped test proved a stranger is
// refused and never proved an insider is admitted, so a gate that
// refused everybody looked correct.
//
// It matters beyond coverage. When this was written no client could
// reach the path at all — zit 0.3.22 answered a 401 by giving up — so
// this test was the only thing standing between "the server half is
// ready" and a claim nobody had checked.
//
// zit 0.3.23 (danmestas/zit#204) reaches it, and by a different door
// than the one I proposed. A clone CANNOT sign its first request: the
// card's shared secret derives from the project code, and the project
// code is what the first reply teaches. So the fix is HTTP Basic on the
// challenge, not a card on round one. This test still says what it says
// — a verified card admits a private read — and the reason it is worth
// keeping is unchanged: it is the only assertion here that the gate ever
// lets anybody IN.
func TestAValidLoginCardReadsAPrivateRepository(t *testing.T) {
	const code = "6f3c2a1b9d8e7f0a5c4b3d2e1f009988aabbccdd"
	secret := SharedSecret(code, "insider", "a-push-credential")

	f := challengeFronter(t)
	f.SetAuth(&mapAuth{users: map[string]string{"insider": secret}})

	req := syncPost(t, "secret")
	req.Body = io.NopCloser(bytes.NewReader(encodeContainer(signedLogin("insider", secret))))
	w := httptest.NewRecorder()
	f.ServeHTTP(w, req)

	switch w.Code {
	case http.StatusUnauthorized:
		t.Fatal("FAIL: a verified login card was challenged: the account proved who it is " +
			"and the gate asked again, which is a loop no client escapes")
	case http.StatusNotFound:
		t.Fatal("FAIL: a verified login card was refused: an account that may read a private " +
			"repository cannot read it through the one credential the sync protocol carries")
	}
	t.Logf("ok: a verified card is admitted (answered %d, past the gate)", w.Code)
}

// The other half of the same claim: the card has to VERIFY, not merely
// be present. A forged signature must not buy a read, or the gate is a
// formality.
//
// It is answered 401 rather than 404, and that is deliberate: a card
// that did not verify has offered the WRONG credential, not a credential
// already spent, so inviting the other mechanism is useful and does not
// loop (a Basic offer is answered 404 — see above). What this asserts is
// the property that has to hold either way: a forged card gets no read,
// and cannot tell a private repository from one that is not there.
func TestAForgedLoginCardDoesNotRead(t *testing.T) {
	const code = "6f3c2a1b9d8e7f0a5c4b3d2e1f009988aabbccdd"
	real := SharedSecret(code, "insider", "a-push-credential")
	forged := encodeContainer(signedLogin("insider", "not-the-secret"))

	f := challengeFronter(t)
	f.SetAuth(&mapAuth{users: map[string]string{"insider": real}})

	priv := httptest.NewRecorder()
	req := syncPost(t, "secret")
	req.Body = io.NopCloser(bytes.NewReader(forged))
	f.ServeHTTP(priv, req)

	absent := httptest.NewRecorder()
	req = syncPost(t, "absent")
	req.Body = io.NopCloser(bytes.NewReader(forged))
	f.ServeHTTP(absent, req)

	switch priv.Code {
	case http.StatusUnauthorized, http.StatusNotFound:
	default:
		t.Fatalf("FAIL: a card signed with the wrong secret answered %d: naming an "+
			"account that may read is not the same as being it", priv.Code)
	}
	if priv.Code != absent.Code {
		t.Fatalf("FAIL: a forged card got %d from a private repository and %d from one that "+
			"does not exist: a stranger can now enumerate which repositories are real",
			priv.Code, absent.Code)
	}
	t.Logf("ok: a forged card reads nothing, and private and absent answer alike (%d)", priv.Code)
}