oficina / home Public

Separate Home credential routes from the directory host

Guard every application request against canonical hosts and restrict authentication and passkey assets to the credential host. Redirect credential root to the directory without rendering its content.

Validation: make test passed, including race detector. Removing credentialOnly or serveKnownHost rejection made the corresponding adversarial route test fail (both exit 1).
Paul Campbell pushed by paul@keymail.dev 4322001a079b2d660805790c7e8ed12947b7d089 parent 7d68e87
10 files changed, +194 −41
  • internal/home/app.go +51 −40
  • internal/home/app_test.go +2 −0
  • internal/home/keymailstub_test.go +1 −0
  • internal/home/logs_test.go +8 −0
  • internal/home/noscript_test.go +4 −0
  • internal/home/origins.go +30 −0
  • internal/home/origins_test.go +77 −0
  • internal/home/passkey_test.go +9 −0
  • internal/home/signin.go +1 −1
  • internal/home/signin_test.go +11 −0
diff --git a/internal/home/app.go b/internal/home/app.go
index e5562cb..ef62fdd 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -136,9 +136,17 @@ func (a *App) Router() http.Handler {
// blind-SSRF surface by design — and csrf.Protect is pure origin
// evidence (Sec-Fetch-Site, then Origin, then Referer) that never
// consults a session, so it works before anybody is signed in.
+ r.Use(serveKnownHost)
r.Use(csrf.Protect(Origin))
- r.Get("/", a.handleIndex)
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+ if r.Host == credentialHost {
+ w.Header().Set("Location", directoryOrigin+"/")
+ w.WriteHeader(http.StatusSeeOther)
+ return
+ }
+ a.handleIndex(w, r)
+ })
// This /healthz is NOT what answers in a deployed instance:
// rastrillo.Serve registers its own GET /healthz on the outer mux
// (serve.go's buildHandler), and Go 1.22+ http.ServeMux prefers that
@@ -150,48 +158,51 @@ func (a *App) Router() http.Handler {
w.WriteHeader(http.StatusOK)
})
- r.Get("/signin", a.handleSigninForm)
- r.Post("/signin", a.handleSigninBegin)
- r.Get("/signin/sent", a.handleSigninSent)
- // GET renders, POST redeems. See handleVerifyForm for why.
- r.Get(linkPath, a.handleVerifyForm)
- r.Post(linkPath, a.handleVerify)
- r.Get(callbackPath, a.handleCallback)
-
- // The second factor. The ceremony endpoints are JSON POSTs driven by
- // static/passkey.js; the recovery POST is a plain form and works
- // with script off, because recovery is exactly the moment WebAuthn
- // is not working.
- r.Get(confirmPath, a.handlePasskeyConfirm)
- r.Post("/passkey/stepup/begin", a.passkeys.StepUpBegin)
- r.Post("/passkey/stepup/finish", a.passkeys.StepUpFinish)
- r.Post("/passkey/signin/begin", a.passkeys.SignInBegin)
- r.Post("/passkey/signin/finish", a.passkeys.SignInFinish)
- r.Post("/passkey/signin/recovery", a.passkeys.SignInRecovery)
- // Enrolling changes who can get in, and there is no operator reset,
- // so it wants a recent session rather than merely a valid one — see
- // enrolFreshness for the lockout this closes.
r.Group(func(r chi.Router) {
- r.Use(a.sessions.RequireFresh(enrolFreshness))
- r.Get("/passkey/enrol", a.handlePasskeyEnrol)
- r.Post("/passkey/register/begin", a.passkeys.RegisterBegin)
- r.Post("/passkey/register/finish", a.passkeys.RegisterFinish)
+ r.Use(credentialOnly)
+ r.Get("/signin", a.handleSigninForm)
+ r.Post("/signin", a.handleSigninBegin)
+ r.Get("/signin/sent", a.handleSigninSent)
+ // GET renders, POST redeems. See handleVerifyForm for why.
+ r.Get(linkPath, a.handleVerifyForm)
+ r.Post(linkPath, a.handleVerify)
+ r.Get(callbackPath, a.handleCallback)
+
+ // The second factor. The ceremony endpoints are JSON POSTs driven by
+ // static/passkey.js; the recovery POST is a plain form and works
+ // with script off, because recovery is exactly the moment WebAuthn
+ // is not working.
+ r.Get(confirmPath, a.handlePasskeyConfirm)
+ r.Post("/passkey/stepup/begin", a.passkeys.StepUpBegin)
+ r.Post("/passkey/stepup/finish", a.passkeys.StepUpFinish)
+ r.Post("/passkey/signin/begin", a.passkeys.SignInBegin)
+ r.Post("/passkey/signin/finish", a.passkeys.SignInFinish)
+ r.Post("/passkey/signin/recovery", a.passkeys.SignInRecovery)
+ // Enrolling changes who can get in, and there is no operator reset,
+ // so it wants a recent session rather than merely a valid one — see
+ // enrolFreshness for the lockout this closes.
+ r.Group(func(r chi.Router) {
+ r.Use(a.sessions.RequireFresh(enrolFreshness))
+ r.Get("/passkey/enrol", a.handlePasskeyEnrol)
+ r.Post("/passkey/register/begin", a.passkeys.RegisterBegin)
+ r.Post("/passkey/register/finish", a.passkeys.RegisterFinish)
+ })
+
+ // The codes page shows ten codes once, so it wants a recent session
+ // rather than merely a valid one.
+ r.Group(func(r chi.Router) {
+ r.Use(a.sessions.RequireFresh(codesFreshness))
+ r.Get("/passkey/codes", a.handleRecoveryCodesPage)
+ r.Post("/passkey/codes", a.handleRegenerateRecoveryCodes)
+ })
+
+ // Stable, unfingerprinted paths: Task 7's scriptless allowlist has
+ // to name them, and a rule that cannot name its subject is not a
+ // rule.
+ r.Get("/static/webauthn.mjs", serveAsset(webauthnJS(), "text/javascript; charset=utf-8"))
+ r.Get("/static/passkey.js", serveAsset(staticAsset("passkey.js"), "text/javascript; charset=utf-8"))
})
- // The codes page shows ten codes once, so it wants a recent session
- // rather than merely a valid one.
- r.Group(func(r chi.Router) {
- r.Use(a.sessions.RequireFresh(codesFreshness))
- r.Get("/passkey/codes", a.handleRecoveryCodesPage)
- r.Post("/passkey/codes", a.handleRegenerateRecoveryCodes)
- })
-
- // Stable, unfingerprinted paths: Task 7's scriptless allowlist has
- // to name them, and a rule that cannot name its subject is not a
- // rule.
- r.Get("/static/webauthn.mjs", serveAsset(webauthnJS(), "text/javascript; charset=utf-8"))
- r.Get("/static/passkey.js", serveAsset(staticAsset("passkey.js"), "text/javascript; charset=utf-8"))
-
// Vendored from rastrillo's ui package, not linked from it: an app
// owns its stylesheet from the moment it lands, and
// `rastrillo doctor --fix` is what reconciles it on an upgrade.
diff --git a/internal/home/app_test.go b/internal/home/app_test.go
index 76b5755..efd584e 100644
--- a/internal/home/app_test.go
+++ b/internal/home/app_test.go
@@ -12,6 +12,7 @@ import (
func TestIndex(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Host = directoryHost
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
@@ -30,6 +31,7 @@ func TestIndex(t *testing.T) {
func TestHealthz(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ req.Host = directoryHost
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
diff --git a/internal/home/keymailstub_test.go b/internal/home/keymailstub_test.go
index bc28754..1cde613 100644
--- a/internal/home/keymailstub_test.go
+++ b/internal/home/keymailstub_test.go
@@ -174,6 +174,7 @@ func callback(t *testing.T, app *testApp, code, state string, cookies ...*http.C
t.Helper()
req := httptest.NewRequest(http.MethodGet,
callbackPath+"?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil)
+ req.Host = credentialHost
for _, c := range cookies {
if c != nil {
req.AddCookie(c)
diff --git a/internal/home/logs_test.go b/internal/home/logs_test.go
index 73dbdea..ef84f57 100644
--- a/internal/home/logs_test.go
+++ b/internal/home/logs_test.go
@@ -122,6 +122,10 @@ func TestNoContentInLogs(t *testing.T) {
}.Encode())
req := httptest.NewRequest(tg.method, tg.path+"?q="+url.QueryEscape(logtest.Canaries[0]), body)
+ req.Host = credentialHost
+ if req.URL.Path == "/" {
+ req.Host = directoryHost
+ }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// csrf.Protect is mounted router-wide and short-circuits a
// state-changing request with no origin evidence — before chi
@@ -172,6 +176,10 @@ func TestNoContentInLogs(t *testing.T) {
}
body := strings.NewReader(url.Values{"address": {logtest.Canaries[0]}}.Encode())
req := httptest.NewRequest(tg.method, tg.path, body)
+ req.Host = credentialHost
+ if req.URL.Path == "/" {
+ req.Host = directoryHost
+ }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", "https://"+logtest.Canaries[1]+".example.invalid")
req.Header.Set("User-Agent", logtest.Canaries[0])
diff --git a/internal/home/noscript_test.go b/internal/home/noscript_test.go
index d463d20..51e020d 100644
--- a/internal/home/noscript_test.go
+++ b/internal/home/noscript_test.go
@@ -300,6 +300,10 @@ func TestServedCSPAllowsNoInlineScript(t *testing.T) {
for _, tg := range targets {
p := tg.path
req := httptest.NewRequest(tg.method, p, strings.NewReader("address=someone@example.invalid"))
+ req.Host = credentialHost
+ if req.URL.Path == "/" {
+ req.Host = directoryHost
+ }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// The POSTs are behind csrf.Protect, which short-circuits before
// the handler runs — and a handler that never runs sets no
diff --git a/internal/home/origins.go b/internal/home/origins.go
new file mode 100644
index 0000000..553ed1a
--- /dev/null
+++ b/internal/home/origins.go
@@ -0,0 +1,30 @@
+package home
+
+import "net/http"
+
+const (
+ suiteDomain = "oficina.works"
+ credentialHost = "id." + suiteDomain
+ directoryHost = suiteDomain
+ directoryOrigin = "https://" + directoryHost
+)
+
+func serveKnownHost(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Host != credentialHost && r.Host != directoryHost {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func credentialOnly(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Host != credentialHost {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go
new file mode 100644
index 0000000..6392707
--- /dev/null
+++ b/internal/home/origins_test.go
@@ -0,0 +1,77 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+func TestEveryCredentialRouteRefusesDirectoryHost(t *testing.T) {
+ app := newTestApp(t)
+ router := app.Router()
+ routes := router.(chi.Routes)
+ count := 0
+ err := chi.Walk(routes, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
+ switch route {
+ case "/", "/healthz", "/static/tokens.css", "/static/theme.css":
+ return nil
+ }
+ count++
+ req := httptest.NewRequest(method, "https://"+directoryHost+route, nil)
+ req.Header.Set("Origin", Origin)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 {
+ t.Errorf("%s %s on directory: status=%d cookies=%d body=%q", method, route, rec.Code, len(rec.Result().Cookies()), rec.Body.String())
+ }
+ return nil
+ })
+ if err != nil || count < 19 {
+ t.Fatalf("route walk err=%v credential routes=%d", err, count)
+ }
+ if _, ok := app.mailer.last(); ok {
+ t.Fatal("directory request sent mail")
+ }
+}
+
+func TestDirectoryContentNeverServedOnCredentialHost(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodGet, Origin+"/?team=untrusted", nil)
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != directoryOrigin+"/" || rec.Body.Len() != 0 {
+ t.Fatalf("credential root: status=%d location=%q body=%q", rec.Code, rec.Header().Get("Location"), rec.Body.String())
+ }
+}
+
+func TestUnknownHostsCannotReachRoutes(t *testing.T) {
+ app := newTestApp(t)
+ router := app.Router()
+ hosts := []string{"", "example.com", "oficina.works.attacker.invalid", "id.oficina.works.attacker.invalid", "ID.OFICINA.WORKS", "id.oficina.works.", "id.oficina.works:443", "oficina.works:443", "localhost"}
+ err := chi.Walk(router.(chi.Routes), func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
+ for _, host := range hosts {
+ req := httptest.NewRequest(method, route, nil)
+ req.Host = host
+ req.Header.Set("Origin", Origin)
+ req.Header.Set("X-Forwarded-Host", credentialHost)
+ req.Header.Set("Forwarded", "host="+credentialHost)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 {
+ t.Errorf("%s %s host=%q status=%d", method, route, host, rec.Code)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDirectoryIsNotWithinCredentialRP(t *testing.T) {
+ if credentialHost == directoryHost || rpID() == directoryHost || Origin == directoryOrigin {
+ t.Fatal("directory overlaps the credential origin or relying party")
+ }
+}
diff --git a/internal/home/passkey_test.go b/internal/home/passkey_test.go
index fde7d45..6c71d40 100644
--- a/internal/home/passkey_test.go
+++ b/internal/home/passkey_test.go
@@ -40,6 +40,7 @@ func postJSON(t *testing.T, app *testApp, path string, cookie *http.Cookie, body
payload = "{}"
}
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", Origin)
if cookie != nil {
@@ -190,6 +191,7 @@ func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) {
form := url.Values{"code": {codes[0]}}
req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
for _, c := range (&http.Response{Header: gated.Header()}).Cookies() {
@@ -216,6 +218,7 @@ func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) {
func subjectOf(t *testing.T, app *testApp, cookie *http.Cookie) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Host = credentialHost
req.AddCookie(cookie)
sess, ok := app.sessions.From(req)
if !ok {
@@ -230,6 +233,7 @@ func TestScriptAssetsAreServedOnNamedPaths(t *testing.T) {
app := newTestApp(t)
for _, path := range []string{"/static/webauthn.mjs", "/static/passkey.js"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Host = credentialHost
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -253,6 +257,7 @@ func TestRecoveryCodesPageNeedsAFreshSession(t *testing.T) {
get := func() int {
req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
+ req.Host = credentialHost
req.AddCookie(cookie)
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -304,6 +309,7 @@ func TestRecoveryCodeProducesAFreshSession(t *testing.T) {
form := url.Values{"code": {codes[0]}}
req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
for _, c := range (&http.Response{Header: gated.Header()}).Cookies() {
@@ -319,6 +325,7 @@ func TestRecoveryCodeProducesAFreshSession(t *testing.T) {
// The RequireFresh-guarded page opens to it.
page := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
+ page.Host = credentialHost
page.AddCookie(recovered)
pw := httptest.NewRecorder()
app.Router().ServeHTTP(pw, page)
@@ -361,6 +368,7 @@ func TestEnrolmentNeedsAFreshSession(t *testing.T) {
// The page itself redirects to re-authenticate rather than 404ing or
// 403ing: a detour, not a dead end.
req := httptest.NewRequest(http.MethodGet, "/passkey/enrol", nil)
+ req.Host = credentialHost
req.AddCookie(cookie)
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -402,6 +410,7 @@ func TestEnrolThenReadCodesWithoutReauthenticating(t *testing.T) {
enrol(t, app, cookie)
req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
+ req.Host = credentialHost
req.AddCookie(cookie)
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
diff --git a/internal/home/signin.go b/internal/home/signin.go
index 4931bcf..973f3c7 100644
--- a/internal/home/signin.go
+++ b/internal/home/signin.go
@@ -15,7 +15,7 @@ import (
// the session cookie and is the WebAuthn RP. §4 splits it from the
// directory origin deliberately, and both the CSRF check and the cookie
// attributes are keyed on this exact string.
-const Origin = "https://id.oficina.works"
+const Origin = "https://" + credentialHost
// linkPath is where magic links land. It must match the route mounted
// below; signin's own default is /auth/verify, which this app does not
diff --git a/internal/home/signin_test.go b/internal/home/signin_test.go
index 3a0d078..e21409c 100644
--- a/internal/home/signin_test.go
+++ b/internal/home/signin_test.go
@@ -27,6 +27,7 @@ func signIn(t *testing.T, app *testApp, addr string) *httptest.ResponseRecorder
t.Helper()
form := url.Values{"address": {addr}}
req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
w := httptest.NewRecorder()
@@ -65,6 +66,7 @@ func redeem(t *testing.T, app *testApp, token string) *httptest.ResponseRecorder
t.Helper()
form := url.Values{"token": {token}}
req := httptest.NewRequest(http.MethodPost, linkPath, strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
w := httptest.NewRecorder()
@@ -164,6 +166,7 @@ func TestGetDoesNotRedeemTheLink(t *testing.T) {
token := capturedLink(t, app)
req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil)
+ req.Host = credentialHost
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -197,6 +200,7 @@ func TestVerifyPageDoesNotDistinguishTokens(t *testing.T) {
get := func(token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil)
+ req.Host = credentialHost
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
return w
@@ -331,6 +335,7 @@ func TestSigninPostRequiresOriginEvidence(t *testing.T) {
for _, path := range []string{"/signin", linkPath} {
form := url.Values{"address": {"person@example.invalid"}}
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -350,6 +355,7 @@ func TestSigninPostRequiresOriginEvidence(t *testing.T) {
func TestClientIPNeverCollapsesToOneBucket(t *testing.T) {
req := func(remote, xff string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/signin", nil)
+ r.Host = credentialHost
r.RemoteAddr = remote
if xff != "" {
r.Header.Set("X-Forwarded-For", xff)
@@ -396,6 +402,7 @@ func TestCallbackWithNoPendingCookieMintsNothing(t *testing.T) {
"",
} {
req := httptest.NewRequest(http.MethodGet, callbackPath+q, nil)
+ req.Host = credentialHost
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -431,6 +438,7 @@ func TestForgedPendingBlobIsRefused(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, callbackPath+"?code=x&state=s", nil)
+ req.Host = credentialHost
req.AddCookie(&http.Cookie{
Name: pendingCookie,
// Well-formed shape — payload.mac — with a MAC that is not one.
@@ -461,6 +469,7 @@ func TestSigninIsRateLimitedPerIPNotGlobally(t *testing.T) {
post := func(ip, addr string) int {
form := url.Values{"address": {addr}}
req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
req.Header.Set("X-Forwarded-For", ip)
@@ -499,6 +508,7 @@ func TestSigninIsRateLimitedPerAddress(t *testing.T) {
for i := range 12 {
form := url.Values{"address": {"target@example.invalid"}}
req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", i+1))
@@ -527,6 +537,7 @@ func TestVerifyPageWritesNothing(t *testing.T) {
before := dumpDatabase(t, app)
for _, tok := range []string{token, "not-a-real-token", ""} {
req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(tok), nil)
+ req.Host = credentialHost
app.Router().ServeHTTP(httptest.NewRecorder(), req)
}
if after := dumpDatabase(t, app); after != before {