diff --git a/cmd/summer/parity_contract_test.go b/cmd/summer/parity_contract_test.go new file mode 100644 index 0000000..2121d24 --- /dev/null +++ b/cmd/summer/parity_contract_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/tide" +) + +func TestParityCommandContract(t *testing.T) { + names := commandNames() + for _, want := range []string{"parity:record", "parity:proxy", "parity:replay"} { + if !containsName(names, want) { + t.Fatalf("missing %s in %v", want, names) + } + } + + spec := filepath.Join("..", "..", "tide", "testdata", "one-route-spec.yaml") + outDir := t.TempDir() + fixture := filepath.Join(outDir, "sample.yaml") + const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSJ9.signaturehere123456" + const inv = "inv_abcd1234xyz" + + orig := httptest.NewServer(jsonHandler(`{"data":"ok","token":"` + jwt + `"}`)) + t.Cleanup(orig.Close) + changed := httptest.NewServer(jsonHandler(`{"data":"no","token":"` + jwt + `"}`)) + t.Cleanup(changed.Close) + + varsPath := filepath.Join(t.TempDir(), "vars.yaml") + rulesPath := filepath.Join(outDir, "rules.yaml") + if err := os.WriteFile(rulesPath, []byte("client: nuxt\nkeep_request_headers: []\nkeep_response_headers:\n - Content-Type\nroutes:\n - method: GET\n path: /sample\n capture:\n - from: response.json\n path: $.token\n as: jwt:alice\n category: jwt\n"), 0o644); err != nil { + t.Fatal(err) + } + + out, err := runParityCapture("parity:record", "--spec", spec, "--target", orig.URL, "--output", fixture, "--rules", rulesPath, "--vars", varsPath) + if err != nil { + t.Fatalf("record: %v\n%s", err, out) + } + raw, err := os.ReadFile(fixture) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if strings.Contains(text, jwt) || strings.Contains(text, inv) { + t.Fatalf("recorded YAML leaked secret:\n%s", text) + } + if !strings.Contains(text, "{{jwt:alice}}") { + t.Fatalf("recorded YAML missing placeholder:\n%s", text) + } + assertNoSecrets(t, out+"\n"+text, jwt, inv) + + out, err = runParityCapture("parity:replay", "--fixtures", fixture, "--target", orig.URL, "--vars", varsPath) + if err != nil { + t.Fatalf("replay identical: %v\n%s", err, out) + } + + out, err = runParityCapture("parity:replay", "--fixtures", fixture, "--target", changed.URL, "--vars", varsPath) + if err == nil { + t.Fatal("changed JSON must fail with nonzero exit") + } + combined := out + "\n" + err.Error() + if !strings.Contains(err.Error(), "$.data") { + t.Fatalf("mismatch missing $.data: %s", combined) + } + assertNoSecrets(t, combined, jwt, inv) + + out, err = runParityCapture("parity:record", "--spec", spec, "--target", orig.URL) + if err == nil || !strings.Contains(err.Error(), "--output") { + t.Fatalf("missing output must fail: %v %s", err, out) + } + assertNoSecrets(t, out+"\n"+errString(err), jwt, inv) + + out, err = runParityCapture("parity:proxy", "--rules", rulesPath, "--fixtures", outDir, "--upstream", "http://example.com") + if err == nil || !strings.Contains(err.Error(), "loopback") { + t.Fatalf("proxy non-loopback: %v %s", err, out) + } + assertNoSecrets(t, out+"\n"+errString(err), jwt, inv) + + missing := filepath.Join(outDir, "missing-var.yaml") + if err := os.WriteFile(missing, []byte("version: 1\nname: miss\nsteps:\n - id: a\n request:\n method: GET\n path: /x/{{missing}}\n response:\n status: 200\n"), 0o644); err != nil { + t.Fatal(err) + } + out, err = runParityCapture("parity:replay", "--fixtures", missing, "--target", orig.URL) + if err == nil || !strings.Contains(err.Error(), "unresolved") && !strings.Contains(err.Error(), "placeholder") { + t.Fatalf("missing placeholder: %v %s", err, out) + } + + unclassified := httptest.NewServer(jsonHandler(`{"token":"` + jwt + `"}`)) + t.Cleanup(unclassified.Close) + leakOut := filepath.Join(outDir, "should-not-exist.yaml") + varsForReject := filepath.Join(t.TempDir(), "reject.yaml") + out, err = runParityCapture("parity:record", "--spec", spec, "--target", unclassified.URL, "--output", leakOut, "--vars", varsForReject) + if err == nil { + t.Fatal("unclassified jwt must fail record") + } + assertNoSecrets(t, out+"\n"+err.Error(), jwt, inv) + if _, statErr := os.Stat(leakOut); !os.IsNotExist(statErr) { + t.Fatalf("unclassified jwt committed fixture: %v", statErr) + } +} + +func runParityCapture(args ...string) (string, error) { + var buf bytes.Buffer + root, err := bonfire.NewRootIO("summer", toolCommands(), bytes.NewReader(nil), &buf, &buf) + if err != nil { + return buf.String(), err + } + root.SetArgs(args) + err = root.Execute() + return buf.String(), err +} + +func assertNoSecrets(t *testing.T, text string, secrets ...string) { + t.Helper() + for _, secret := range secrets { + if secret != "" && strings.Contains(text, secret) { + t.Fatalf("secret %q leaked into CLI output:\n%s", secret, text) + } + } + if strings.Contains(text, "auth_token=") && !strings.Contains(text, "{{") { + t.Fatalf("auth_token cookie leaked:\n%s", text) + } +} + +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func TestParityCommandContractUnknownCredential(t *testing.T) { + store, err := tide.OpenStore("") + if err != nil { + t.Fatal(err) + } + step := tide.Step{ + ID: "bad", + Request: tide.Request{ + Method: http.MethodGet, + Path: "/x", + Headers: map[string]string{"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaa.bbb"}, + }, + Response: tide.Response{Status: 200, Body: tide.Body(`{"ok":true}`)}, + } + if err := tide.ScrubStep(store, &step); err == nil { + t.Fatal("unknown leftover jwt must fail") + } else if strings.Contains(err.Error(), "eyJ") { + t.Fatalf("scrub error leaked jwt: %v", err) + } +} diff --git a/tide/proxy_security_test.go b/tide/proxy_security_test.go new file mode 100644 index 0000000..fcbcff9 --- /dev/null +++ b/tide/proxy_security_test.go @@ -0,0 +1,427 @@ +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) + } + }) +}