oficina / home Public

Cover shared origins and scope CSRF to credential handlers

Paul Campbell pushed by paul@keymail.dev 5109a489fa95ea9aa1a6003f5f969a51bb74524e parent ca1b14d
2 files changed, +30 −7
  • internal/home/app.go +6 −7
  • internal/home/origins_test.go +24 −0

CI passed — run details

Intent

Human

Independently review a bounded follow-on design to Home origin split. Read-only. Previous Fable review approved origin split plan with required real canary evidence. Production routing fixed hosts means cannot review via isolated canary hostname. Parent specifically asks configurable named canary origins without trusting forwarded headers. Proposed: immutable Origins config containing validated credential and directory HTTPS origins. NewWithOrigins constructor validates config; existing New defaults to production id.oficina.works and oficina.works. cmd loads HOME_CREDENTIAL_ORIGIN/HOME_DIRECTORY_ORIGIN together or neither. Strict URL must be exactly https://DNS-host, no port, path, query, fragment, userinfo, IP literal; canonical lowercase no trailing dot. Hosts must differ and directory may not equal or descend from credential host, preventing credential RP accepting directory. Production pair remains default; custom pair must not mix production hosts with custom ones. Routing exact Host only, no forwarded-header inference. Config propagates to sessions.New Origin, signin.Flow Origin, Keymail callback Origin, passkey.Config Origin and template RPID, csrf.Protect per credential group, canonical redirect destination. Existing production RPID/cookie tests retained; custom origin integration tests exercise sign-in links, Host admission, CSRF and passkey RPID. Canary infrastructure uses separate database and separately generated config keys, never a second RP over production auth state. No env values or keys in logs. No new UI strings; existing internal error strings reused if necessary; production defaults unchanged. Review security and feasibility; identify blocking gaps, give APPROVE/REQUEST CHANGES with concise specific conditions. State exact model.

diff --git a/internal/home/app.go b/internal/home/app.go
index ef62fdd..bec990a 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -130,14 +130,7 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
func (a *App) Router() http.Handler {
r := chi.NewRouter()
- // Router-wide, not just on the verify POST. POST /signin needs it
- // too: it is an unauthenticated endpoint that sends mail and makes
- // outbound DNS and HTTPS calls decided by the submitted address — a
- // 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("/", func(w http.ResponseWriter, r *http.Request) {
if r.Host == credentialHost {
@@ -160,6 +153,12 @@ func (a *App) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(credentialOnly)
+ // POST /signin is an unauthenticated endpoint that sends mail and makes
+ // outbound DNS and HTTPS calls decided by the submitted address — a
+ // 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(csrf.Protect(Origin))
r.Get("/signin", a.handleSigninForm)
r.Post("/signin", a.handleSigninBegin)
r.Get("/signin/sent", a.handleSigninSent)
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go
index 6392707..6438875 100644
--- a/internal/home/origins_test.go
+++ b/internal/home/origins_test.go
@@ -75,3 +75,27 @@ func TestDirectoryIsNotWithinCredentialRP(t *testing.T) {
t.Fatal("directory overlaps the credential origin or relying party")
}
}
+
+func TestSharedRoutesOnBothOrigins(t *testing.T) {
+ app := newTestApp(t)
+ for _, host := range []string{credentialHost, directoryHost} {
+ for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css"} {
+ req := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil)
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
+ t.Errorf("host=%s path=%s status=%d cookies=%d", host, path, rec.Code, len(rec.Result().Cookies()))
+ }
+ }
+ }
+}
+
+func TestDirectoryCredentialPostRefusedBeforeCSRF(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodPost, directoryOrigin+"/signin", nil)
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 {
+ t.Fatalf("directory credential POST status=%d body=%q", rec.Code, rec.Body.String())
+ }
+}