| 1 | package home |
| 2 | |
| 3 | import ( |
| 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 | |
| 17 | type rosterSpy struct { |
| 18 | calls int |
| 19 | req directorydata.RosterRequest |
| 20 | page roster.Page |
| 21 | status int |
| 22 | err error |
| 23 | } |
| 24 | |
| 25 | func (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 | } |
| 33 | func 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 | |
| 47 | const rosterTestChallenge = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" |
| 48 | |
| 49 | func 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 | } |
| 55 | func 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 | } |
| 66 | func 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 | } |
| 109 | func 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 | } |
| 134 | func 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 | |