carlos / platform Public

edge: resolve a login POST by the PATH of `next`, not the raw string

The 401 page's hidden field is SafeNext(r.URL.RequestURI()), so it carries
the query the visitor was refused on and it is percent-encoded. The
re-resolve split that string directly, so firstSegment("/paulca?tab=1")
came out "paulca?tab=1", matched no prefix, and the POST fell through to
the FRONT host's route: on an ungated front that is a 404 for a visitor who
typed the right password, and on a gated one the wrong hash would judge the
password and the cookie would be signed for the wrong host. A percent-
encoded first segment missed the same way, because the prefix map is keyed
on decoded segments.

Parse `next` and look up u.Path — query and fragment dropped, segments
decoded — so a login POST lands on the route that put up the page whatever
shape the refused URL had.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 53537d5b3862d5cab03857e89318f175e3563017 parent b0e0632
2 files changed, +91 −17
  • internal/edge/edge.go +18 −1
  • internal/edge/gate_test.go +73 −16
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 94d010222..0079590c3 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -34,6 +34,7 @@ import (
"net/http"
"net/http/httputil"
"net/textproto"
+ "net/url"
"os"
"os/signal"
"path"
@@ -1255,11 +1256,27 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
return
}
+ // Resolve on the PATH of `next`, never on the raw string. The 401
+ // page's next is SafeNext(r.URL.RequestURI()), so it carries the
+ // query the visitor was refused on and it is percent-ENCODED —
+ // while the prefix map is keyed on decoded segments, the way
+ // r.URL.Path arrives for an ordinary request. Splitting the raw
+ // string makes firstSegment("/paulca?tab=1") "paulca?tab=1", which
+ // matches nothing and drops the login onto the FRONT route: the
+ // wrong hash judges the password and the cookie is signed for the
+ // wrong host. Parsing first drops the query and the fragment and
+ // decodes the segments, so both shapes land where the refusal did.
+ //
// A miss cannot happen — SafeNext yields a path on this host, and
// the worst case "/" resolves to the host's own route, the one
// already in up — but keeping the first up is the honest answer if
// the table changed underneath.
- if target, hit := e.lookupUpstreamPath(host, gate.SafeNext(r.PostForm.Get("next"))); hit {
+ next := gate.SafeNext(r.PostForm.Get("next"))
+ lookupPath := "/"
+ if u, err := url.ParseRequestURI(next); err == nil && u.Path != "" {
+ lookupPath = u.Path
+ }
+ if target, hit := e.lookupUpstreamPath(host, lookupPath); hit {
up = target
}
}
diff --git a/internal/edge/gate_test.go b/internal/edge/gate_test.go
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