oficina / home Public

Configure isolated paired Home origins

Paul Campbell pushed by paul@keymail.dev 651e5b3d3f95be0d788847f38272a5fda081b14c parent 5109a48
11 files changed, +280 −51
  • cmd/home/main.go +7 −1
  • internal/home/app.go +35 −21
  • internal/home/keymail_test.go +1 −1
  • internal/home/origins.go +81 −5
  • internal/home/origins_config_test.go +139 −0
  • internal/home/origins_test.go +3 −2
  • internal/home/passkey.go +2 −14
  • internal/home/passkey_test.go +1 −1
  • internal/home/rpid_test.go +2 −2
  • internal/home/signin.go +3 −3
  • internal/home/testapp_test.go +6 −1

CI passed — run details

Intent

Attributed to this commit: 12.7 g CO₂e (500546 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on feat/overnight-origin-split, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/cmd/home/main.go b/cmd/home/main.go
index 27df38a..355cb9e 100644
--- a/cmd/home/main.go
+++ b/cmd/home/main.go
@@ -95,7 +95,13 @@ func main() {
os.Exit(1)
}
- app, err := home.New(secrets, d, logger)
+ origins, err := home.LoadOrigins()
+ if err != nil {
+ logger.Error("config", "err", err)
+ os.Exit(1)
+ }
+
+ app, err := home.NewWithOrigins(secrets, d, logger, origins)
if err != nil {
logger.Error("wire app", "err", err)
os.Exit(1)
diff --git a/internal/home/app.go b/internal/home/app.go
index bec990a..e827061 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -19,6 +19,7 @@ import (
"errors"
"log/slog"
"net/http"
+ "strings"
"time"
"github.com/carlosframework/rastrillo/csrf"
@@ -35,14 +36,17 @@ import (
// is sealed on the way in, and the database handle because that is what
// it is sealed into. Nothing here reaches for a raw key.
type App struct {
- secrets Secrets
- db *db.DB
- links *linkStore
- logger *slog.Logger
- renderer *renderer
- flow *signin.Flow
- sessions *sessions.Sessions
- passkeys *passkey.Handlers
+ origins Origins
+ credentialHost string
+ directoryHost string
+ secrets Secrets
+ db *db.DB
+ links *linkStore
+ logger *slog.Logger
+ renderer *renderer
+ flow *signin.Flow
+ sessions *sessions.Sessions
+ passkeys *passkey.Handlers
// passkeyCfg is the config handed to passkey.New. Retained because
// passkey.Handlers keeps its own copy unexported, and the RP-ID
@@ -63,6 +67,13 @@ type App struct {
// boot path; this guards every other way an App can be constructed,
// which is every test and every future caller.
func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
+ return NewWithOrigins(secrets, d, logger, Origins{Origin, directoryOrigin})
+}
+
+func NewWithOrigins(secrets Secrets, d *db.DB, logger *slog.Logger, origins Origins) (*App, error) {
+ if err := origins.validate(); err != nil {
+ return nil, err
+ }
if logger == nil {
logger = slog.Default()
}
@@ -91,7 +102,7 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
// rastrillo.
sess, err := sessions.New(sessions.Config{
DB: d.Writer(),
- Origin: Origin,
+ Origin: origins.Credential,
TTL: 30 * 24 * time.Hour,
SigninPath: "/signin",
Logger: logger,
@@ -101,13 +112,16 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
}
a := &App{
- secrets: secrets,
- db: d,
- links: links,
- logger: logger,
- renderer: rend,
- flow: newFlow(secrets, links),
- sessions: sess,
+ origins: origins,
+ credentialHost: strings.TrimPrefix(origins.Credential, "https://"),
+ directoryHost: strings.TrimPrefix(origins.Directory, "https://"),
+ secrets: secrets,
+ db: d,
+ links: links,
+ logger: logger,
+ renderer: rend,
+ flow: newFlow(secrets, links, origins.Credential),
+ sessions: sess,
}
if a.passkeys, err = newPasskeys(a); err != nil {
@@ -130,11 +144,11 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
func (a *App) Router() http.Handler {
r := chi.NewRouter()
- r.Use(serveKnownHost)
+ r.Use(a.serveKnownHost)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
- if r.Host == credentialHost {
- w.Header().Set("Location", directoryOrigin+"/")
+ if strings.ToLower(r.Host) == a.credentialHost {
+ w.Header().Set("Location", a.origins.Directory+"/")
w.WriteHeader(http.StatusSeeOther)
return
}
@@ -152,13 +166,13 @@ func (a *App) Router() http.Handler {
})
r.Group(func(r chi.Router) {
- r.Use(credentialOnly)
+ r.Use(a.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.Use(csrf.Protect(a.origins.Credential))
r.Get("/signin", a.handleSigninForm)
r.Post("/signin", a.handleSigninBegin)
r.Get("/signin/sent", a.handleSigninSent)
diff --git a/internal/home/keymail_test.go b/internal/home/keymail_test.go
index 52f0f62..888de15 100644
--- a/internal/home/keymail_test.go
+++ b/internal/home/keymail_test.go
@@ -174,7 +174,7 @@ func TestCallbackWithWrongStateMintsNothing(t *testing.T) {
// submitter picked.
func TestFlowUsesBoundedHTTPClients(t *testing.T) {
app := newTestApp(t)
- fresh := newFlow(app.secrets, app.links)
+ fresh := newFlow(app.secrets, app.links, app.origins.Credential)
if fresh.Classifier.HTTP == nil {
t.Error("the classifier has no HTTP client, so it uses an unbounded default")
diff --git a/internal/home/origins.go b/internal/home/origins.go
index 553ed1a..fb6829f 100644
--- a/internal/home/origins.go
+++ b/internal/home/origins.go
@@ -1,6 +1,13 @@
package home
-import "net/http"
+import (
+ "errors"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+)
const (
suiteDomain = "oficina.works"
@@ -9,9 +16,78 @@ const (
directoryOrigin = "https://" + directoryHost
)
-func serveKnownHost(next http.Handler) http.Handler {
+var errOrigins = errors.New("home: invalid origin configuration")
+
+type Origins struct {
+ Credential string
+ Directory string
+}
+
+func LoadOrigins() (Origins, error) {
+ credential, haveCredential := os.LookupEnv("HOME_CREDENTIAL_ORIGIN")
+ directory, haveDirectory := os.LookupEnv("HOME_DIRECTORY_ORIGIN")
+ if !haveCredential && !haveDirectory {
+ return Origins{Origin, directoryOrigin}, nil
+ }
+ origins := Origins{credential, directory}
+ if !haveCredential || !haveDirectory {
+ return Origins{}, errOrigins
+ }
+ return origins, origins.validate()
+}
+
+func originHost(origin string) (string, error) {
+ u, err := url.Parse(origin)
+ if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || origin != "https://"+u.Host || u.Host != strings.ToLower(u.Host) || net.ParseIP(u.Host) != nil {
+ return "", errOrigins
+ }
+ if len(u.Host) > 253 {
+ return "", errOrigins
+ }
+ labels := strings.Split(u.Host, ".")
+ if len(labels) < 2 {
+ return "", errOrigins
+ }
+ for _, label := range labels {
+ if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
+ return "", errOrigins
+ }
+ for _, c := range label {
+ if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
+ return "", errOrigins
+ }
+ }
+ }
+ return u.Host, nil
+}
+
+func (o Origins) validate() error {
+ credential, err := originHost(o.Credential)
+ if err != nil {
+ return err
+ }
+ directory, err := originHost(o.Directory)
+ if err != nil {
+ return err
+ }
+ if directory == credential || strings.HasSuffix(directory, "."+credential) {
+ return errOrigins
+ }
+ if o.Credential == Origin && o.Directory == directoryOrigin {
+ return nil
+ }
+ for _, host := range []string{credential, directory} {
+ if host == directoryHost || host == credentialHost || strings.HasSuffix(host, "."+credentialHost) {
+ return errOrigins
+ }
+ }
+ return nil
+}
+
+func (a *App) serveKnownHost(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Host != credentialHost && r.Host != directoryHost {
+ host := strings.ToLower(r.Host)
+ if host != a.credentialHost && host != a.directoryHost {
w.WriteHeader(http.StatusNotFound)
return
}
@@ -19,9 +95,9 @@ func serveKnownHost(next http.Handler) http.Handler {
})
}
-func credentialOnly(next http.Handler) http.Handler {
+func (a *App) credentialOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Host != credentialHost {
+ if strings.ToLower(r.Host) != a.credentialHost {
w.WriteHeader(http.StatusNotFound)
return
}
diff --git a/internal/home/origins_config_test.go b/internal/home/origins_config_test.go
new file mode 100644
index 0000000..9859282
--- /dev/null
+++ b/internal/home/origins_config_test.go
@@ -0,0 +1,139 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestConfiguredOriginsRequireAnIsolatedPair(t *testing.T) {
+ valid := Origins{"https://id.preview.example", "https://home.preview.example"}
+ for _, pair := range []Origins{{Origin, directoryOrigin}, valid, {"https://notid.oficina.works", "https://preview.oficina.works"}} {
+ if err := pair.validate(); err != nil {
+ t.Errorf("valid %+v: %v", pair, err)
+ }
+ }
+ for _, pair := range []Origins{
+ {}, {Origin, valid.Directory}, {valid.Credential, directoryOrigin},
+ {valid.Credential, valid.Credential}, {valid.Credential, "https://child.id.preview.example"},
+ {"https://preview.id.oficina.works", valid.Directory}, {valid.Credential, "https://child.id.oficina.works"},
+ } {
+ if err := pair.validate(); err == nil {
+ t.Errorf("accepted unsafe pair %+v", pair)
+ }
+ }
+ for _, bad := range []string{"", "http://id.preview.example", "https://ID.preview.example", "https://id.preview.example/", "https://id.preview.example:443", "https://user@id.preview.example", "https://id.preview.example?x", "https://id.preview.example#x", "https://127.0.0.1", "https://[::1]", "https://localhost", "https://id.preview.example.", "https://-id.example", "https://id_.example"} {
+ for _, pair := range []Origins{{bad, valid.Directory}, {valid.Credential, bad}} {
+ if err := pair.validate(); err == nil {
+ t.Errorf("accepted malformed pair %+v", pair)
+ }
+ }
+ }
+}
+
+func TestLoadOriginsRequiresBothVariablesOrNeither(t *testing.T) {
+ for _, key := range []string{"HOME_CREDENTIAL_ORIGIN", "HOME_DIRECTORY_ORIGIN"} {
+ t.Setenv(key, "")
+ if err := os.Unsetenv(key); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got, err := LoadOrigins(); err != nil || got != (Origins{Origin, directoryOrigin}) {
+ t.Fatalf("defaults %+v %v", got, err)
+ }
+ t.Setenv("HOME_CREDENTIAL_ORIGIN", "https://id.preview.example")
+ if _, err := LoadOrigins(); err == nil {
+ t.Fatal("accepted credential-only setting")
+ }
+ t.Setenv("HOME_DIRECTORY_ORIGIN", "https://home.preview.example")
+ if got, err := LoadOrigins(); err != nil || got.Credential != "https://id.preview.example" {
+ t.Fatalf("pair %+v %v", got, err)
+ }
+ t.Setenv("HOME_CREDENTIAL_ORIGIN", "")
+ if _, err := LoadOrigins(); err == nil {
+ t.Fatal("accepted explicit empty setting")
+ }
+}
+
+func TestCustomOriginsReachEveryCredentialSeam(t *testing.T) {
+ origins := Origins{"https://id.preview.example", "https://home.preview.example"}
+ app := newTestAppWithOrigins(t, origins)
+ if app.flow.Origin != origins.Credential || app.flow.Keymail("keymail.example").Origin != origins.Credential || app.passkeyCfg.Origin != origins.Credential || app.passkeyCfg.LegacyRPID != "" || app.passkeyPageData("subject", nil)["RPID"] != "id.preview.example" {
+ t.Fatal("credential origin did not reach signin/keymail/passkey configuration")
+ }
+ router := app.Router()
+ for _, c := range []struct {
+ origin, path string
+ status int
+ }{
+ {origins.Credential, "/signin", 200}, {origins.Directory, "/", 200}, {origins.Credential, "/", 303},
+ {origins.Directory, "/signin", 404}, {Origin, "/signin", 404}, {directoryOrigin, "/", 404},
+ {strings.ToUpper(origins.Credential), "/signin", 200},
+ } {
+ req := httptest.NewRequest(http.MethodGet, c.origin+c.path, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != c.status {
+ t.Errorf("%s%s=%d want%d", c.origin, c.path, rec.Code, c.status)
+ }
+ if c.status == 303 && (rec.Header().Get("Location") != origins.Directory+"/" || rec.Body.Len() != 0) {
+ t.Fatal("credential root escaped configured directory")
+ }
+ }
+ for _, origin := range []string{origins.Credential, origins.Directory} {
+ for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css"} {
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest("GET", origin+path, nil))
+ if rec.Code != 200 {
+ t.Errorf("shared %s%s=%d", origin, path, rec.Code)
+ }
+ }
+ }
+ post := func(path, source string, values url.Values) *httptest.ResponseRecorder {
+ req := httptest.NewRequest("POST", origins.Credential+path, strings.NewReader(values.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", source)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+ }
+ if rec := post("/signin", Origin, url.Values{"address": {"person@example.invalid"}}); rec.Code != 403 {
+ t.Fatalf("production CSRF origin accepted: %d", rec.Code)
+ }
+ if _, ok := app.mailer.last(); ok {
+ t.Fatal("wrong CSRF origin sent mail")
+ }
+ if rec := post("/signin", origins.Credential, url.Values{"address": {"person@example.invalid"}}); rec.Code != 303 {
+ t.Fatalf("configured signin=%d", rec.Code)
+ }
+ sent, ok := app.mailer.last()
+ if !ok {
+ t.Fatal("no captured mail")
+ }
+ var token string
+ for _, word := range strings.Fields(sent.Body) {
+ if strings.HasPrefix(word, origins.Credential+linkPath) {
+ u, err := url.Parse(word)
+ if err != nil {
+ t.Fatal(err)
+ }
+ token = u.Query().Get("token")
+ }
+ }
+ if token == "" {
+ t.Fatal("mail did not contain configured credential link")
+ }
+ rec := post(linkPath, origins.Credential, url.Values{"token": {token}})
+ cookie := sessionCookie(rec)
+ if rec.Code != 303 || cookie == nil {
+ t.Fatalf("redemption=%d missing session=%v", rec.Code, cookie == nil)
+ }
+ for _, c := range rec.Result().Cookies() {
+ if c.Domain != "" || !c.Secure || c.Path != "/" {
+ t.Errorf("unsafe configured cookie %s", c.Name)
+ }
+ }
+}
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go
index 6438875..51b8fa6 100644
--- a/internal/home/origins_test.go
+++ b/internal/home/origins_test.go
@@ -49,7 +49,7 @@ func TestDirectoryContentNeverServedOnCredentialHost(t *testing.T) {
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"}
+ hosts := []string{"", "example.com", "oficina.works.attacker.invalid", "id.oficina.works.attacker.invalid", "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)
@@ -71,7 +71,8 @@ func TestUnknownHostsCannotReachRoutes(t *testing.T) {
}
func TestDirectoryIsNotWithinCredentialRP(t *testing.T) {
- if credentialHost == directoryHost || rpID() == directoryHost || Origin == directoryOrigin {
+ app := newTestApp(t)
+ if credentialHost == directoryHost || app.credentialHost == directoryHost || Origin == directoryOrigin {
t.Fatal("directory overlaps the credential origin or relying party")
}
}
diff --git a/internal/home/passkey.go b/internal/home/passkey.go
index b713368..a82da68 100644
--- a/internal/home/passkey.go
+++ b/internal/home/passkey.go
@@ -4,7 +4,6 @@ import (
"embed"
"encoding/base64"
"net/http"
- "net/url"
"time"
"github.com/carlosframework/rastrillo/passkey"
@@ -97,7 +96,7 @@ func passkeyConfig(a *App) passkey.Config {
return passkey.Config{
Sessions: a.sessions,
DB: a.db.Writer(),
- Origin: Origin,
+ Origin: a.origins.Credential,
ConfirmPath: confirmPath,
Logger: a.logger,
}
@@ -119,7 +118,7 @@ func newPasskeys(a *App) (*passkey.Handlers, error) {
// around.
func (a *App) passkeyPageData(subject string, extra map[string]any) map[string]any {
data := map[string]any{
- "RPID": rpID(),
+ "RPID": a.credentialHost,
"LegacyRPID": a.passkeyCfg.LegacyRPID,
"UserID": base64.RawURLEncoding.EncodeToString([]byte(subject)),
"UserName": subject,
@@ -218,14 +217,3 @@ func staticAsset(name string) []byte {
}
func webauthnJS() []byte { return webauthn.JS() }
-
-// rpID is the WebAuthn relying-party ID: the credential origin's
-// hostname, derived from the one Origin constant rather than written out
-// again, so the two cannot drift.
-func rpID() string {
- u, err := url.Parse(Origin)
- if err != nil {
- panic("home: Origin is not a URL: " + err.Error())
- }
- return u.Hostname()
-}
diff --git a/internal/home/passkey_test.go b/internal/home/passkey_test.go
index 6c71d40..d1b1aed 100644
--- a/internal/home/passkey_test.go
+++ b/internal/home/passkey_test.go
@@ -78,7 +78,7 @@ func enrol(t *testing.T, app *testApp, cookie *http.Cookie) *authtest.Authentica
t.Fatal(err)
}
challenge := challengeFrom(t, postJSON(t, app, "/passkey/register/begin", cookie, nil))
- clientData, attestation := a.Create(challenge, authtest.Options{RPID: rpID(), Origin: Origin})
+ clientData, attestation := a.Create(challenge, authtest.Options{RPID: app.credentialHost, Origin: Origin})
rec := postJSON(t, app, "/passkey/register/finish", cookie, map[string]string{
"clientDataJSON": base64.RawURLEncoding.EncodeToString(clientData),
"attestationObject": base64.RawURLEncoding.EncodeToString(attestation),
diff --git a/internal/home/rpid_test.go b/internal/home/rpid_test.go
index 003fe2d..ebc1105 100644
--- a/internal/home/rpid_test.go
+++ b/internal/home/rpid_test.go
@@ -60,8 +60,8 @@ func TestRPIDIsExactlyTheCredentialHost(t *testing.T) {
// the ceremony and the server disagree about who the relying party
// is and every assertion fails — or worse, succeeds under a wider
// name.
- if rpID() != u.Hostname() {
- t.Errorf("the pages advertise RP ID %q but the server derives %q", rpID(), u.Hostname())
+ if app.credentialHost != u.Hostname() {
+ t.Errorf("the pages advertise RP ID %q but the server derives %q", app.credentialHost, u.Hostname())
}
}
diff --git a/internal/home/signin.go b/internal/home/signin.go
index 973f3c7..fcae68f 100644
--- a/internal/home/signin.go
+++ b/internal/home/signin.go
@@ -77,14 +77,14 @@ const classifierTimeout = 5 * time.Second
// newFlow wires signin.Flow. Exactly one per process: the rate limiter's
// state lives on the value, so one per request would hand every caller a
// fresh empty budget and rate-limit nothing, silently.
-func newFlow(secrets Secrets, links signin.LinkStore) *signin.Flow {
+func newFlow(secrets Secrets, links signin.LinkStore, origin string) *signin.Flow {
http := &http.Client{Timeout: classifierTimeout}
return &signin.Flow{
Classifier: &signin.Classifier{HTTP: http, Scheme: "https"},
Keymail: func(server string) *signin.Keymail {
return &signin.Keymail{
Base: "https://" + server,
- Origin: Origin,
+ Origin: origin,
RedirectPath: callbackPath,
HTTP: http,
}
@@ -92,7 +92,7 @@ func newFlow(secrets Secrets, links signin.LinkStore) *signin.Flow {
Links: links,
Mailer: secrets.Mailer,
Key: secrets.SigninKey,
- Origin: Origin,
+ Origin: origin,
LinkPath: linkPath,
Subject: "Sign in to Oficina",
}
diff --git a/internal/home/testapp_test.go b/internal/home/testapp_test.go
index 89b0cf1..a36a848 100644
--- a/internal/home/testapp_test.go
+++ b/internal/home/testapp_test.go
@@ -97,6 +97,11 @@ type testApp struct {
// long-lived connection, and a hook registered after that misses the
// only connection that ever deletes anything.
func newTestApp(t *testing.T) *testApp {
+ t.Helper()
+ return newTestAppWithOrigins(t, Origins{Origin, directoryOrigin})
+}
+
+func newTestAppWithOrigins(t *testing.T, origins Origins) *testApp {
t.Helper()
secrets, mailer := testSecrets(t)
@@ -113,7 +118,7 @@ func newTestApp(t *testing.T) *testApp {
t.Fatalf("migrate.Apply: %v", err)
}
- app, err := New(secrets, d, quietLogger())
+ app, err := NewWithOrigins(secrets, d, quietLogger(), origins)
if err != nil {
t.Fatalf("New: %v", err)
}