From 5994e671b3e276eb85b20b3a8befab07d22d973c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 17 Sep 2026 14:39:45 +0200 Subject: [PATCH] fix(02): stop short ids rewriting pagination and IPv4 (CR-01) Co-authored-by: Cursor --- tide/capture_test.go | 42 +++++++++++++++++++++++++++++++----------- tide/variables.go | 38 ++++++++++++++++++++++++++++---------- 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/tide/capture_test.go b/tide/capture_test.go index 63aab7d..a6bd655 100644 --- a/tide/capture_test.go +++ b/tide/capture_test.go @@ -375,7 +375,7 @@ func TestScrubShortNumericIDsDoNotCorruptPaths(t *testing.T) { ID: "genres", Request: Request{ Method: http.MethodGet, - Path: "/_fonoteka/api/v1/genres", + Path: "/_fonoteka/api/v1/genres/1", Headers: map[string]string{"Authorization": "Bearer x"}, }, Response: Response{ @@ -386,21 +386,41 @@ func TestScrubShortNumericIDsDoNotCorruptPaths(t *testing.T) { 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) + 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 !strings.Contains(body, `"id":{{id:alice}}`) { - t.Fatalf("id 1 not isolated: %s", 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(body, `"id":15`) { - t.Fatalf("id 15 must stay intact: %s", body) + if strings.Contains(string(step.Request.Path), "v{{id:alice}}") { + t.Fatalf("substring replace leaked: %s", step.Request.Path) } - if !strings.Contains(body, `"id":{{id:genre}}`) { - t.Fatalf("id 4 not isolated: %s", body) +} + +func TestScrubShortIDsLeavePaginationAndIPv4Literal(t *testing.T) { + store, err := OpenStore("") + if err != nil { + t.Fatal(err) } - if strings.Contains(body, "{{id:alice}}5") || strings.Contains(body, "v{{id:alice}}") { - t.Fatalf("substring replace leaked: %s", body) + 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) } } diff --git a/tide/variables.go b/tide/variables.go index 2106855..d0562d9 100644 --- a/tide/variables.go +++ b/tide/variables.go @@ -444,10 +444,12 @@ func ScrubStep(store *Store, step *Step) error { return nil } pairs := store.replacements() - step.Request.Path = replaceAll(step.Request.Path, pairs) - step.Request.Query = replaceAll(step.Request.Query, pairs) - step.Request.Headers = scrubMap(step.Request.Headers, pairs) - step.Request.Body = Body(replaceAll(string(step.Request.Body), pairs)) + // Short numeric IDs belong in path/query/headers (e.g. /albums/1). JSON + // bodies keep literal counts and IPv4; normalizeJSON already masks id/*_id. + step.Request.Path = replaceAll(step.Request.Path, pairs, true) + step.Request.Query = replaceAll(step.Request.Query, pairs, true) + step.Request.Headers = scrubMap(step.Request.Headers, pairs, true) + step.Request.Body = Body(replaceAll(string(step.Request.Body), pairs, false)) for _, rule := range step.Capture { if strings.TrimSpace(rule.From) != "request.form" { continue @@ -457,8 +459,8 @@ func ScrubStep(store *Store, step *Step) error { } step.Request.Body = Body(scrubFormField(string(step.Request.Body), rule.Name, rule.As)) } - step.Response.Headers = scrubMap(step.Response.Headers, pairs) - step.Response.Body = Body(replaceAll(string(step.Response.Body), pairs)) + step.Response.Headers = scrubMap(step.Response.Headers, pairs, true) + step.Response.Body = Body(replaceAll(string(step.Response.Body), pairs, false)) return rejectUnclassifiedCredentials(*step) } @@ -484,22 +486,25 @@ func (s *Store) replacements() [][2]string { return out } -func scrubMap(in map[string]string, pairs [][2]string) map[string]string { +func scrubMap(in map[string]string, pairs [][2]string, allowShortNumeric bool) map[string]string { if in == nil { return nil } out := make(map[string]string, len(in)) for k, v := range in { - out[k] = replaceAll(v, pairs) + out[k] = replaceAll(v, pairs, allowShortNumeric) } return out } -func replaceAll(s string, pairs [][2]string) string { +func replaceAll(s string, pairs [][2]string, allowShortNumeric bool) string { for _, p := range pairs { if p[0] == "" { continue } + if !allowShortNumeric && isAllDigits(p[0]) && len(p[0]) < 8 { + continue + } olds := []string{p[0]} if esc := phpJSONEscape(p[0]); esc != p[0] { olds = append(olds, esc) @@ -515,6 +520,18 @@ func replaceAll(s string, pairs [][2]string) string { return s } +func isAllDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + func phpJSONEscape(s string) string { return strings.ReplaceAll(s, "/", `\/`) } @@ -556,7 +573,8 @@ func replaceIsolated(s, old, neu string) string { } func isIdentByte(c byte) bool { - return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || c == '_' || c == '.' } func rejectUnclassifiedCredentials(step Step) error {