diff --git a/tide/capture_test.go b/tide/capture_test.go index 7148a83..2fc761e 100644 --- a/tide/capture_test.go +++ b/tide/capture_test.go @@ -124,7 +124,7 @@ func TestScrubRejectsUnclassifiedAndPersistsVars(t *testing.T) { t.Fatalf("mode %o", st.Mode().Perm()) } unknown := Step{ - ID: "bad", + ID: "bad", Response: Response{Body: Body(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJib2IifQ.otherSignatureValue99"}`)}, } if err := ScrubStep(store, &unknown); err == nil { @@ -271,3 +271,43 @@ func post(t *testing.T, url, session, ct, body string) { t.Fatalf("%s: %d", url, resp.StatusCode) } } + +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) + } +} diff --git a/tide/variables.go b/tide/variables.go index 951a487..4ad93fa 100644 --- a/tide/variables.go +++ b/tide/variables.go @@ -456,11 +456,47 @@ func replaceAll(s string, pairs [][2]string) string { if p[0] == "" { continue } - s = strings.ReplaceAll(s, p[0], p[1]) + if len(p[0]) >= 8 { + s = strings.ReplaceAll(s, p[0], p[1]) + continue + } + s = replaceIsolated(s, p[0], p[1]) } return s } +func replaceIsolated(s, old, neu string) string { + if old == "" || s == "" { + return s + } + var b strings.Builder + i := 0 + for i < len(s) { + j := strings.Index(s[i:], old) + if j < 0 { + b.WriteString(s[i:]) + break + } + j += i + leftOK := j == 0 || !isIdentByte(s[j-1]) + right := j + len(old) + rightOK := right == len(s) || !isIdentByte(s[right]) + if leftOK && rightOK { + b.WriteString(s[i:j]) + b.WriteString(neu) + i = right + continue + } + b.WriteString(s[i : j+len(old)]) + i = j + len(old) + } + return b.String() +} + +func isIdentByte(c byte) bool { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' +} + func rejectUnclassifiedCredentials(step Step) error { check := func(label, s string) error { if hit := remainingCredential(s); hit != "" {