oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
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
20const navigationOrigin = "https://docs.example.com"
21
22type navigationMail struct{}
23
24func (navigationMail) Send(context.Context, string, string, string) error { return nil }
25
26func 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}
46func navigationRequest(path string) *http.Request {
47 return httptest.NewRequest("GET", navigationOrigin+path, nil)
48}
49func 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}
67func 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}
81func 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
99func 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
147func 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
182func 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
234func 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
247func 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
296type navigationBrokenSealer struct{}
297
298func (navigationBrokenSealer) Seal(string, []byte) ([]byte, error) {
299 return nil, errors.New("private failure")
300}
301func (navigationBrokenSealer) Open(string, []byte) ([]byte, error) {
302 return nil, errors.New("private failure")
303}
304func 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