fix(02): stop short ids rewriting pagination and IPv4 (CR-01)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user