package edge import ( "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/carlosframework/platform/internal/gate" "github.com/carlosframework/platform/internal/registry" ) func gatedEdge(t *testing.T, act Activator, hibernate bool) (*Edge, *httptest.Server, string, *string) { t.Helper() backend, seenCookie := cookieEchoBackend(t, "secret page") h, err := gate.Hash("correct horse") if err != nil { t.Fatal(err) } reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: hibernate, Gate: h}); err != nil { t.Fatal(err) } if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.custom.example", Host: "gated.example.com", Account: "acct", App: "app", CreatedAt: 1}); err != nil { t.Fatal(err) } e, err := New(reg, act) if err != nil { t.Fatal(err) } srv := httptest.NewServer(e) t.Cleanup(srv.Close) return e, srv, h, seenCookie } // cookieEchoBackend records the Cookie header it saw. func cookieEchoBackend(t *testing.T, body string) (*httptest.Server, *string) { t.Helper() seen := new(string) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { *seen = r.Header.Get("Cookie") io.WriteString(w, body) })) t.Cleanup(ts.Close) return ts, seen } func doHost(t *testing.T, srv *httptest.Server, method, host, path, cookie string) *http.Response { t.Helper() req, _ := http.NewRequest(method, srv.URL+path, nil) req.Host = host if cookie != "" { req.Header.Set("Cookie", cookie) } 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 TestGatedRouteWithoutCookieNeverWakesOrProxies(t *testing.T) { // refusingActivator's Fatal message talks about caching — that is where // it came from — but here it proves the gate: a route flagged // Hibernate:true whose activator is never invoked is one a stranger // could not have woken. _, srv, _, seen := gatedEdge(t, refusingActivator{t: t}, true) resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", resp.StatusCode) } b, _ := io.ReadAll(resp.Body) if !strings.Contains(string(b), "Password required") { t.Fatalf("body: %s", b) } if resp.Header.Get("Cache-Control") != "no-store" { t.Fatalf("Cache-Control = %q", resp.Header.Get("Cache-Control")) } if resp.Header.Get("X-Robots-Tag") != "noindex, nofollow" { t.Fatalf("X-Robots-Tag = %q", resp.Header.Get("X-Robots-Tag")) } if *seen != "" || strings.Contains(string(b), "secret page") { t.Fatal("upstream was reached without a cookie") } } func TestGatedRouteCookieAdmitsAndIsStripped(t *testing.T) { _, srv, h, seen := gatedEdge(t, &fakeActivator{}, false) cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)) resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "a=1; "+cookie) b, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 || string(b) != "secret page" { t.Fatalf("got %d %q", resp.StatusCode, b) } if *seen != "a=1" { t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", *seen) } } func TestGatedAliasIsGatedToo(t *testing.T) { _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", ""); resp.StatusCode != http.StatusUnauthorized { t.Fatalf("alias without cookie = %d, want 401", resp.StatusCode) } // The MAC is bound to the ROUTE host, so a cookie minted for the primary // verifies when presented on the alias. cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)) if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", cookie); resp.StatusCode != 200 { t.Fatalf("alias with cookie = %d, want 200", resp.StatusCode) } } func TestGatedLoginPostSetsCookie(t *testing.T) { _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) form := url.Values{"password": {"correct horse"}, "next": {"/docs"}} req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode())) req.Host = "gated.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 }} resp, err := c.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" { t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location")) } var got string for _, k := range resp.Cookies() { if k.Name == gate.CookieName { got = k.Value } } if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) { t.Fatalf("cookie %q does not verify", got) } } // TestGatedLoginPostOnAliasKeepsTheInboundHost is the pin on what the edge // hands Admit: the same-origin check compares Origin against the REQUEST's // Host, so a login posted to the attached hostname must arrive with that // hostname intact — while the cookie it mints is signed for the ROUTE's // host, the one every alias of the route shares. Rewriting r.Host to the // route before the gate step would 403 every alias login. func TestGatedLoginPostOnAliasKeepsTheInboundHost(t *testing.T) { _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) form := url.Values{"password": {"correct horse"}, "next": {"/docs"}} req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode())) req.Host = "www.custom.example" req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", "http://www.custom.example") c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := c.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" { t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location")) } var got string for _, k := range resp.Cookies() { if k.Name == gate.CookieName { got = k.Value } } if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) { t.Fatalf("cookie %q does not verify for the route host", got) } } // gatedPrefixEdge stands up the shape §4.2 is about: a hostname whose own // route is UNGATED and whose "paulca" prefix reaches a GATED route on // another host. A login POST to /.carlos/gate has no first segment that can // ever be a prefix, so the edge must route it by `next` to land on the // gated target rather than on the front. func gatedPrefixEdge(t *testing.T) (*httptest.Server, string, *string, *string) { t.Helper() front, frontSeen := hostEchoBackend(t, "the front app") target, targetCookie := cookieEchoBackend(t, "secret page") h, err := gate.Hash("correct horse") if err != nil { t.Fatal(err) } reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "front.example.com", Kind: "service", Addr: front.Listener.Addr().String(), Status: "ready"}); err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "service", Addr: target.Listener.Addr().String(), Status: "ready", Gate: h}); err != nil { t.Fatal(err) } upsertPath(t, reg, "front.example.com", "paulca", "gated.example.com") e, err := New(reg, &fakeActivator{}) if err != nil { t.Fatal(err) } srv := httptest.NewServer(e) t.Cleanup(srv.Close) return srv, h, frontSeen, targetCookie } // TestGatedPrefixTargetCanBeLoggedInto is the §4.2 finding: routing the // login POST by its own path would resolve it to the FRONT host's ungated // route, which either proxies the password to an app that never asked for // it or mints a cookie signed for the wrong host. Routed by `next`, the // POST reaches the target whose hash judges it and whose host signs the // cookie — and that cookie then admits the visitor on the front hostname, // because the prefix resolves to the same route either way. func TestGatedPrefixTargetCanBeLoggedInto(t *testing.T) { srv, h, frontSeen, targetCookie := gatedPrefixEdge(t) // The refusal itself: the prefix reaches the gated route, so the front // app never sees the request. resp := doHost(t, srv, http.MethodGet, "front.example.com", "/paulca/x", "") b, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(string(b), "Password required") { t.Fatalf("prefix GET without a cookie = %d %s, want the 401 login page", resp.StatusCode, b) } if *frontSeen != "" { t.Fatalf("the front app was reached: Host %q", *frontSeen) } 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) } 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) } resp = doHost(t, srv, http.MethodGet, "front.example.com", "/paulca/x", gate.CookieName+"="+got) b, _ = io.ReadAll(resp.Body) if resp.StatusCode != 200 || string(b) != "secret page" { t.Fatalf("prefix GET with the cookie = %d %q", resp.StatusCode, b) } 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 // only ever rendered by a gated route, so a login POST that lands on an // ungated one is a stray or a probe. It is answered 404 and the body — a // field literally named "password" — is never forwarded to an app that did // not ask for it. func TestLoginPostToUngatedRouteIs404(t *testing.T) { backend, seenHost := hostEchoBackend(t, "open page") reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { t.Fatal(err) } e, err := New(reg, &fakeActivator{}) if err != nil { t.Fatal(err) } srv := httptest.NewServer(e) defer srv.Close() form := url.Values{"password": {"correct horse"}, "next": {"/"}} req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode())) req.Host = "open.example.com" req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("login POST to an ungated route = %d, want 404", resp.StatusCode) } if *seenHost != "" { t.Fatalf("the app was reached with the password form: Host %q", *seenHost) } // Every other method on that path stays ordinary and reaches the app. if resp := doHost(t, srv, http.MethodGet, "open.example.com", gate.LoginPath, ""); resp.StatusCode != 200 { t.Fatalf("GET %s on an ungated route = %d, want the app's 200", gate.LoginPath, resp.StatusCode) } } // TestGatedRefusalIsMeteredToTheRoute pins the meter neighbour of the gate // step: the 401 is the tenant's page, served by the route, and its bytes // bill to the route like any other response it caused. Placing the gate // above the meter's wrap would make refusals free. func TestGatedRefusalIsMeteredToTheRoute(t *testing.T) { e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false) m := NewUsageMeter() e.EnableUsage(m) resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", resp.StatusCode) } // Drain only once the whole body is in hand: the login page is over // 2 KiB, so the headers reach the client before the handler returns and // the meter's deferred Record has run. io.ReadAll(resp.Body) row, ok := m.Drain()["gated.example.com"] if !ok { t.Fatal("no usage row for the gated route — the refusal was not metered") } if row.Requests != 1 { t.Fatalf("Requests = %d, want 1", row.Requests) } if row.BytesOut <= 0 { t.Fatalf("BytesOut = %d, want the login page's bytes", row.BytesOut) } } // TestLoginPostForAnUnknownHostLeavesTheBodyUnread is the backhaul pin. A // host this box does not serve may be homed on a sibling edge, and // e.backhaul reverse-proxies THIS request there — so nothing on the way to // that branch may read the body. Parsing the login form before the route // lookup drained it while leaving ContentLength alone, and the hop 502'd // with "ContentLength=41 with Body length 0". The form is parsed only once // the host is known to be ours. func TestLoginPostForAnUnknownHostLeavesTheBodyUnread(t *testing.T) { e, _, _, _ := gatedEdge(t, &fakeActivator{}, false) form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}.Encode() body := strings.NewReader(form) req := httptest.NewRequest(http.MethodPost, gate.LoginPath, body) req.Host = "not.served.here.example" req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) if body.Len() != len(form) { t.Fatalf("%d of %d body bytes were consumed before the route lookup — a backhauled hop would 502", len(form)-body.Len(), len(form)) } if rec.Code != http.StatusNotFound { t.Fatalf("unknown host = %d, want the unchanged unknown-host answer (404)", rec.Code) } } // TestOversizedLoginPostIs413AndSpendsNoAttempt: a body over // gate.MaxFormBody is refused at the edge, before Admit is ever called, so // it costs neither a bcrypt comparison nor a limiter token. The proof that // no token was spent is the ten wrong-password attempts that follow from // the same address: the bucket holds exactly ten, so if the junk body had // taken one, the last would be 429 instead of 401. func TestOversizedLoginPostIs413AndSpendsNoAttempt(t *testing.T) { _, srv, _, seen := gatedEdge(t, &fakeActivator{}, false) c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} post := func(t *testing.T, form string) int { t.Helper() req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form)) req.Host = "gated.example.com" req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) return resp.StatusCode } if code := post(t, "password="+strings.Repeat("a", 64<<10)); code != http.StatusRequestEntityTooLarge { t.Fatalf("oversized login POST = %d, want 413", code) } if *seen != "" { t.Fatal("the oversized body reached the upstream") } for i := range 10 { if code := post(t, url.Values{"password": {"wrong horse"}}.Encode()); code != http.StatusUnauthorized { t.Fatalf("wrong-password attempt %d = %d, want 401 — the 413 spent a token", i+1, code) } } } func TestGatedRouteCacheIsNeverConsultedBeforeTheGate(t *testing.T) { e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false) e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) e.cache.Put(CacheKey{Host: "gated.example.com", Method: "GET", Path: "/"}, "", &CachedResponse{ StatusCode: 200, Header: http.Header{"Content-Type": {"text/plain"}}, Body: []byte("cached secret"), StoredAt: time.Now(), TTL: time.Minute, }) resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "") b, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusUnauthorized || strings.Contains(string(b), "cached secret") { t.Fatalf("cache served past the gate: %d %s", resp.StatusCode, b) } } func TestGatedWebSocketHandshakeIsGated(t *testing.T) { _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil) req.Host = "gated.example.com" req.Header.Set("Connection", "Upgrade") req.Header.Set("Upgrade", "websocket") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("upgrade without cookie = %d, want 401", resp.StatusCode) } req.Header.Set("Cookie", gate.CookieName+"="+gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))) resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { t.Fatal("upgrade with cookie was refused") } } func TestUngatedRouteUnchanged(t *testing.T) { backend, seen := cookieEchoBackend(t, "open page") reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { t.Fatal(err) } e, err := New(reg, &fakeActivator{}) if err != nil { t.Fatal(err) } srv := httptest.NewServer(e) defer srv.Close() resp := doHost(t, srv, http.MethodGet, "open.example.com", "/", gate.CookieName+"=whatever; a=1") if resp.StatusCode != 200 || *seen != gate.CookieName+"=whatever; a=1" { t.Fatalf("ungated route touched: %d cookie=%q", resp.StatusCode, *seen) } }