oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12
13 "amadan.net/oficina/home/internal/home/directorydata"
14 "amadan.net/oficina/home/roster"
15)
16
17type rosterSpy struct {
18 calls int
19 req directorydata.RosterRequest
20 page roster.Page
21 status int
22 err error
23}
24
25func (s *rosterSpy) Read(ctx context.Context, r directorydata.RosterRequest, now time.Time) (roster.Page, int, error) {
26 s.calls++
27 s.req = r
28 if d, ok := ctx.Deadline(); !ok || time.Until(d) > 5*time.Second {
29 panic("missing bounded context")
30 }
31 return s.page, s.status, s.err
32}
33func rosterHandlerFixture(t *testing.T) (*rosterHandler, *rosterSpy) {
34 t.Helper()
35 p := newExchangePair(t)
36 cfg := *p.directory.runtime
37 k, _ := rosterPrivate(t)
38 cfg.roster = rosterRuntime{mode: "serve", signer: k, keyID: rosterKeyID(k)}
39 s := &rosterSpy{status: 503, err: directorydata.ErrUnavailable}
40 h, e := newRosterHandler(cfg, s)
41 if e != nil {
42 t.Fatal(e)
43 }
44 return h, s
45}
46
47const rosterTestChallenge = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
48
49func rosterRequest() *http.Request {
50 r := httptest.NewRequest("GET", "/roster?team=A1&fetch="+rosterTestChallenge, nil)
51 r.Host = strings.TrimPrefix(directoryOrigin, "https://")
52 r.Header.Set("Authorization", "Bearer "+rosterTestChallenge)
53 return r
54}
55func rosterHeaders(t *testing.T, r *httptest.ResponseRecorder) {
56 t.Helper()
57 if r.Header().Get("Cache-Control") != "no-store, no-transform" || r.Header().Get("Content-Type") != "application/json" || r.Header().Get("X-Content-Type-Options") != "nosniff" {
58 t.Fatal(r.Header())
59 }
60 for _, key := range []string{"Location", "Content-Encoding", "Set-Cookie", "ETag", "Last-Modified", "Access-Control-Allow-Origin"} {
61 if r.Header().Get(key) != "" {
62 t.Fatal("unexpected header", key)
63 }
64 }
65}
66func TestRosterHTTPGuardOrderAndNoPreauthSource(t *testing.T) {
67 h, s := rosterHandlerFixture(t)
68 cases := []struct {
69 name string
70 change func(*http.Request)
71 want int
72 }{
73 {"foreign host", func(r *http.Request) { r.Host = "evil.example" }, 404}, {"host case", func(r *http.Request) { r.Host = strings.ToUpper(r.Host) }, 404}, {"host port", func(r *http.Request) { r.Host += ":443" }, 404}, {"host trailing dot", func(r *http.Request) { r.Host += "." }, 404},
74 {"post", func(r *http.Request) { r.Method = "POST" }, 405}, {"head", func(r *http.Request) { r.Method = "HEAD" }, 405}, {"options", func(r *http.Request) { r.Method = "OPTIONS" }, 405},
75 {"missing token", func(r *http.Request) { r.Header.Del("Authorization") }, 401}, {"duplicate token", func(r *http.Request) { r.Header.Add("Authorization", r.Header.Get("Authorization")) }, 401}, {"case duplicate", func(r *http.Request) { r.Header["authorization"] = []string{r.Header.Get("Authorization")} }, 401}, {"comma", func(r *http.Request) {
76 r.Header.Set("Authorization", r.Header.Get("Authorization")+","+r.Header.Get("Authorization"))
77 }, 401}, {"token space", func(r *http.Request) { r.Header.Set("Authorization", r.Header.Get("Authorization")+" ") }, 401}, {"token tab", func(r *http.Request) { r.Header.Set("Authorization", "Bearer\t"+rosterTestChallenge) }, 401}, {"scheme", func(r *http.Request) { r.Header.Set("Authorization", "bearer "+rosterTestChallenge) }, 401}, {"padding", func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+rosterTestChallenge+"=") }, 401},
78 {"no fetch", func(r *http.Request) { r.URL.RawQuery = "team=A1" }, 400}, {"duplicate fetch", func(r *http.Request) { r.URL.RawQuery += "&fetch=" + rosterTestChallenge }, 400}, {"unknown", func(r *http.Request) { r.URL.RawQuery += "&epoch=1" }, 400}, {"email challenge", func(r *http.Request) { r.URL.RawQuery = "team=A1&fetch=secret@example.invalid" }, 400}, {"team path", func(r *http.Request) { r.URL.RawQuery = "team=../B2&fetch=" + rosterTestChallenge }, 400}, {"escaped key", func(r *http.Request) { r.URL.RawQuery = "%74eam=A1&fetch=" + rosterTestChallenge }, 400}, {"escaped value", func(r *http.Request) { r.URL.RawQuery = "team=%41&fetch=" + rosterTestChallenge }, 400}, {"extra separator", func(r *http.Request) { r.URL.RawQuery += "&" }, 400}, {"empty cursor", func(r *http.Request) { r.URL.RawQuery += "&cursor=" }, 400}, {"big cursor", func(r *http.Request) { r.URL.RawQuery += "&cursor=" + strings.Repeat("A", 2049) }, 400}, {"big target", func(r *http.Request) { r.RequestURI = strings.Repeat("A", 4097) }, 400}, {"body", func(r *http.Request) { r.ContentLength = 1 }, 400}, {"chunked", func(r *http.Request) { r.TransferEncoding = []string{"chunked"} }, 400}, {"absolute target", func(r *http.Request) { r.URL.Scheme = "https"; r.URL.Host = r.Host }, 400}, {"escaped path", func(r *http.Request) { r.URL.RawPath = "/r%6fster" }, 400},
79 }
80 for _, tc := range cases {
81 t.Run(tc.name, func(t *testing.T) {
82 req := rosterRequest()
83 tc.change(req)
84 rec := httptest.NewRecorder()
85 h.ServeHTTP(rec, req)
86 if rec.Code != tc.want || s.calls != 0 {
87 t.Fatal(rec.Code, s.calls)
88 }
89 rosterHeaders(t, rec)
90 for _, value := range []string{rosterTestChallenge, "secret@example.invalid", "evil.example"} {
91 if strings.Contains(rec.Body.String(), value) {
92 t.Fatal("error input disclosure")
93 }
94 }
95 if tc.want == 405 && rec.Header().Get("Allow") != "GET" {
96 t.Fatal("missing Allow")
97 }
98 })
99 }
100 req := rosterRequest()
101 req.Header["Authorization"] = []string{"Bearer " + rosterTestChallenge}
102 req.Header.Set("Cookie", "session=anything")
103 rec := httptest.NewRecorder()
104 h.ServeHTTP(rec, req)
105 if rec.Code != 503 || s.calls != 1 || s.req.Team != "A1" || s.req.Fetch != rosterTestChallenge {
106 t.Fatal(rec.Code, s.calls, s.req)
107 }
108}
109func TestRosterHTTPDisabledIgnoresTokenQueryAndSessions(t *testing.T) {
110 p := newExchangePair(t)
111 h := p.directory.Router()
112 var body []byte
113 for _, query := range []string{"?team=A1&fetch=" + rosterTestChallenge, "?token=secret@example.invalid", "?" + strings.Repeat("x", 5000)} {
114 req := rosterRequest()
115 req.URL.RawQuery = strings.TrimPrefix(query, "?")
116 req.Header.Set("Authorization", "canary-token")
117 req.Header.Set("Cookie", p.directory.sessions.CookieName()+"=hostile; "+p.directory.sessions.CookieName()+"=duplicate")
118 rec := httptest.NewRecorder()
119 h.ServeHTTP(rec, req)
120 if rec.Code != 503 {
121 t.Fatal(rec.Code)
122 }
123 rosterHeaders(t, rec)
124 if body != nil && !bytes.Equal(body, rec.Body.Bytes()) {
125 t.Fatal("disabled details differed")
126 }
127 body = append([]byte{}, rec.Body.Bytes()...)
128 }
129 var n int
130 if e := p.directory.db.Writer().QueryRow(`SELECT count(*) FROM sessions`).Scan(&n); e != nil || n != 0 {
131 t.Fatal("roster touched sessions", n, e)
132 }
133}
134func TestRosterHTTPResponseFailuresAreBufferedAndFixed(t *testing.T) {
135 h, s := rosterHandlerFixture(t)
136 for _, tc := range []struct {
137 status int
138 err error
139 typ string
140 }{{401, directorydata.ErrRosterUnauthorized, ""}, {404, directorydata.ErrRosterNotFound, ""}, {400, directorydata.ErrRosterBadRequest, ""}, {503, directorydata.ErrUnavailable, ""}, {200, errors.New("secret@example.invalid"), "page"}, {404, nil, "page"}, {304, nil, "page"}, {200, nil, "wrong"}} {
141 s.status = tc.status
142 s.err = tc.err
143 s.page = roster.Page{Type: tc.typ}
144 rec := httptest.NewRecorder()
145 h.ServeHTTP(rec, rosterRequest())
146 want := tc.status
147 if tc.err == nil || tc.status == 200 {
148 want = 503
149 }
150 if rec.Code != want {
151 t.Fatal(rec.Code, want)
152 }
153 rosterHeaders(t, rec)
154 if strings.Contains(rec.Body.String(), "secret@example.invalid") {
155 t.Fatal("error leak")
156 }
157 }
158 huge := strings.Repeat("x", roster.BodyLimit)
159 s.page = roster.Page{Type: "page", Cursor: &huge}
160 s.status = 200
161 s.err = nil
162 rec := httptest.NewRecorder()
163 h.ServeHTTP(rec, rosterRequest())
164 if rec.Code != 503 || rec.Body.String() != `{"error":"unavailable"}` {
165 t.Fatal("partial signed response", rec.Code)
166 }
167 s.calls = 0
168 h.now = func() time.Time { return time.Unix(0, 0) }
169 rec = httptest.NewRecorder()
170 h.ServeHTTP(rec, rosterRequest())
171 if rec.Code != 503 || s.calls != 0 {
172 t.Fatal("clock fault reached DB")
173 }
174}
175