513 lines
15 KiB
Go
513 lines
15 KiB
Go
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/1",
|
|
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/{{id:alice}}" {
|
|
t.Fatalf("path id not scrubbed: %s", step.Request.Path)
|
|
}
|
|
body := string(step.Response.Body)
|
|
if body != `{"data":[{"id":1,"name":"Rock","album_count":0},{"id":15,"name":"Latin"},{"id":4,"name":"Jazz","album_count":1}]}` {
|
|
t.Fatalf("JSON body short ids must stay literal: %s", body)
|
|
}
|
|
if strings.Contains(string(step.Request.Path), "v{{id:alice}}") {
|
|
t.Fatalf("substring replace leaked: %s", step.Request.Path)
|
|
}
|
|
}
|
|
|
|
func TestScrubShortIDsLeavePaginationAndIPv4Literal(t *testing.T) {
|
|
store, err := OpenStore("")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.Set("id:album", "1")
|
|
step := Step{
|
|
ID: "page",
|
|
Request: Request{Method: http.MethodGet, Path: "/albums/1"},
|
|
Response: Response{
|
|
Status: 200,
|
|
Body: Body(`{"id":1,"total":1,"host":"127.0.0.1"}`),
|
|
},
|
|
}
|
|
if err := ScrubStep(store, &step); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if step.Request.Path != "/albums/{{id:album}}" {
|
|
t.Fatalf("path id not scrubbed: %s", step.Request.Path)
|
|
}
|
|
body := string(step.Response.Body)
|
|
if body != `{"id":1,"total":1,"host":"127.0.0.1"}` {
|
|
t.Fatalf("pagination/IPv4 rewritten: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestScrubRejectsUnknownPasswordKeepsAllowlist(t *testing.T) {
|
|
store, err := OpenStore("")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
allowed := Step{
|
|
ID: "login",
|
|
Request: Request{Method: http.MethodPost, Path: "/login", Body: Body(`{"email":"alice@parity.test","password":"parity-alice-pass"}`)},
|
|
Response: Response{Status: 200, Body: Body(`{"ok":true}`)},
|
|
}
|
|
if err := ScrubStep(store, &allowed); err != nil {
|
|
t.Fatalf("allow-listed test password: %v", err)
|
|
}
|
|
leaked := Step{
|
|
ID: "login",
|
|
Request: Request{Method: http.MethodPost, Path: "/login", Body: Body(`{"email":"alice@parity.test","password":"hunter2-live"}`)},
|
|
Response: Response{Status: 200, Body: Body(`{"ok":true}`)},
|
|
}
|
|
if err := ScrubStep(store, &leaked); err == nil || !strings.Contains(err.Error(), "password") {
|
|
t.Fatalf("unknown password: %v", err)
|
|
}
|
|
opaque := Step{
|
|
ID: "tok",
|
|
Response: Response{Status: 200, Body: Body(`{"access_token":"not-a-jwt-but-secret"}`)},
|
|
}
|
|
if err := ScrubStep(store, &opaque); err == nil || !strings.Contains(err.Error(), "access_token") {
|
|
t.Fatalf("opaque access_token: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMismatchErrorRedactsJWT(t *testing.T) {
|
|
err := &MismatchError{Result: Result{Steps: []StepResult{{
|
|
ID: "t",
|
|
Diffs: []Diff{{
|
|
Path: "$.token",
|
|
Expected: testJWT,
|
|
Actual: testJWT + "x",
|
|
}},
|
|
}}}}
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "eyJ") {
|
|
t.Fatalf("mismatch leaked jwt: %s", msg)
|
|
}
|
|
if !strings.Contains(msg, "<redacted-jwt>") {
|
|
t.Fatalf("expected redaction: %s", msg)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|