| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "amadan.net/oficina/home/handoff/seal" |
| 16 | rauth "amadan.net/rastrillo/rastrillo/auth" |
| 17 | "amadan.net/rastrillo/rastrillo/sessions" |
| 18 | ) |
| 19 | |
| 20 | const navigationOrigin = "https://docs.example.com" |
| 21 | |
| 22 | type navigationMail struct{} |
| 23 | |
| 24 | func (navigationMail) Send(context.Context, string, string, string) error { return nil } |
| 25 | |
| 26 | func navigationFixture(t *testing.T) (*App, *localLinkNavigation) { |
| 27 | t.Helper() |
| 28 | app, err := NewForTestWith(t, Config{Origin: navigationOrigin, SampleDocuments: "0", Mailer: navigationMail{}}) |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | sess, err := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: navigationOrigin}) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | sealer, err := seal.New(bytes.Repeat([]byte{19}, 32), "oficina/docs") |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | n := newLocalLinkNavigation(sealer, sess, navigationOrigin) |
| 41 | if n == nil { |
| 42 | t.Fatal("navigation config refused") |
| 43 | } |
| 44 | return app, n |
| 45 | } |
| 46 | func navigationRequest(path string) *http.Request { |
| 47 | return httptest.NewRequest("GET", navigationOrigin+path, nil) |
| 48 | } |
| 49 | func navigationMarker(t *testing.T, n *localLinkNavigation) *http.Cookie { |
| 50 | t.Helper() |
| 51 | rec := httptest.NewRecorder() |
| 52 | n.Begin(rec, navigationRequest("/account/home")) |
| 53 | if rec.Code != 303 || rec.Header().Get("Location") != "/signin/local" { |
| 54 | t.Fatal("local navigation did not begin") |
| 55 | } |
| 56 | var marker *http.Cookie |
| 57 | for _, c := range rec.Result().Cookies() { |
| 58 | if c.Name == localLinkNavigationCookie && c.MaxAge > 0 { |
| 59 | marker = c |
| 60 | } |
| 61 | } |
| 62 | if marker == nil { |
| 63 | t.Fatal("navigation marker missing") |
| 64 | } |
| 65 | return marker |
| 66 | } |
| 67 | func navigationSession(t *testing.T, n *localLinkNavigation, s sessions.Session) *http.Cookie { |
| 68 | t.Helper() |
| 69 | rec := httptest.NewRecorder() |
| 70 | if err := n.sess.SignIn(rec, navigationRequest("/"), s); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | for _, c := range rec.Result().Cookies() { |
| 74 | if c.Name == n.sess.CookieName() { |
| 75 | return c |
| 76 | } |
| 77 | } |
| 78 | t.Fatal("session cookie missing") |
| 79 | return nil |
| 80 | } |
| 81 | func assertNavigation(t *testing.T, n *localLinkNavigation, r *http.Request, want string) { |
| 82 | t.Helper() |
| 83 | rec := httptest.NewRecorder() |
| 84 | n.Complete(rec, r) |
| 85 | if rec.Code != 303 || rec.Header().Get("Location") != want || rec.Header().Get("Cache-Control") != "no-store" { |
| 86 | t.Fatalf("navigation status=%d target=%q wanted%q", rec.Code, rec.Header().Get("Location"), want) |
| 87 | } |
| 88 | cleared := false |
| 89 | for _, c := range rec.Result().Cookies() { |
| 90 | if c.Name == localLinkNavigationCookie && c.MaxAge < 0 { |
| 91 | cleared = true |
| 92 | } |
| 93 | } |
| 94 | if !cleared { |
| 95 | t.Fatal("marker not cleared") |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestLocalNavigationBeginIsBoundedFixedAndClearsOldPointers(t *testing.T) { |
| 100 | _, n := navigationFixture(t) |
| 101 | now := time.Now().Truncate(time.Second) |
| 102 | n.now = func() time.Time { return now } |
| 103 | rec := httptest.NewRecorder() |
| 104 | n.Begin(rec, navigationRequest("/account/home")) |
| 105 | cleared := map[string]bool{} |
| 106 | for _, c := range rec.Result().Cookies() { |
| 107 | if !c.Secure || !c.HttpOnly || c.Domain != "" || c.Path != "/" || c.SameSite != http.SameSiteLaxMode { |
| 108 | t.Fatal("cookie policy weakened") |
| 109 | } |
| 110 | if c.MaxAge < 0 { |
| 111 | cleared[c.Name] = true |
| 112 | } |
| 113 | } |
| 114 | if !cleared["__Host-docs-home-init"] || !cleared["__Host-docs-home-proof"] { |
| 115 | t.Fatal("old link browser pointers retained") |
| 116 | } |
| 117 | marker := navigationMarker(t, n) |
| 118 | if marker.MaxAge != 600 || len(marker.Value) > 1024 { |
| 119 | t.Fatal("unbounded navigation") |
| 120 | } |
| 121 | encrypted, err := base64.RawURLEncoding.DecodeString(marker.Value) |
| 122 | if err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | plain, err := n.sealer.Open(n.purpose(), encrypted) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | want, _ := json.Marshal(localLinkMarker{1, "link-start", now.Unix(), now.Unix() + 600}) |
| 130 | if !bytes.Equal(plain, want) { |
| 131 | t.Fatal("navigation carries something beyond fixed action/time") |
| 132 | } |
| 133 | for _, path := range []string{"/account/home?return_to=%2Fsecret", "/account/home?next=https://evil.example", "/other"} { |
| 134 | rec = httptest.NewRecorder() |
| 135 | n.Begin(rec, navigationRequest(path)) |
| 136 | if rec.Code != 303 || rec.Header().Get("Location") != "/" { |
| 137 | t.Fatal("caller selected navigation") |
| 138 | } |
| 139 | for _, c := range rec.Result().Cookies() { |
| 140 | if c.MaxAge > 0 { |
| 141 | t.Fatal("bad path set marker") |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func TestLocalNavigationUsesActualFreshSessionMethod(t *testing.T) { |
| 148 | app, n := navigationFixture(t) |
| 149 | now := time.Now().Truncate(time.Second) |
| 150 | n.now = func() time.Time { return now } |
| 151 | for _, tc := range []struct { |
| 152 | name, method string |
| 153 | auth, created time.Time |
| 154 | want string |
| 155 | }{ |
| 156 | {"magic zero", "magiclink", time.Time{}, now, "/account/home"}, |
| 157 | {"magic old stored At", "magiclink", time.Time{}, now.Add(-301 * time.Second), "/"}, |
| 158 | {"magic future stored At", "magiclink", time.Time{}, now.Add(time.Second), "/"}, |
| 159 | {"magic actual boundary", "magiclink", now.Add(-300 * time.Second), now, "/account/home"}, |
| 160 | {"magic old original", "magiclink", now.Add(-301 * time.Second), now, "/"}, |
| 161 | {"magic future original", "magiclink", now.Add(time.Second), now, "/"}, |
| 162 | {"Home remint", "home-assertion", time.Time{}, now, "/"}, |
| 163 | {"Home fresh claim", "home-assertion", now, now, "/"}, |
| 164 | {"keymail zero", "keymail", time.Time{}, now, "/"}, |
| 165 | {"keymail fresh", "keymail", now.Add(-time.Minute), now, "/account/home"}, |
| 166 | {"keymail stale", "keymail", now.Add(-301 * time.Second), now, "/"}, |
| 167 | {"unknown", "password", now, now, "/"}, |
| 168 | } { |
| 169 | t.Run(tc.name, func(t *testing.T) { |
| 170 | cookie := navigationSession(t, n, sessions.Session{Subject: "local@example.com", Method: tc.method, AuthTime: tc.auth}) |
| 171 | if _, err := app.db.Writer().Exec(`UPDATE sessions SET created_at=? WHERE token_hash=?`, tc.created.UTC().Format(time.RFC3339), sessions.HashToken(cookie.Value)); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | r := navigationRequest(localLinkCompletePath) |
| 175 | r.AddCookie(navigationMarker(t, n)) |
| 176 | r.AddCookie(cookie) |
| 177 | assertNavigation(t, n, r, tc.want) |
| 178 | }) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestLocalNavigationRealEmailRotationAndChangedAccount(t *testing.T) { |
| 183 | app, n := navigationFixture(t) |
| 184 | // This plugin differs only in its configured successful local landing. |
| 185 | plugin, err := rauth.New(rauth.Config{DB: app.db.Writer(), Origin: navigationOrigin, InstanceKey: devInstanceKey, Mailer: app.mailRecorder, SignedInPath: localLinkCompletePath, ProofOff: true}) |
| 186 | if err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | first := app.TestRequest(t, "first@example.com", "GET", "/", nil) |
| 190 | old, err := first.Cookie(n.sess.CookieName()) |
| 191 | if err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | marker := navigationMarker(t, n) |
| 195 | // The newly verified account may differ: only the displayed initiation page |
| 196 | // is reached. No old account proof or local person is carried by the marker. |
| 197 | link, err := app.TestSignIn(t, "second@example.com") |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | verify := confirmRequest(link) |
| 202 | verify.AddCookie(old) |
| 203 | rec := httptest.NewRecorder() |
| 204 | plugin.Verify(rec, verify) |
| 205 | if rec.Code != 303 || rec.Header().Get("Location") != localLinkCompletePath { |
| 206 | t.Fatal("actual plugin did not use fixed landing") |
| 207 | } |
| 208 | if _, ok := n.sess.From(first); ok { |
| 209 | t.Fatal("old actual session survived rotation") |
| 210 | } |
| 211 | complete := navigationRequest(localLinkCompletePath) |
| 212 | complete.AddCookie(marker) |
| 213 | for _, c := range rec.Result().Cookies() { |
| 214 | complete.AddCookie(c) |
| 215 | } |
| 216 | actual, ok := n.sess.From(complete) |
| 217 | if !ok || actual.Subject != "second@example.com" || actual.Method != "magiclink" || !actual.AuthTime.IsZero() { |
| 218 | t.Fatal("real email verification identity wrong") |
| 219 | } |
| 220 | assertNavigation(t, n, complete, "/account/home") |
| 221 | // Ordinary local verification has no continuation marker and still lands /. |
| 222 | ordinary := navigationRequest(localLinkCompletePath) |
| 223 | for _, c := range rec.Result().Cookies() { |
| 224 | ordinary.AddCookie(c) |
| 225 | } |
| 226 | assertNavigation(t, n, ordinary, "/") |
| 227 | failed := httptest.NewRecorder() |
| 228 | plugin.Verify(failed, verify) |
| 229 | if failed.Header().Get("Location") == localLinkCompletePath { |
| 230 | t.Fatal("failed replayed verification reached bridge") |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | func TestLocalNavigationRejectsAmbiguousCookies(t *testing.T) { |
| 235 | _, n := navigationFixture(t) |
| 236 | marker := navigationMarker(t, n) |
| 237 | cookie := navigationSession(t, n, sessions.Session{Subject: "local@example.com", Method: "magiclink"}) |
| 238 | for _, cookies := range [][]*http.Cookie{nil, {marker}, {cookie}, {marker, marker, cookie}, {marker, cookie, cookie}, {marker, {Name: n.sess.CookieName(), Value: "unknown"}}, {marker, cookie, {Name: n.sess.CookieName(), Value: "unknown"}}} { |
| 239 | r := navigationRequest(localLinkCompletePath) |
| 240 | for _, c := range cookies { |
| 241 | r.AddCookie(c) |
| 242 | } |
| 243 | assertNavigation(t, n, r, "/") |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | func TestLocalNavigationRejectsInvalidMarkersAndOtherHosts(t *testing.T) { |
| 248 | _, n := navigationFixture(t) |
| 249 | now := time.Now().Truncate(time.Second) |
| 250 | n.now = func() time.Time { return now } |
| 251 | cookie := navigationSession(t, n, sessions.Session{Subject: "local@example.com", Method: "magiclink"}) |
| 252 | valid := navigationMarker(t, n).Value |
| 253 | plain := func(m localLinkMarker) string { b, _ := json.Marshal(m); return string(b) } |
| 254 | base := localLinkMarker{1, "link-start", now.Unix(), now.Unix() + 600} |
| 255 | badJSON := []string{ |
| 256 | "{}", `{"v":1,"v":1,"action":"link-start","issued":1,"expires":2}`, |
| 257 | plain(base) + " {}", strings.TrimSuffix(plain(base), "}") + `,"unknown":true}`, |
| 258 | } |
| 259 | for _, m := range []localLinkMarker{{2, "link-start", now.Unix(), now.Unix() + 600}, {1, "other", now.Unix(), now.Unix() + 600}, {1, "link-start", 0, now.Unix() + 600}, {1, "link-start", now.Unix() + 1, now.Unix() + 600}, {1, "link-start", now.Unix() - 600, now.Unix()}, {1, "link-start", now.Unix(), now.Unix() + 601}} { |
| 260 | badJSON = append(badJSON, plain(m)) |
| 261 | } |
| 262 | badValues := []string{"", strings.Repeat("a", 1025), valid + "=", "%" + valid, valid[:len(valid)-2]} |
| 263 | for _, p := range badJSON { |
| 264 | sealed, err := n.sealer.Seal(n.purpose(), []byte(p)) |
| 265 | if err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | badValues = append(badValues, base64.RawURLEncoding.EncodeToString(sealed)) |
| 269 | } |
| 270 | otherPurpose, err := n.sealer.Seal("docs.local-link-navigation.v1:https://other.example", []byte(plain(base))) |
| 271 | if err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | badValues = append(badValues, base64.RawURLEncoding.EncodeToString(otherPurpose)) |
| 275 | for _, value := range badValues { |
| 276 | r := navigationRequest(localLinkCompletePath) |
| 277 | r.AddCookie(cookie) |
| 278 | r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: value}) |
| 279 | assertNavigation(t, n, r, "/") |
| 280 | } |
| 281 | for _, change := range []func(*http.Request){func(r *http.Request) { r.Host = "other.example" }, func(r *http.Request) { r.URL.RawQuery = "return_to=https://evil.example" }, func(r *http.Request) { r.URL.Path = "/wrong" }} { |
| 282 | r := navigationRequest(localLinkCompletePath) |
| 283 | r.AddCookie(cookie) |
| 284 | r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid}) |
| 285 | change(r) |
| 286 | assertNavigation(t, n, r, "/") |
| 287 | } |
| 288 | // A valid marker expires independently of the browser cookie's MaxAge. |
| 289 | n.now = func() time.Time { return now.Add(600 * time.Second) } |
| 290 | r := navigationRequest(localLinkCompletePath) |
| 291 | r.AddCookie(cookie) |
| 292 | r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid}) |
| 293 | assertNavigation(t, n, r, "/") |
| 294 | } |
| 295 | |
| 296 | type navigationBrokenSealer struct{} |
| 297 | |
| 298 | func (navigationBrokenSealer) Seal(string, []byte) ([]byte, error) { |
| 299 | return nil, errors.New("private failure") |
| 300 | } |
| 301 | func (navigationBrokenSealer) Open(string, []byte) ([]byte, error) { |
| 302 | return nil, errors.New("private failure") |
| 303 | } |
| 304 | func TestLocalNavigationStorageFailureAndMethod(t *testing.T) { |
| 305 | _, n := navigationFixture(t) |
| 306 | n.sealer = navigationBrokenSealer{} |
| 307 | rec := httptest.NewRecorder() |
| 308 | n.Begin(rec, navigationRequest("/account/home")) |
| 309 | if rec.Code != 503 || strings.Contains(rec.Body.String(), "private") || rec.Header().Get("Location") != "" { |
| 310 | t.Fatal("storage failure published/leaked navigation") |
| 311 | } |
| 312 | for _, fn := range []http.HandlerFunc{n.Begin, n.Complete} { |
| 313 | rec = httptest.NewRecorder() |
| 314 | r := navigationRequest(localLinkCompletePath) |
| 315 | r.Method = "POST" |
| 316 | fn(rec, r) |
| 317 | if rec.Code != 405 || rec.Header().Get("Allow") != "GET" || rec.Header().Get("Cache-Control") != "no-store" { |
| 318 | t.Fatal("wrong method accepted") |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 | |