package tide import ( "context" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) const testJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSJ9.signaturehere123456" func TestCaptureAndPlaceholderResolution(t *testing.T) { store, err := OpenStore("") if err != nil { t.Fatal(err) } step := Step{ ID: "login", Response: Response{ Headers: map[string]string{"Content-Type": "application/json"}, Body: Body(`{"token":"` + testJWT + `"}`), }, Capture: []CaptureRule{{ From: "response.json", Path: "$.token", As: "jwt:alice", Identity: "alice", Category: "jwt", }}, } if err := CaptureStep(store, &step); err != nil { t.Fatal(err) } if err := ScrubStep(store, &step); err != nil { t.Fatal(err) } if !strings.Contains(string(step.Response.Body), "{{jwt:alice}}") { t.Fatalf("body not scrubbed: %s", step.Response.Body) } if strings.Contains(string(step.Response.Body), testJWT) { t.Fatal("jwt left in body") } got, err := store.Expand("Bearer {{jwt:alice}}") if err != nil { t.Fatal(err) } if got != "Bearer "+testJWT { t.Fatalf("expand: %q", got) } if _, err := store.Expand("{{missing}}"); err == nil || !strings.Contains(err.Error(), "unresolved") { t.Fatalf("missing placeholder: %v", err) } escaped := Step{ ID: "consent", Response: Response{ Body: Body(`{"data":{"redirect_to":"http:\/\/127.0.0.1:8424\/oauth\/callback?code=oauthCode99"}}`), }, Capture: []CaptureRule{{ From: "response.json", Path: "$.data.redirect_to", As: "oauth:redirect", Category: "oauth_code", }}, } if err := CaptureStep(store, &escaped); err != nil { t.Fatal(err) } if err := ScrubStep(store, &escaped); err != nil { t.Fatal(err) } if strings.Contains(string(escaped.Response.Body), "oauthCode99") { t.Fatalf("php-escaped redirect still has code: %s", escaped.Response.Body) } if !strings.Contains(string(escaped.Response.Body), "{{oauth:redirect}}") { t.Fatalf("php-escaped redirect not placeholder: %s", escaped.Response.Body) } codeStep := Step{ ID: "consent-code", Response: Response{ Body: Body(`{"data":{"redirect_to":"http://127.0.0.1:8424/oauth/callback?code=oauthCodeFromJSON"}}`), }, Capture: []CaptureRule{{ From: "response.json.query", Path: "$.data.redirect_to", Name: "code", As: "oauth:code", Category: "oauth_code", }}, } if err := CaptureStep(store, &codeStep); err != nil { t.Fatal(err) } gotCode, ok := store.Get("oauth:code") if !ok || gotCode != "oauthCodeFromJSON" { t.Fatalf("json.query code: ok=%v val=%q", ok, gotCode) } step2 := Step{ ID: "pkce", Request: Request{ Method: "POST", Path: "/authorize", Body: Body("code_verifier=pkceVerifierValue1&client_id=x"), }, Response: Response{ Status: 302, Headers: map[string]string{"Location": "/cb?code=oauthCode99"}, }, Capture: []CaptureRule{ {From: "request.form", Name: "code_verifier", As: "pkce:alice", Category: "pkce"}, {From: "response.location.query", Name: "code", As: "oauth:alice-code", Category: "oauth_code"}, }, } if err := CaptureStep(store, &step2); err != nil { t.Fatal(err) } if err := ScrubStep(store, &step2); err != nil { t.Fatal(err) } if !strings.Contains(string(step2.Request.Body), "{{pkce:alice}}") || strings.Contains(string(step2.Request.Body), "pkceVerifierValue1") { t.Fatalf("pkce not scrubbed: %s", step2.Request.Body) } if !strings.Contains(step2.Response.Headers["Location"], "{{oauth:alice-code}}") { t.Fatalf("code not scrubbed: %v", step2.Response.Headers) } } func TestScrubRejectsUnclassifiedAndPersistsVars(t *testing.T) { dir := t.TempDir() varsPath := filepath.Join(dir, "vars.yaml") store, err := OpenStore(varsPath) if err != nil { t.Fatal(err) } step := Step{ ID: "login", Response: Response{ Headers: map[string]string{"Content-Type": "application/json"}, Body: Body(`{"token":"` + testJWT + `"}`), }, Capture: []CaptureRule{{From: "response.json", Path: "$.token", As: "jwt:alice", Category: "jwt"}}, } if err := CaptureStep(store, &step); err != nil { t.Fatal(err) } if err := ScrubStep(store, &step); err != nil { t.Fatal(err) } if err := store.Save(); err != nil { t.Fatal(err) } raw, err := os.ReadFile(varsPath) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), testJWT) { t.Fatalf("vars missing secret:\n%s", raw) } st, err := os.Stat(varsPath) if err != nil { t.Fatal(err) } if st.Mode().Perm() != 0o600 { t.Fatalf("mode %o", st.Mode().Perm()) } unknown := Step{ ID: "bad", Response: Response{Body: Body(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJib2IifQ.otherSignatureValue99"}`)}, } if err := ScrubStep(store, &unknown); err == nil { t.Fatal("unclassified leftover jwt must fail") } } func TestProxyLoginTokenAndPKCESession(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/login": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"token":"` + testJWT + `"}`)) case r.URL.Path == "/me": if r.Header.Get("Authorization") != "Bearer "+testJWT { http.Error(w, "no", http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) case r.URL.Path == "/authorize": body, _ := io.ReadAll(r.Body) if !strings.Contains(string(body), "code_verifier=pkceVerifierValue1") { http.Error(w, "missing verifier", http.StatusBadRequest) return } w.Header().Set("Location", "/cb?code=oauthCode99") w.WriteHeader(http.StatusFound) default: http.NotFound(w, r) } })) 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"+ " - Content-Type\n"+ "keep_response_headers:\n"+ " - Content-Type\n"+ " - Location\n"+ "routes:\n"+ " - method: POST\n"+ " path: /login\n"+ " capture:\n"+ " - from: response.json\n"+ " path: $.token\n"+ " as: jwt:alice\n"+ " identity: alice\n"+ " category: jwt\n"+ " - method: GET\n"+ " path: /me\n"+ " - method: POST\n"+ " path: /authorize\n"+ " capture:\n"+ " - from: request.form\n"+ " name: code_verifier\n"+ " as: pkce:alice\n"+ " category: pkce\n"+ " - from: response.location.query\n"+ " name: code\n"+ " as: oauth:alice-code\n"+ " category: oauth_code\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", "sess", "application/json", `{}`) req, err := http.NewRequest(http.MethodGet, srv.URL+"/me", nil) if err != nil { t.Fatal(err) } req.Header.Set(SessionHeader, "sess") req.Header.Set("Authorization", "Bearer "+testJWT) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("me: %d", resp.StatusCode) } post(t, srv.URL+"/authorize", "sess", "application/x-www-form-urlencoded", "code_verifier=pkceVerifierValue1") if err := proxy.Flush(); err != nil { t.Fatal(err) } raw, err := os.ReadFile(filepath.Join(fixtures, "nuxt", "sess.yaml")) if err != nil { t.Fatal(err) } text := string(raw) if strings.Contains(text, testJWT) || strings.Contains(text, "pkceVerifierValue1") || strings.Contains(text, "oauthCode99") { t.Fatalf("secrets in fixture:\n%s", text) } if !strings.Contains(text, "{{jwt:alice}}") || !strings.Contains(text, "{{pkce:alice}}") || !strings.Contains(text, "{{oauth:alice-code}}") { t.Fatalf("placeholders missing:\n%s", text) } flow, err := LoadFlow(filepath.Join(fixtures, "nuxt", "sess.yaml")) if err != nil { t.Fatal(err) } store, err := OpenStore(varsPath) if err != nil { t.Fatal(err) } if _, err := ReplayFlow(context.Background(), flow, ReplayConfig{Target: upstream.URL, Store: store}); err != nil { t.Fatalf("replay captured session: %v", err) } } func post(t *testing.T, url, session, ct, body string) { t.Helper() req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set(SessionHeader, session) req.Header.Set("Content-Type", ct) client := &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode >= 400 { t.Fatalf("%s: %d", url, resp.StatusCode) } } func TestReplayExpandsUnquotedIDPlaceholdersAfterCapture(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/login": _, _ = w.Write([]byte(`{"token":"` + testJWT + `","user":{"id":1}}`)) case "/genres": _, _ = w.Write([]byte(`{"data":[{"id":1,"name":"Rock","album_count":0}]}`)) default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) flow := Flow{ Version: 1, Name: "seed-then-list", Steps: []Step{ { ID: "login", Request: Request{Method: http.MethodPost, Path: "/login"}, Response: Response{ Status: 200, Headers: jsonCT(), Body: Body(`{"token":"{{jwt:alice}}","user":{"id":{{id:alice}}}}`), }, Capture: []CaptureRule{ {From: "response.json", Path: "$.token", As: "jwt:alice", Category: "jwt"}, {From: "response.json", Path: "$.user.id", As: "id:alice"}, }, }, { ID: "genres", Request: Request{Method: http.MethodGet, Path: "/genres"}, Response: Response{ Status: 200, Headers: jsonCT(), Body: Body(`{"data":[{"id":{{id:alice}},"name":"Rock","album_count":0}]}`), }, }, }, } if _, err := ReplayFlow(context.Background(), flow, ReplayConfig{Target: srv.URL}); err != nil { t.Fatalf("replay with unquoted id placeholders: %v", err) } } func TestScrubShortNumericIDsDoNotCorruptPaths(t *testing.T) { store, err := OpenStore("") if err != nil { t.Fatal(err) } store.Set("id:alice", "1") store.Set("id:genre", "4") step := Step{ ID: "genres", Request: Request{ Method: http.MethodGet, Path: "/_fonoteka/api/v1/genres", Headers: map[string]string{"Authorization": "Bearer x"}, }, Response: Response{ Status: 200, Body: Body(`{"data":[{"id":1,"name":"Rock","album_count":0},{"id":15,"name":"Latin"},{"id":4,"name":"Jazz","album_count":1}]}`), }, } if err := ScrubStep(store, &step); err != nil { t.Fatal(err) } if step.Request.Path != "/_fonoteka/api/v1/genres" { t.Fatalf("path corrupted: %s", step.Request.Path) } body := string(step.Response.Body) if !strings.Contains(body, `"id":{{id:alice}}`) { t.Fatalf("id 1 not isolated: %s", body) } if !strings.Contains(body, `"id":15`) { t.Fatalf("id 15 must stay intact: %s", body) } if !strings.Contains(body, `"id":{{id:genre}}`) { t.Fatalf("id 4 not isolated: %s", body) } if strings.Contains(body, "{{id:alice}}5") || strings.Contains(body, "v{{id:alice}}") { t.Fatalf("substring replace leaked: %s", body) } } func TestScrubFormFieldDespiteSubstringSecrets(t *testing.T) { store, err := OpenStore("") if err != nil { t.Fatal(err) } code := "overlapSECRET99" verifier := "xx" + code + "yyPKCEverifierValue" store.Set("oauth:code", code) step := Step{ ID: "20", Request: Request{ Method: "POST", Path: "/oauth/mcp/token", Body: Body("grant_type=authorization_code&code=" + code + "&code_verifier=" + verifier), }, Response: Response{Status: 200, Body: Body(`{"ok":true}`)}, Capture: []CaptureRule{ {From: "request.form", Name: "code_verifier", As: "pkce:mcp", Category: "pkce"}, {From: "request.form", Name: "code", As: "oauth:code", Category: "oauth_code"}, }, } if err := CaptureStep(store, &step); err != nil { t.Fatal(err) } if err := ScrubStep(store, &step); err != nil { t.Fatal(err) } got := string(step.Request.Body) if strings.Contains(got, verifier) || strings.Contains(got, code) { t.Fatalf("form still live: %s", got) } if !strings.Contains(got, "code_verifier={{pkce:mcp}}") { t.Fatalf("verifier placeholder: %s", got) } if !strings.Contains(got, "code={{oauth:code}}") { t.Fatalf("code placeholder: %s", got) } }