| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "amadan.net/oficina/home/handoff" |
| 13 | "amadan.net/oficina/home/internal/home/directorydata" |
| 14 | "amadan.net/rastrillo/rastrillo/migrate" |
| 15 | "amadan.net/rastrillo/rastrillo/sessions" |
| 16 | ) |
| 17 | |
| 18 | const chooserPerson = "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 19 | const chooserOther = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| 20 | |
| 21 | type chooserFixture struct { |
| 22 | p exchangePair |
| 23 | store *directorydata.Store |
| 24 | handler http.Handler |
| 25 | } |
| 26 | |
| 27 | func newChooserFixture(t *testing.T) chooserFixture { |
| 28 | t.Helper() |
| 29 | p := newExchangePair(t) |
| 30 | if _, err := migrate.Apply(context.Background(), p.directory.db, directorydata.Schema); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | store, err := directorydata.New(p.directory.db.Writer(), p.directory.secrets.Sealer, directorydata.Config{Apps: []string{"docs"}, ValidateRoute: func(r directorydata.Route) bool { return r.Origin == "https://"+r.Label+".docs.oficina.works" }}) |
| 34 | if err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | c, err := p.directory.newDocsChooser(store) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | mux := http.NewServeMux() |
| 42 | mux.HandleFunc(docsEntryPath, c.Entry) |
| 43 | mux.HandleFunc(docsChoicePath, c.Select) |
| 44 | return chooserFixture{p, store, p.directory.serveKnownHost(p.directory.sessions.Middleware(mux))} |
| 45 | } |
| 46 | func (f chooserFixture) session(t *testing.T, subject string) *http.Cookie { |
| 47 | t.Helper() |
| 48 | rec := httptest.NewRecorder() |
| 49 | req := httptest.NewRequest("GET", directoryOrigin+"/", nil) |
| 50 | if err := f.p.directory.sessions.SignIn(rec, req, sessions.Session{Subject: subject, Method: "assertion", AuthTime: time.Now()}); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | cookie := sessionCookie(rec) |
| 54 | if cookie == nil { |
| 55 | t.Fatal("missing session") |
| 56 | } |
| 57 | return cookie |
| 58 | } |
| 59 | func (f chooserFixture) request(method, path, origin, body string, cookie *http.Cookie) *httptest.ResponseRecorder { |
| 60 | r := httptest.NewRequest(method, directoryOrigin+path, strings.NewReader(body)) |
| 61 | if cookie != nil { |
| 62 | r.AddCookie(cookie) |
| 63 | } |
| 64 | if method == "POST" { |
| 65 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 66 | } |
| 67 | if origin != "" { |
| 68 | r.Header.Set("Origin", origin) |
| 69 | } |
| 70 | rec := httptest.NewRecorder() |
| 71 | f.handler.ServeHTTP(rec, r) |
| 72 | return rec |
| 73 | } |
| 74 | func (f chooserFixture) team(t *testing.T, sqid, label, name, person string) int64 { |
| 75 | t.Helper() |
| 76 | tx, err := f.p.directory.db.Writer().Begin() |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | defer tx.Rollback() |
| 81 | r, err := tx.Exec(`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES(?,?,zeroblob(29),1)`, sqid, label) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | id, err := r.LastInsertId() |
| 86 | if err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | b, err := f.store.SealTeamName(id, name) |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | for _, q := range []struct { |
| 94 | s string |
| 95 | a []any |
| 96 | }{ |
| 97 | {`UPDATE teams SET sealed_name=? WHERE id=?`, []any{b, id}}, |
| 98 | {`INSERT INTO memberships VALUES(?,?,?)`, []any{id, person, "member"}}, |
| 99 | {`INSERT INTO team_instances VALUES(?,?,?,?,?)`, []any{id, "docs", "https://" + label + ".docs.oficina.works", label + "-docs", "live"}}, |
| 100 | } { |
| 101 | if _, err = tx.Exec(q.s, q.a...); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | } |
| 105 | if err = tx.Commit(); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | return id |
| 109 | } |
| 110 | func noStore(t *testing.T, r *httptest.ResponseRecorder) { |
| 111 | t.Helper() |
| 112 | if r.Header().Get("Cache-Control") != "no-store" { |
| 113 | t.Fatalf("cacheable response %d", r.Code) |
| 114 | } |
| 115 | if len(r.Result().Cookies()) != 0 { |
| 116 | t.Fatal("chooser changed session/navigation cookies") |
| 117 | } |
| 118 | } |
| 119 | func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) { |
| 120 | f := newChooserFixture(t) |
| 121 | cookie := f.session(t, chooserPerson) |
| 122 | r := f.request("GET", docsEntryPath, "", "", cookie) |
| 123 | noStore(t, r) |
| 124 | if r.Code != 200 || !strings.Contains(r.Body.String(), "This account has no team with Docs available.") { |
| 125 | t.Fatal(r.Code, r.Body.String()) |
| 126 | } |
| 127 | a := f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| 128 | r = f.request("GET", docsEntryPath, "", "", cookie) |
| 129 | noStore(t, r) |
| 130 | if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/" { |
| 131 | t.Fatal(r.Code, r.Header()) |
| 132 | } |
| 133 | f.team(t, "B2", "beta", `<script>alert("team")</script>`, chooserPerson) |
| 134 | f.team(t, "C3", "foreign", "Foreign secret label", chooserOther) |
| 135 | r = f.request("GET", docsEntryPath, "", "", cookie) |
| 136 | noStore(t, r) |
| 137 | for _, want := range []string{`method="post" action="/apps/docs/team"`, `name="team" value="A1"`, `name="team" value="B2"`, `/static/theme.css`, `<script>`} { |
| 138 | if !strings.Contains(r.Body.String(), want) { |
| 139 | t.Fatal("missing", want) |
| 140 | } |
| 141 | } |
| 142 | if r.Code != 200 || hasOtherScript(r.Body.String()) || strings.Contains(r.Body.String(), "Foreign secret label") || strings.Contains(r.Body.String(), chooserPerson) { |
| 143 | t.Fatal(r.Code, r.Body.String()) |
| 144 | } |
| 145 | r = f.request("POST", docsChoicePath, directoryOrigin, "team=B2", cookie) |
| 146 | noStore(t, r) |
| 147 | assertDocsContinuation(t, r, "https://beta.docs.oficina.works/") |
| 148 | if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | for _, team := range []string{"A1", "C3", "Unknown"} { |
| 152 | r = f.request("POST", docsChoicePath, directoryOrigin, "team="+team, cookie) |
| 153 | noStore(t, r) |
| 154 | if r.Code != 404 { |
| 155 | t.Fatal(team, r.Code) |
| 156 | } |
| 157 | } |
| 158 | r = f.request("GET", docsEntryPath, "", "", cookie) |
| 159 | if r.Header().Get("Location") != "https://beta.docs.oficina.works/" { |
| 160 | t.Fatal(r.Header()) |
| 161 | } |
| 162 | } |
| 163 | func TestDocsChooserSignedOutReturnThroughActualHandoff(t *testing.T) { |
| 164 | f := newChooserFixture(t) |
| 165 | r := f.request("GET", docsEntryPath, "", "", nil) |
| 166 | noStore(t, r) |
| 167 | if r.Code != 303 || r.Header().Get("Location") != "/auth/start?return_to=%2Fapps%2Fdocs" { |
| 168 | t.Fatal(r.Code, r.Header()) |
| 169 | } |
| 170 | start := exchangeRequest(f.p.directory, "GET", directoryOrigin+r.Header().Get("Location"), "", nil) |
| 171 | pending := namedCookie(t, start, "__Host-oficina-assertion-pending") |
| 172 | authorize := exchangeRequest(f.p.credential.App, "GET", start.Header().Get("Location"), "", nil) |
| 173 | intent := namedCookie(t, authorize, assertionIntentCookie) |
| 174 | credentialSession := signInFully(t, f.p.credential, "chooser@example.invalid") |
| 175 | token, _ := f.p.mint(t, intent, credentialSession) |
| 176 | callback := exchangeRequest(f.p.directory, "POST", directoryOrigin+handoff.CallbackPath, Origin, url.Values{"assertion": {token}}) |
| 177 | inbox := namedCookie(t, callback, "__Host-oficina-assertion-inbox") |
| 178 | finish := exchangeRequest(f.p.directory, "GET", directoryOrigin+handoff.FinishPath, "", nil, pending, inbox) |
| 179 | if finish.Code != 303 || finish.Header().Get("Location") != docsEntryPath { |
| 180 | t.Fatal(finish.Code, finish.Header()) |
| 181 | } |
| 182 | r = f.request("GET", finish.Header().Get("Location"), "", "", sessionCookie(finish)) |
| 183 | if r.Code != 200 { |
| 184 | t.Fatal(r.Code) |
| 185 | } |
| 186 | } |
| 187 | func TestDocsChooserRefusesHostileInputAndCrossOriginSelection(t *testing.T) { |
| 188 | f := newChooserFixture(t) |
| 189 | cookie := f.session(t, chooserPerson) |
| 190 | f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| 191 | for _, path := range []string{"/apps/docs?team=A1", "/apps/docs?return_to=https://attacker.example", "/apps/docs?", "/apps/docs/team?team=A1"} { |
| 192 | method := "GET" |
| 193 | if strings.Contains(path, "/team") { |
| 194 | method = "POST" |
| 195 | } |
| 196 | r := f.request(method, path, directoryOrigin, "team=A1", cookie) |
| 197 | noStore(t, r) |
| 198 | if r.Code != 404 { |
| 199 | t.Fatal(path, r.Code) |
| 200 | } |
| 201 | } |
| 202 | for _, body := range []string{"team=A1&team=A1", "team=A1&return_to=%2Fsecret", "team=", "team=%zz", "team=https%3A%2F%2Fattacker.example", strings.Repeat("x", 1025)} { |
| 203 | r := f.request("POST", docsChoicePath, directoryOrigin, body, cookie) |
| 204 | noStore(t, r) |
| 205 | if r.Code != 404 { |
| 206 | t.Fatal(body, r.Code) |
| 207 | } |
| 208 | } |
| 209 | for _, origin := range []string{"", "https://attacker.example", Origin} { |
| 210 | r := f.request("POST", docsChoicePath, origin, "team=A1", cookie) |
| 211 | noStore(t, r) |
| 212 | if r.Code != 403 { |
| 213 | t.Fatal(origin, r.Code) |
| 214 | } |
| 215 | } |
| 216 | for _, subject := range []string{"user@example.invalid", "A1", chooserOther} { |
| 217 | other := f.session(t, subject) |
| 218 | r := f.request("POST", docsChoicePath, directoryOrigin, "team=A1", other) |
| 219 | noStore(t, r) |
| 220 | if r.Code != 404 { |
| 221 | t.Fatal(subject, r.Code) |
| 222 | } |
| 223 | } |
| 224 | r := f.request("GET", docsChoicePath, "", "", cookie) |
| 225 | noStore(t, r) |
| 226 | if r.Code != 405 { |
| 227 | t.Fatal(r.Code) |
| 228 | } |
| 229 | r = f.request("POST", docsEntryPath, directoryOrigin, "team=A1", cookie) |
| 230 | noStore(t, r) |
| 231 | if r.Code != 405 { |
| 232 | t.Fatal(r.Code) |
| 233 | } |
| 234 | } |
| 235 | func TestDocsChooserFailureIsPrivateWhenMounted(t *testing.T) { |
| 236 | f := newChooserFixture(t) |
| 237 | cookie := f.session(t, chooserPerson) |
| 238 | f.team(t, "A1", "alpha", "Private team name", chooserPerson) |
| 239 | if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://unapproved.example'`); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | r := f.request("GET", docsEntryPath, "", "", cookie) |
| 243 | noStore(t, r) |
| 244 | if r.Code != 503 || strings.Contains(r.Body.String(), "Private team name") || strings.Contains(r.Body.String(), "unapproved.example") { |
| 245 | t.Fatal(r.Code, r.Body.String()) |
| 246 | } |
| 247 | r = exchangeRequest(f.p.directory, "GET", directoryOrigin+docsEntryPath, "", nil, cookie) |
| 248 | if r.Code != 503 { |
| 249 | t.Fatal("mounted chooser did not preserve storage refusal", r.Code) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func TestDocsChooserCannotAttachToCredentialRole(t *testing.T) { |
| 254 | f := newChooserFixture(t) |
| 255 | if _, err := f.p.credential.App.newDocsChooser(f.store); err == nil { |
| 256 | t.Fatal("credential role accepted directory renderer") |
| 257 | } |
| 258 | if _, err := f.p.directory.newDocsChooser(nil); err == nil { |
| 259 | t.Fatal("nil store accepted") |
| 260 | } |
| 261 | } |
| 262 | |