package tide import ( "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) func TestProxyNamedSessionsOrderedAndIsolated(t *testing.T) { var seenHost []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenHost = append(seenHost, r.Host) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"path":"` + r.URL.Path + `"}`)) })) t.Cleanup(upstream.Close) fixtures := t.TempDir() proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML()) srv := httptest.NewServer(proxy.Handler()) t.Cleanup(srv.Close) doProxy(t, srv.URL, "alpha", "/one", "") doProxy(t, srv.URL, "alpha", "/two", "") doProxy(t, srv.URL, "beta", "/other", "") if err := proxy.Flush(); err != nil { t.Fatal(err) } alpha, err := LoadFlow(filepath.Join(fixtures, "nuxt", "alpha.yaml")) if err != nil { t.Fatal(err) } if len(alpha.Steps) != 2 { t.Fatalf("alpha steps: %d", len(alpha.Steps)) } if alpha.Steps[0].Request.Path != "/one" || alpha.Steps[1].Request.Path != "/two" { t.Fatalf("alpha order: %+v", alpha.Steps) } beta, err := LoadFlow(filepath.Join(fixtures, "nuxt", "beta.yaml")) if err != nil { t.Fatal(err) } if len(beta.Steps) != 1 || beta.Steps[0].Request.Path != "/other" { t.Fatalf("beta interleaved: %+v", beta.Steps) } req, err := http.NewRequest(http.MethodGet, srv.URL+"/evil", nil) if err != nil { t.Fatal(err) } req.Host = "evil.example" req.Header.Set(SessionHeader, "gamma") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if len(seenHost) < 4 { t.Fatalf("upstream hits: %d", len(seenHost)) } wantHost := strings.TrimPrefix(upstream.URL, "http://") if seenHost[len(seenHost)-1] != wantHost { t.Fatalf("client Host leaked: last=%s want=%s all=%v", seenHost[len(seenHost)-1], wantHost, seenHost) } } func TestProxyForwardsCookieRedirectAndBody(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/go" { w.Header().Set("Set-Cookie", "sid=abc; Path=/") w.Header().Set("Location", "/landed") w.WriteHeader(http.StatusFound) _, _ = w.Write([]byte("redirect-body")) return } w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("cookie=" + r.Header.Get("Cookie"))) })) t.Cleanup(upstream.Close) fixtures := t.TempDir() proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML()) srv := httptest.NewServer(proxy.Handler()) t.Cleanup(srv.Close) client := &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } req, err := http.NewRequest(http.MethodGet, srv.URL+"/go", nil) if err != nil { t.Fatal(err) } req.Header.Set(SessionHeader, "redir") req.Header.Set("Cookie", "keep=1") resp, err := client.Do(req) if err != nil { t.Fatal(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("status %d", resp.StatusCode) } if loc := resp.Header.Get("Location"); loc != "/landed" { t.Fatalf("location %q", loc) } if !strings.Contains(resp.Header.Get("Set-Cookie"), "sid=abc") { t.Fatalf("set-cookie %q", resp.Header.Get("Set-Cookie")) } if string(body) != "redirect-body" { t.Fatalf("body %q", body) } req2, err := http.NewRequest(http.MethodGet, srv.URL+"/echo", nil) if err != nil { t.Fatal(err) } req2.Header.Set(SessionHeader, "redir") req2.Header.Set("Cookie", "keep=1") resp2, err := client.Do(req2) if err != nil { t.Fatal(err) } got, _ := io.ReadAll(resp2.Body) resp2.Body.Close() if string(got) != "cookie=keep=1" { t.Fatalf("cookie not forwarded: %q", got) } } func TestProxyRejectsNonLoopbackOverflowAndCredentials(t *testing.T) { if _, err := NewProxy(ProxyConfig{ Listen: "0.0.0.0:8422", Upstream: "http://127.0.0.1:8423", Fixtures: t.TempDir(), Rules: mustParseRules(t, testRulesYAML()), }); err == nil || !strings.Contains(err.Error(), "loopback") { t.Fatalf("non-loopback listen: %v", err) } if _, err := NewProxy(ProxyConfig{ Listen: "127.0.0.1:8422", Upstream: "http://example.com", Fixtures: t.TempDir(), Rules: mustParseRules(t, testRulesYAML()), }); err == nil || !strings.Contains(err.Error(), "loopback") { t.Fatalf("non-loopback upstream: %v", err) } upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("hello-world")) })) t.Cleanup(upstream.Close) fixtures := t.TempDir() cfg := ProxyConfig{ Listen: "127.0.0.1:0", Upstream: upstream.URL, Fixtures: fixtures, Rules: mustParseRules(t, testRulesYAML()), MaxBody: 4, } proxy, err := NewProxy(cfg) if err != nil { t.Fatal(err) } srv := httptest.NewServer(proxy.Handler()) t.Cleanup(srv.Close) req, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil) if err != nil { t.Fatal(err) } req.Header.Set(SessionHeader, "overflow") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge && resp.StatusCode != http.StatusBadGateway && resp.StatusCode != http.StatusInternalServerError { if resp.StatusCode == http.StatusOK { t.Fatal("overflow recorded as success") } } overflowPath := filepath.Join(fixtures, "nuxt", "overflow.yaml") if _, statErr := os.Stat(overflowPath); !os.IsNotExist(statErr) { t.Fatalf("partial fixture committed: %v", statErr) } bigUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.abcabcabcabcabcabcab"}`)) })) t.Cleanup(bigUp.Close) secretFix := t.TempDir() secretProxy, err := NewProxy(ProxyConfig{ Listen: "127.0.0.1:0", Upstream: bigUp.URL, Fixtures: secretFix, Rules: mustParseRules(t, testRulesYAML()), }) if err != nil { t.Fatal(err) } secretSrv := httptest.NewServer(secretProxy.Handler()) t.Cleanup(secretSrv.Close) sreq, err := http.NewRequest(http.MethodGet, secretSrv.URL+"/sample", nil) if err != nil { t.Fatal(err) } sreq.Header.Set(SessionHeader, "secret") sresp, err := http.DefaultClient.Do(sreq) if err != nil { t.Fatal(err) } sresp.Body.Close() if _, statErr := os.Stat(filepath.Join(secretFix, "nuxt", "secret.yaml")); !os.IsNotExist(statErr) { t.Fatalf("credential fixture was committed: %v", statErr) } reqBad, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil) if err != nil { t.Fatal(err) } reqBad.Header.Set(SessionHeader, "alice/../bob") bresp, err := http.DefaultClient.Do(reqBad) if err != nil { t.Fatal(err) } bresp.Body.Close() if bresp.StatusCode == http.StatusOK { t.Fatal("path-separator session must fail") } inside := filepath.Join(fixtures, "vars.yaml") if _, err := NewProxy(ProxyConfig{ Listen: "127.0.0.1:0", Upstream: upstream.URL, Fixtures: fixtures, VarsPath: inside, Rules: mustParseRules(t, testRulesYAML()), }); err == nil || !strings.Contains(err.Error(), "outside") { t.Fatalf("vars inside fixtures: %v", err) } if err := os.WriteFile(inside, []byte("{}\n"), 0o600); err != nil { t.Fatal(err) } linkDir := t.TempDir() link := filepath.Join(linkDir, "vars-link.yaml") if err := os.Symlink(inside, link); err != nil { t.Fatal(err) } if _, err := NewProxy(ProxyConfig{ Listen: "127.0.0.1:0", Upstream: upstream.URL, Fixtures: fixtures, VarsPath: link, Rules: mustParseRules(t, testRulesYAML()), }); err == nil || !strings.Contains(err.Error(), "outside") { t.Fatalf("symlink vars into fixtures: %v", err) } outside := filepath.Join(t.TempDir(), "vars.yaml") p, err := NewProxy(ProxyConfig{ Listen: "127.0.0.1:0", Upstream: upstream.URL, Fixtures: fixtures, VarsPath: outside, Rules: mustParseRules(t, testRulesYAML()), }) if err != nil { t.Fatal(err) } _ = p st, err := os.Stat(outside) if err != nil { t.Fatal(err) } if st.Mode().Perm() != 0o600 { t.Fatalf("vars mode %o", st.Mode().Perm()) } } func TestProxyDuplicateSessionName(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte("ok")) })) t.Cleanup(upstream.Close) fixtures := t.TempDir() if err := os.MkdirAll(filepath.Join(fixtures, "nuxt"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(fixtures, "nuxt", "taken.yaml"), []byte("version: 1\n"), 0o644); err != nil { t.Fatal(err) } proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML()) srv := httptest.NewServer(proxy.Handler()) t.Cleanup(srv.Close) req, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil) if err != nil { t.Fatal(err) } req.Header.Set(SessionHeader, "taken") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Fatal("duplicate session must fail") } } func TestProxyFailedSessionLeavesNoFixture(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/ok" { _, _ = w.Write([]byte(`{"ok":true}`)) return } _, _ = w.Write([]byte(`{"token":"` + testJWT + `"}`)) })) t.Cleanup(upstream.Close) fixtures := t.TempDir() proxy := newTestProxy(t, upstream.URL, fixtures, testRulesYAML()) srv := httptest.NewServer(proxy.Handler()) t.Cleanup(srv.Close) doProxy(t, srv.URL, "partial", "/ok", "") req, err := http.NewRequest(http.MethodGet, srv.URL+"/leak", nil) if err != nil { t.Fatal(err) } req.Header.Set(SessionHeader, "partial") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if err := proxy.Flush(); err == nil { t.Fatal("failed session flush must surface the capture error") } if _, err := os.Stat(filepath.Join(fixtures, "nuxt", "partial.yaml")); !os.IsNotExist(err) { t.Fatalf("partial session fixture committed: %v", err) } } func newTestProxy(t *testing.T, upstream, fixtures, rulesYAML string) *Proxy { t.Helper() p, err := NewProxy(ProxyConfig{ Listen: "127.0.0.1:0", Upstream: upstream, Fixtures: fixtures, Rules: mustParseRules(t, rulesYAML), }) if err != nil { t.Fatal(err) } return p } func mustParseRules(t *testing.T, raw string) Rules { t.Helper() rules, err := ParseRules([]byte(raw)) if err != nil { t.Fatal(err) } return rules } func testRulesYAML() string { return "" + "client: nuxt\n" + "keep_request_headers:\n" + " - Cookie\n" + " - Content-Type\n" + "keep_response_headers:\n" + " - Content-Type\n" + " - Location\n" + " - Set-Cookie\n" + "routes:\n" + " - method: GET\n" + " path: /sample\n" + " - method: GET\n" + " path: /*\n" } func doProxy(t *testing.T, base, session, path, body string) { t.Helper() var rdr io.Reader if body != "" { rdr = strings.NewReader(body) } req, err := http.NewRequest(http.MethodGet, base+path, rdr) if err != nil { t.Fatal(err) } req.Header.Set(SessionHeader, session) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Fatalf("%s %s: %d %s", session, path, resp.StatusCode, b) } }