| index 82c723e36..dbeb39b26 100644 |
| --- a/internal/edge/gate_test.go |
| +++ b/internal/edge/gate_test.go |
| @@ -229,28 +229,14 @@ func TestGatedPrefixTargetCanBeLoggedInto(t *testing.T) { |
| t.Fatalf("the front app was reached: Host %q", *frontSeen) |
| } |
| |
| - form := url.Values{"password": {"correct horse"}, "next": {"/paulca/x"}} |
| - req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode())) |
| - req.Host = "front.example.com" |
| - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| - c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| - post, err := c.Do(req) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - defer post.Body.Close() |
| + post := postLogin(t, srv, "front.example.com", "correct horse", "/paulca/x") |
| if post.StatusCode != http.StatusSeeOther || post.Header.Get("Location") != "/paulca/x" { |
| t.Fatalf("login POST = %d -> %q, want 303 -> /paulca/x", post.StatusCode, post.Header.Get("Location")) |
| } |
| if *frontSeen != "" { |
| t.Fatalf("the login POST reached the front app: Host %q — the password must never be proxied", *frontSeen) |
| } |
| - var got string |
| - for _, k := range post.Cookies() { |
| - if k.Name == gate.CookieName { |
| - got = k.Value |
| - } |
| - } |
| + got := gateCookie(post) |
| if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) { |
| t.Fatalf("cookie %q does not verify for the target host", got) |
| } |
| @@ -263,6 +249,77 @@ func TestGatedPrefixTargetCanBeLoggedInto(t *testing.T) { |
| if *targetCookie != "" { |
| t.Fatalf("target Cookie = %q, want the gate cookie stripped", *targetCookie) |
| } |
| + |
| + // A refusal on a prefix path that carries a QUERY. The 401's next is |
| + // SafeNext(r.URL.RequestURI()), which keeps the query, so the browser |
| + // posts next=/paulca?tab=1 — and resolving the POST on that raw string |
| + // makes the first segment "paulca?tab=1", which matches no prefix and |
| + // drops the login onto the FRONT route: the wrong hash judges the |
| + // password and the cookie is signed for the wrong host. The lookup must |
| + // see the PATH only. |
| + resp = doHost(t, srv, http.MethodGet, "front.example.com", "/paulca?tab=1", "") |
| + b, _ = io.ReadAll(resp.Body) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("prefix GET with a query = %d, want the 401 login page", resp.StatusCode) |
| + } |
| + if !strings.Contains(string(b), `name="next" value="/paulca?tab=1"`) { |
| + t.Fatalf("the 401 page does not carry next=/paulca?tab=1:\n%s", b) |
| + } |
| + |
| + post = postLogin(t, srv, "front.example.com", "correct horse", "/paulca?tab=1") |
| + if post.StatusCode != http.StatusSeeOther || post.Header.Get("Location") != "/paulca?tab=1" { |
| + t.Fatalf("login POST with a query in next = %d -> %q, want 303 -> /paulca?tab=1", |
| + post.StatusCode, post.Header.Get("Location")) |
| + } |
| + if *frontSeen != "" { |
| + t.Fatalf("the login POST reached the front app: Host %q — it resolved on the raw next, query and all", *frontSeen) |
| + } |
| + got = gateCookie(post) |
| + if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) { |
| + t.Fatalf("cookie %q does not verify for the target host — the POST was judged by the front route", got) |
| + } |
| + |
| + // And a percent-encoded first segment. RequestURI is raw, r.URL.Path is |
| + // decoded: "/pau%6Cca/x" is "/paulca/x" once parsed, so the POST belongs |
| + // to the target too. |
| + post = postLogin(t, srv, "front.example.com", "correct horse", "/pau%6Cca/x") |
| + if post.StatusCode != http.StatusSeeOther { |
| + t.Fatalf("login POST with a percent-encoded segment = %d, want 303", post.StatusCode) |
| + } |
| + if *frontSeen != "" { |
| + t.Fatalf("the login POST reached the front app: Host %q — the encoded segment was not decoded", *frontSeen) |
| + } |
| + got = gateCookie(post) |
| + if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) { |
| + t.Fatalf("cookie %q does not verify for the target host", got) |
| + } |
| +} |
| + |
| +// postLogin submits the gate's login form to srv as host, exactly as the |
| +// 401 page's form would, and returns the response with redirects left |
| +// unfollowed. |
| +func postLogin(t *testing.T, srv *httptest.Server, host, password, next string) *http.Response { |
| + t.Helper() |
| + form := url.Values{"password": {password}, "next": {next}} |
| + req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode())) |
| + req.Host = host |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + resp, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +func gateCookie(resp *http.Response) string { |
| + for _, k := range resp.Cookies() { |
| + if k.Name == gate.CookieName { |
| + return k.Value |
| + } |
| + } |
| + return "" |
| } |
| |
| // TestLoginPostToUngatedRouteIs404 is the other half of §4.2: the form is |