Render validated Docs team selection as scriptless continuation
7 files changed,
+172
−9
internal/home/directorydata/store.go+5 −2internal/home/directorydata/store_test.go+2 −2internal/home/docs_chooser.go+12 −1internal/home/docs_chooser_test.go+2 −4internal/home/docs_continue_test.go+144 −0internal/home/templates/docs_continue.html+6 −0internal/home/templates/layout.html+1 −0
diff --git a/internal/home/directorydata/store.go b/internal/home/directorydata/store.go| index f5092a0..c7bb31b 100644 |
| --- a/internal/home/directorydata/store.go |
| +++ b/internal/home/directorydata/store.go |
| @@ -145,7 +145,7 @@ func (s *Store) Choices(ctx context.Context, subject, app string) ([]Choice, err |
| continue |
| } |
| route := Route{TeamID: c.ID, TeamSQID: c.SQID, Label: c.Label, App: c.App, Origin: c.Origin, ConsumerID: c.ConsumerID} |
| - if !validOrigin(c.Origin) || !validConsumer(c.ConsumerID) || !s.validateRoute(route) { |
| + if !ValidOrigin(c.Origin) || !validConsumer(c.ConsumerID) || !s.validateRoute(route) { |
| return nil, ErrUnavailable |
| } |
| if err = s.openTeam(&c.Team, sealed); err != nil { |
| @@ -216,7 +216,10 @@ func validName(s string) bool { |
| } |
| return true |
| } |
| -func validOrigin(s string) bool { |
| + |
| +// ValidOrigin accepts only canonical HTTPS DNS origins without ports or URL components. |
| +// It checks syntax only; callers must also apply their provisioning policy. |
| +func ValidOrigin(s string) bool { |
| u, err := url.Parse(s) |
| if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.ForceQuery || u.Opaque != "" || u.Port() != "" || u.Host != strings.ToLower(u.Host) { |
| return false |
diff --git a/internal/home/directorydata/store_test.go b/internal/home/directorydata/store_test.go| index b512f0d..c84b7bb 100644 |
| --- a/internal/home/directorydata/store_test.go |
| +++ b/internal/home/directorydata/store_test.go |
| @@ -221,11 +221,11 @@ func TestUnsafeStoredRouteRefusesEntireList(t *testing.T) { |
| func TestInputAndErrorPrivacy(t *testing.T) { |
| s, _, _ := fixture(t) |
| for _, origin := range []string{"http://alpha.example", "https://user@example.com", "https://example.com/", "https://example.com?", "https://EXAMPLE.com", "https://example.com:443", "https://example.com#x", "https://example.com.", "https://localhost", "https://a..example"} { |
| - if validOrigin(origin) { |
| + if ValidOrigin(origin) { |
| t.Errorf("accepted %q", origin) |
| } |
| } |
| - if !validOrigin("https://alpha.docs.oficina.works") { |
| + if !ValidOrigin("https://alpha.docs.oficina.works") { |
| t.Fatal("valid origin refused") |
| } |
| for _, cfg := range []Config{{}, {Apps: []string{"docs"}}, {Apps: []string{"docs", "docs"}, ValidateRoute: func(Route) bool { return true }}, {Apps: []string{"bad/app"}, ValidateRoute: func(Route) bool { return true }}} { |
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go| index fca4bb0..b664a5f 100644 |
| --- a/internal/home/docs_chooser.go |
| +++ b/internal/home/docs_chooser.go |
| @@ -107,7 +107,7 @@ func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) { |
| } |
| for _, choice := range choices { |
| if choice.SQID == form.Get("team") { |
| - c.redirect(w, r, choice) |
| + c.continueToDocs(w, choice) |
| return |
| } |
| } |
| @@ -133,3 +133,14 @@ func (c *docsChooser) redirect(w http.ResponseWriter, r *http.Request, choice di |
| // policy. Generic entry carries only the app root, never a document path. |
| http.Redirect(w, r, choice.Origin+handoff.StartPath+"?"+url.Values{"return_to": {"/"}}.Encode(), http.StatusSeeOther) |
| } |
| + |
| +func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata.Choice) { |
| + if !directorydata.ValidOrigin(choice.Origin) { |
| + c.unavailable(w) |
| + return |
| + } |
| + destination := choice.Origin + handoff.StartPath + "?" + url.Values{"return_to": {"/"}}.Encode() |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Referrer-Policy", "no-referrer") |
| + c.app.render(w, http.StatusOK, "docs_continue.html", struct{ Destination string }{destination}) |
| +} |
diff --git a/internal/home/docs_chooser_test.go b/internal/home/docs_chooser_test.go| index 98b9b40..3bfc673 100644 |
| --- a/internal/home/docs_chooser_test.go |
| +++ b/internal/home/docs_chooser_test.go |
| @@ -144,9 +144,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) { |
| } |
| r = f.request("POST", docsChoicePath, directoryOrigin, "team=B2", cookie) |
| noStore(t, r) |
| - if r.Code != 303 || r.Header().Get("Location") != "https://beta.docs.oficina.works/auth/start?return_to=%2F" { |
| - t.Fatal(r.Code, r.Header()) |
| - } |
| + assertDocsContinuation(t, r, "https://beta.docs.oficina.works/auth/start?return_to=%2F") |
| if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil { |
| t.Fatal(err) |
| } |
| @@ -208,7 +206,7 @@ func TestDocsChooserRefusesHostileInputAndCrossOriginSelection(t *testing.T) { |
| t.Fatal(body, r.Code) |
| } |
| } |
| - for _, origin := range []string{"https://attacker.example", Origin} { |
| + for _, origin := range []string{"", "https://attacker.example", Origin} { |
| r := f.request("POST", docsChoicePath, origin, "team=A1", cookie) |
| noStore(t, r) |
| if r.Code != 403 { |
diff --git a/internal/home/docs_continue_test.go b/internal/home/docs_continue_test.go| new file mode 100644 |
| index 0000000..8b7b237 |
| --- /dev/null |
| +++ b/internal/home/docs_continue_test.go |
| @@ -0,0 +1,144 @@ |
| +package home |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/xml" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| +) |
| + |
| +func continuationTargets(t *testing.T, body string) (refresh, links []string) { |
| + t.Helper() |
| + d := xml.NewDecoder(strings.NewReader(body)) |
| + d.Strict = false |
| + d.AutoClose = xml.HTMLAutoClose |
| + d.Entity = xml.HTMLEntity |
| + for { |
| + token, err := d.Token() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + el, ok := token.(xml.StartElement) |
| + if !ok { |
| + continue |
| + } |
| + a := map[string]string{} |
| + for _, v := range el.Attr { |
| + a[v.Name.Local] = v.Value |
| + } |
| + if el.Name.Local == "meta" && strings.EqualFold(a["http-equiv"], "refresh") { |
| + refresh = append(refresh, a["content"]) |
| + } |
| + if el.Name.Local == "a" { |
| + if a["referrerpolicy"] != "no-referrer" { |
| + t.Fatal("anchor sends referrer") |
| + } |
| + links = append(links, a["href"]) |
| + } |
| + } |
| + return |
| +} |
| +func assertDocsContinuation(t *testing.T, r *httptest.ResponseRecorder, want string) { |
| + t.Helper() |
| + noStore(t, r) |
| + if r.Code != 200 || r.Header().Get("Location") != "" || r.Header().Get("Refresh") != "" || r.Header().Get("Content-Type") != "text/html; charset=utf-8" || r.Header().Get("Referrer-Policy") != "no-referrer" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| + refresh, links := continuationTargets(t, r.Body.String()) |
| + if len(refresh) != 1 || refresh[0] != "0;url="+want || len(links) != 1 || links[0] != want || strings.Contains(r.Body.String(), "ZgotmplZ") || strings.Contains(r.Body.String(), "<script") { |
| + t.Fatal(refresh, links, r.Body.String()) |
| + } |
| +} |
| +func TestDocsContinueCanonicalResponseBoundary(t *testing.T) { |
| + f := newChooserFixture(t) |
| + c, err := f.p.directory.newDocsChooser(f.store) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, origin := range []string{"https://alpha.docs.oficina.works", "https://approved.fixture.example"} { |
| + r := httptest.NewRecorder() |
| + c.continueToDocs(r, directorydata.Choice{Origin: origin}) |
| + assertDocsContinuation(t, r, origin+"/auth/start?return_to=%2F") |
| + } |
| + for _, origin := range []string{"", "http://alpha.example", "https://Alpha.example", "https://alpha.example/", "https://alpha.example:443", "https://user@alpha.example", "https://alpha.example?x=1", "https://alpha.example#f", "https://alpha.example?", "https://alpha.example\n", " https://alpha.example", "https://alpha.example\";url=https://bad.example", "javascript:alert(1)", "//alpha.example", "https://[::1]", "https://alpha.example/%22"} { |
| + r := httptest.NewRecorder() |
| + c.continueToDocs(r, directorydata.Choice{Origin: origin}) |
| + if r.Code != 503 || strings.Contains(r.Body.String(), "http-equiv") || r.Header().Get("Location") != "" { |
| + t.Fatal(origin, r.Code, r.Body.String()) |
| + } |
| + } |
| +} |
| +func TestDocsContinueRendererEscapesIdenticalTargets(t *testing.T) { |
| + f := newChooserFixture(t) |
| + // Exercise real template contexts independently of the fixed runtime URL. |
| + want := "https://alpha.docs.oficina.works/auth/start?return_to=%2F&probe=%22%3C" |
| + r := httptest.NewRecorder() |
| + f.p.directory.render(r, 200, "docs_continue.html", struct{ Destination string }{want}) |
| + refresh, links := continuationTargets(t, r.Body.String()) |
| + if len(refresh) != 1 || refresh[0] != "0;url="+want || len(links) != 1 || links[0] != want || !strings.Contains(r.Body.String(), "&probe=") { |
| + t.Fatal(refresh, links, r.Body.String()) |
| + } |
| + for name, page := range f.p.directory.renderer.pages { |
| + if name == "docs_continue.html" { |
| + continue |
| + } |
| + var b bytes.Buffer |
| + _ = page.ExecuteTemplate(&b, "layout", map[string]any{}) |
| + if strings.Contains(strings.ToLower(b.String()), `http-equiv="refresh"`) { |
| + t.Fatalf("refresh leaked to %s", name) |
| + } |
| + } |
| +} |
| +func TestDocsContinueRequeriesCurrentOrigin(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + id := f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + f.team(t, "B2", "beta", "Beta", chooserPerson) |
| + if r := f.request("GET", docsEntryPath, "", "", cookie); r.Code != 200 { |
| + t.Fatal(r.Code) |
| + } |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE teams SET label='changed' WHERE id=?`, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://changed.docs.oficina.works' WHERE team_id=?`, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + assertDocsContinuation(t, f.request("POST", docsChoicePath, directoryOrigin, "team=A1", cookie), "https://changed.docs.oficina.works/auth/start?return_to=%2F") |
| +} |
| +func TestDocsContinueExactHostAndExpiredSession(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + for _, method := range []string{"GET", "POST"} { |
| + path := docsEntryPath |
| + if method == "POST" { |
| + path = docsChoicePath |
| + } |
| + r := httptest.NewRequest(method, directoryOrigin+path, strings.NewReader("team=A1")) |
| + r.Host = "wrong.example" |
| + r.AddCookie(cookie) |
| + r.Header.Set("Origin", directoryOrigin) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code != 404 { |
| + t.Fatal(method, w.Code) |
| + } |
| + } |
| + if _, err := f.p.directory.db.Writer().ExecContext(context.Background(), `UPDATE sessions SET expires_at='2000-01-01T00:00:00Z'`); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := f.request(http.MethodPost, docsChoicePath, directoryOrigin, "team=A1", cookie) |
| + if r.Code != 303 || r.Header().Get("Location") != "/auth/start?return_to=%2Fapps%2Fdocs" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| +} |
diff --git a/internal/home/templates/docs_continue.html b/internal/home/templates/docs_continue.html| new file mode 100644 |
| index 0000000..ff03281 |
| --- /dev/null |
| +++ b/internal/home/templates/docs_continue.html |
| @@ -0,0 +1,6 @@ |
| +{{define "title"}}Continue to Docs{{end}} |
| +{{define "head"}}<meta http-equiv="refresh" content="0;url={{.Destination}}">{{end}} |
| +{{define "content"}} |
| +<h1>Continue to Docs</h1> |
| +<p><a href="{{.Destination}}" referrerpolicy="no-referrer">Continue to Docs</a></p> |
| +{{end}} |
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html| index 9340be0..6054667 100644 |
| --- a/internal/home/templates/layout.html |
| +++ b/internal/home/templates/layout.html |
| @@ -6,6 +6,7 @@ |
| <title>{{block "title" .}}Oficina{{end}}</title> |
| <link rel="stylesheet" href="/static/tokens.css"> |
| <link rel="stylesheet" href="/static/theme.css"> |
| +{{block "head" .}}{{end}} |
| </head> |
| <body{{block "bodyattrs" .}}{{end}}> |
| <main rst-page> |