| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "net/url" |
| 7 | "os" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func TestConfiguredOriginsRequireAnIsolatedPair(t *testing.T) { |
| 13 | valid := Origins{"https://id.preview.example", "https://home.preview.example"} |
| 14 | for _, pair := range []Origins{{Origin, directoryOrigin}, valid, {"https://notid.oficina.works", "https://preview.oficina.works"}} { |
| 15 | if err := pair.validate(); err != nil { |
| 16 | t.Errorf("valid %+v: %v", pair, err) |
| 17 | } |
| 18 | } |
| 19 | for _, pair := range []Origins{ |
| 20 | {}, {Origin, valid.Directory}, {valid.Credential, directoryOrigin}, |
| 21 | {valid.Credential, valid.Credential}, {valid.Credential, "https://child.id.preview.example"}, |
| 22 | {"https://preview.id.oficina.works", valid.Directory}, {valid.Credential, "https://child.id.oficina.works"}, |
| 23 | } { |
| 24 | if err := pair.validate(); err == nil { |
| 25 | t.Errorf("accepted unsafe pair %+v", pair) |
| 26 | } |
| 27 | } |
| 28 | 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"} { |
| 29 | for _, pair := range []Origins{{bad, valid.Directory}, {valid.Credential, bad}} { |
| 30 | if err := pair.validate(); err == nil { |
| 31 | t.Errorf("accepted malformed pair %+v", pair) |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func TestLoadOriginsRequiresBothVariablesOrNeither(t *testing.T) { |
| 38 | for _, key := range []string{"HOME_CREDENTIAL_ORIGIN", "HOME_DIRECTORY_ORIGIN"} { |
| 39 | t.Setenv(key, "") |
| 40 | if err := os.Unsetenv(key); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | } |
| 44 | if got, err := LoadOrigins(); err != nil || got != (Origins{Origin, directoryOrigin}) { |
| 45 | t.Fatalf("defaults %+v %v", got, err) |
| 46 | } |
| 47 | t.Setenv("HOME_CREDENTIAL_ORIGIN", "https://id.preview.example") |
| 48 | if _, err := LoadOrigins(); err == nil { |
| 49 | t.Fatal("accepted credential-only setting") |
| 50 | } |
| 51 | t.Setenv("HOME_DIRECTORY_ORIGIN", "https://home.preview.example") |
| 52 | if got, err := LoadOrigins(); err != nil || got.Credential != "https://id.preview.example" { |
| 53 | t.Fatalf("pair %+v %v", got, err) |
| 54 | } |
| 55 | t.Setenv("HOME_CREDENTIAL_ORIGIN", "") |
| 56 | if _, err := LoadOrigins(); err == nil { |
| 57 | t.Fatal("accepted explicit empty setting") |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func TestCustomOriginsReachEveryCredentialSeam(t *testing.T) { |
| 62 | origins := Origins{"https://id.preview.example", "https://home.preview.example"} |
| 63 | app := newTestAppWithOrigins(t, origins) |
| 64 | 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" { |
| 65 | t.Fatal("credential origin did not reach signin/keymail/passkey configuration") |
| 66 | } |
| 67 | router := app.Router() |
| 68 | for _, c := range []struct { |
| 69 | origin, path string |
| 70 | status int |
| 71 | }{ |
| 72 | {origins.Credential, "/signin", 200}, {origins.Directory, "/", 200}, {origins.Credential, "/", 303}, |
| 73 | {origins.Directory, "/signin", 404}, {Origin, "/signin", 404}, {directoryOrigin, "/", 404}, |
| 74 | {strings.ToUpper(origins.Credential), "/signin", 200}, |
| 75 | } { |
| 76 | req := httptest.NewRequest(http.MethodGet, c.origin+c.path, nil) |
| 77 | rec := httptest.NewRecorder() |
| 78 | router.ServeHTTP(rec, req) |
| 79 | if rec.Code != c.status { |
| 80 | t.Errorf("%s%s=%d want%d", c.origin, c.path, rec.Code, c.status) |
| 81 | } |
| 82 | if c.status == 303 && (rec.Header().Get("Location") != origins.Directory+"/" || rec.Body.Len() != 0) { |
| 83 | t.Fatal("credential root escaped configured directory") |
| 84 | } |
| 85 | } |
| 86 | for _, origin := range []string{origins.Credential, origins.Directory} { |
| 87 | for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css"} { |
| 88 | rec := httptest.NewRecorder() |
| 89 | router.ServeHTTP(rec, httptest.NewRequest("GET", origin+path, nil)) |
| 90 | if rec.Code != 200 { |
| 91 | t.Errorf("shared %s%s=%d", origin, path, rec.Code) |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | post := func(path, source string, values url.Values) *httptest.ResponseRecorder { |
| 96 | req := httptest.NewRequest("POST", origins.Credential+path, strings.NewReader(values.Encode())) |
| 97 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 98 | req.Header.Set("Origin", source) |
| 99 | rec := httptest.NewRecorder() |
| 100 | router.ServeHTTP(rec, req) |
| 101 | return rec |
| 102 | } |
| 103 | if rec := post("/signin", Origin, url.Values{"address": {"person@example.invalid"}}); rec.Code != 403 { |
| 104 | t.Fatalf("production CSRF origin accepted: %d", rec.Code) |
| 105 | } |
| 106 | if _, ok := app.mailer.last(); ok { |
| 107 | t.Fatal("wrong CSRF origin sent mail") |
| 108 | } |
| 109 | if rec := post("/signin", origins.Credential, url.Values{"address": {"person@example.invalid"}}); rec.Code != 303 { |
| 110 | t.Fatalf("configured signin=%d", rec.Code) |
| 111 | } |
| 112 | sent, ok := app.mailer.last() |
| 113 | if !ok { |
| 114 | t.Fatal("no captured mail") |
| 115 | } |
| 116 | var token string |
| 117 | for _, word := range strings.Fields(sent.Body) { |
| 118 | if strings.HasPrefix(word, origins.Credential+linkPath) { |
| 119 | u, err := url.Parse(word) |
| 120 | if err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | token = u.Query().Get("token") |
| 124 | } |
| 125 | } |
| 126 | if token == "" { |
| 127 | t.Fatal("mail did not contain configured credential link") |
| 128 | } |
| 129 | rec := post(linkPath, origins.Credential, url.Values{"token": {token}}) |
| 130 | cookie := sessionCookie(rec) |
| 131 | if rec.Code != 303 || cookie == nil { |
| 132 | t.Fatalf("redemption=%d missing session=%v", rec.Code, cookie == nil) |
| 133 | } |
| 134 | for _, c := range rec.Result().Cookies() { |
| 135 | if c.Domain != "" || !c.Secure || c.Path != "/" { |
| 136 | t.Errorf("unsafe configured cookie %s", c.Name) |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |