package tide import ( "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" ) func TestProxySecurity(t *testing.T) { t.Run("rejects non-loopback bind and client-selected upstream", func(t *testing.T) { fixtures := t.TempDir() rules := mustParseRules(t, testRulesYAML()) if _, err := NewProxy(ProxyConfig{Listen: "0.0.0.0:8422", Upstream: DefaultUpstream, Fixtures: fixtures, Rules: rules}); err == nil || !strings.Contains(err.Error(), "loopback") { t.Fatalf("non-loopback listen: %v", err) } if _, err := NewProxy(ProxyConfig{Listen: DefaultListen, Upstream: "http://example.com", Fixtures: fixtures, Rules: rules}); err == nil || !strings.Contains(err.Error(), "loopback") { t.Fatalf("non-loopback upstream: %v", err) } if _, err := NewProxy(ProxyConfig{Listen: DefaultListen, Upstream: "https://127.0.0.1:8423", Fixtures: fixtures, Rules: rules}); err == nil || !strings.Contains(err.Error(), "http") { t.Fatalf("https upstream: %v", err) } 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", "text/plain") _, _ = w.Write([]byte("ok")) })) t.Cleanup(upstream.Close) 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.Host = "evil.example" req.Header.Set(SessionHeader, "pin") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status %d", resp.StatusCode) } wantHost := strings.TrimPrefix(upstream.URL, "http://") if len(seenHost) != 1 || seenHost[0] != wantHost { t.Fatalf("upstream host %v want %s", seenHost, wantHost) } }) t.Run("request and response size caps leave no fixture", func(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("hello-world")) })) t.Cleanup(upstream.Close) fixtures := t.TempDir() proxy, err := NewProxy(ProxyConfig{ Listen: "127.0.0.1:0", Upstream: upstream.URL, Fixtures: fixtures, Rules: mustParseRules(t, testRulesYAML()), MaxBody: 4, }) if err != nil { t.Fatal(err) } srv := httptest.NewServer(proxy.Handler()) t.Cleanup(srv.Close) req, err := http.NewRequest(http.MethodPost, srv.URL+"/sample", strings.NewReader("12345")) if err != nil { t.Fatal(err) } req.Header.Set(SessionHeader, "reqcap") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("request overflow status %d", resp.StatusCode) } if _, err := os.Stat(filepath.Join(fixtures, "nuxt", "reqcap.yaml")); !os.IsNotExist(err) { t.Fatalf("request overflow committed fixture: %v", err) } get, err := http.NewRequest(http.MethodGet, srv.URL+"/sample", nil) if err != nil { t.Fatal(err) } get.Header.Set(SessionHeader, "respcap") gresp, err := http.DefaultClient.Do(get) if err != nil { t.Fatal(err) } gresp.Body.Close() if gresp.StatusCode == http.StatusOK { t.Fatal("response overflow recorded as success") } if _, err := os.Stat(filepath.Join(fixtures, "nuxt", "respcap.yaml")); !os.IsNotExist(err) { t.Fatalf("response overflow committed fixture: %v", err) } }) t.Run("cookie redirect passthrough and concurrent sessions", func(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.Write([]byte("cookie=" + r.Header.Get("Cookie") + " 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) 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 || resp.Header.Get("Location") != "/landed" { t.Fatalf("redirect %d %s", resp.StatusCode, resp.Header.Get("Location")) } if !strings.Contains(resp.Header.Get("Set-Cookie"), "sid=abc") || string(body) != "redirect-body" { t.Fatalf("cookie/body passthrough cookie=%q body=%q", resp.Header.Get("Set-Cookie"), body) } var wg sync.WaitGroup errCh := make(chan error, 2) for _, name := range []string{"alpha", "beta"} { wg.Add(1) go func(session string) { defer wg.Done() req, err := http.NewRequest(http.MethodGet, srv.URL+"/"+session, nil) if err != nil { errCh <- err return } req.Header.Set(SessionHeader, session) resp, err := http.DefaultClient.Do(req) if err != nil { errCh <- err return } resp.Body.Close() if resp.StatusCode != http.StatusOK { errCh <- fmt.Errorf("session %s status %d", session, resp.StatusCode) } }(name) } wg.Wait() close(errCh) for err := range errCh { if err != nil { t.Fatal(err) } } if err := proxy.Flush(); err != nil { t.Fatal(err) } alpha, err := LoadFlow(filepath.Join(fixtures, "nuxt", "alpha.yaml")) if err != nil { t.Fatal(err) } beta, err := LoadFlow(filepath.Join(fixtures, "nuxt", "beta.yaml")) if err != nil { t.Fatal(err) } if len(alpha.Steps) != 1 || alpha.Steps[0].Request.Path != "/alpha" { t.Fatalf("alpha: %+v", alpha.Steps) } if len(beta.Steps) != 1 || beta.Steps[0].Request.Path != "/beta" { t.Fatalf("beta: %+v", beta.Steps) } }) t.Run("path traversal session and sidecar digest", func(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) t.Cleanup(upstream.Close) fixtures := t.TempDir() 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, "../escape") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Fatal("traversal session must fail") } if entries, _ := os.ReadDir(filepath.Dir(fixtures)); len(entries) == 0 { t.Fatal("temp fixtures dir vanished") } unsafe := "version: 1\nname: bin\nsteps:\n - id: a\n request:\n method: GET\n path: /bin\n response:\n body_file: ../secret.bin\n" p := filepath.Join(fixtures, "unsafe.yaml") if err := os.WriteFile(p, []byte(unsafe), 0o644); err != nil { t.Fatal(err) } if _, err := LoadFlow(p); err == nil { t.Fatal("parent sidecar must fail") } }) t.Run("scrubs credentials across header cookie query json and form", func(t *testing.T) { const ( invTok = "inv_abcd1234xyz" secret = "superSecretValue99" code = "oauthCodeValue99" cookieV = "cookieSecretValue99" ) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/login": w.Header().Set("Content-Type", "application/json") w.Header().Set("Set-Cookie", "auth_token="+testJWT) _, _ = w.Write([]byte(`{"token":"` + testJWT + `"}`)) case r.URL.Path == "/token": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) case r.URL.Path == "/authorize": w.Header().Set("Location", "/cb?code="+code) w.WriteHeader(http.StatusFound) default: w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) } })) t.Cleanup(upstream.Close) fixtures := t.TempDir() varsPath := filepath.Join(t.TempDir(), "vars.yaml") rules := mustParseRules(t, ""+ "client: nuxt\n"+ "keep_request_headers:\n"+ " - Authorization\n"+ " - Cookie\n"+ " - Content-Type\n"+ "keep_response_headers:\n"+ " - Content-Type\n"+ " - Location\n"+ " - Set-Cookie\n"+ "routes:\n"+ " - method: POST\n"+ " path: /login\n"+ " capture:\n"+ " - from: response.json\n"+ " path: $.token\n"+ " as: jwt:alice\n"+ " category: jwt\n"+ " - method: GET\n"+ " path: /me\n"+ " - method: GET\n"+ " path: /api\n"+ " capture:\n"+ " - from: request.query\n"+ " name: token\n"+ " as: token:mcp-read\n"+ " category: token\n"+ " - method: POST\n"+ " path: /token\n"+ " capture:\n"+ " - from: request.form\n"+ " name: client_secret\n"+ " as: oauth:secret\n"+ " category: oauth_secret\n"+ " - method: GET\n"+ " path: /authorize\n"+ " capture:\n"+ " - from: response.location.query\n"+ " name: code\n"+ " as: oauth:code\n"+ " category: oauth_code\n"+ " - method: GET\n"+ " path: /cookie\n"+ " capture:\n"+ " - from: request.header\n"+ " name: Cookie\n"+ " as: cookie:auth\n"+ " category: cookie\n") proxy, err := NewProxy(ProxyConfig{ Listen: "127.0.0.1:0", Upstream: upstream.URL, Fixtures: fixtures, VarsPath: varsPath, Rules: rules, }) if err != nil { t.Fatal(err) } srv := httptest.NewServer(proxy.Handler()) t.Cleanup(srv.Close) post(t, srv.URL+"/login", "creds", "application/json", `{}`) me, err := http.NewRequest(http.MethodGet, srv.URL+"/me", nil) if err != nil { t.Fatal(err) } me.Header.Set(SessionHeader, "creds") me.Header.Set("Authorization", "Bearer "+testJWT) meResp, err := http.DefaultClient.Do(me) if err != nil { t.Fatal(err) } meResp.Body.Close() q, err := http.NewRequest(http.MethodGet, srv.URL+"/api?token="+invTok, nil) if err != nil { t.Fatal(err) } q.Header.Set(SessionHeader, "creds") qResp, err := http.DefaultClient.Do(q) if err != nil { t.Fatal(err) } qResp.Body.Close() post(t, srv.URL+"/token", "creds", "application/x-www-form-urlencoded", "client_secret="+secret) get, err := http.NewRequest(http.MethodGet, srv.URL+"/authorize", nil) if err != nil { t.Fatal(err) } get.Header.Set(SessionHeader, "creds") client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} aResp, err := client.Do(get) if err != nil { t.Fatal(err) } aResp.Body.Close() ck, err := http.NewRequest(http.MethodGet, srv.URL+"/cookie", nil) if err != nil { t.Fatal(err) } ck.Header.Set(SessionHeader, "creds") ck.Header.Set("Cookie", "auth_token="+cookieV) cResp, err := http.DefaultClient.Do(ck) if err != nil { t.Fatal(err) } cResp.Body.Close() if err := proxy.Flush(); err != nil { t.Fatal(err) } raw, err := os.ReadFile(filepath.Join(fixtures, "nuxt", "creds.yaml")) if err != nil { t.Fatal(err) } text := string(raw) for _, secret := range []string{testJWT, invTok, secret, code, cookieV, "auth_token=" + cookieV} { if strings.Contains(text, secret) { t.Fatalf("secret %q leaked into YAML:\n%s", secret, text) } } for _, ph := range []string{"{{jwt:alice}}", "{{token:mcp-read}}", "{{oauth:secret}}", "{{oauth:code}}", "{{cookie:auth}}"} { if !strings.Contains(text, ph) { t.Fatalf("missing placeholder %s:\n%s", ph, text) } } unclassified := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"token":"` + testJWT + `"}`)) })) t.Cleanup(unclassified.Close) badFix := t.TempDir() badProxy, err := NewProxy(ProxyConfig{ Listen: "127.0.0.1:0", Upstream: unclassified.URL, Fixtures: badFix, Rules: mustParseRules(t, testRulesYAML()), }) if err != nil { t.Fatal(err) } badSrv := httptest.NewServer(badProxy.Handler()) t.Cleanup(badSrv.Close) breq, err := http.NewRequest(http.MethodGet, badSrv.URL+"/sample", nil) if err != nil { t.Fatal(err) } breq.Header.Set(SessionHeader, "leak") bresp, err := http.DefaultClient.Do(breq) if err != nil { t.Fatal(err) } bresp.Body.Close() if _, err := os.Stat(filepath.Join(badFix, "nuxt", "leak.yaml")); !os.IsNotExist(err) { t.Fatalf("unclassified jwt fixture committed: %v", err) } }) }