From 0849472dfe1cd74212b0f851dc46e187588afcdc Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Sun, 18 Jan 2026 16:06:46 -0800 Subject: [PATCH 1/3] Bump jsonschema dependency to v6.0.2 --- app/allowlist.go | 257 +----------------- app/allowlist_body_filter_test.go | 147 ++++------ app/allowlist_helpers_test.go | 82 +++++- app/allowlist_match_test.go | 173 ++++++------ app/allowlist_test.go | 84 +++--- app/allowlist_validate.go | 5 + app/allowlist_validate_test.go | 19 ++ app/body_schema.go | 64 +++++ app/denylist.go | 17 +- app/denylist_test.go | 100 ++++++- app/denylist_validate_test.go | 24 +- .../plugins/sendgrid/capabilities.go | 18 +- .../plugins/sendgrid/sendgrid_test.go | 26 +- .../plugins/slack/capabilities.go | 40 ++- app/integrations/plugins/slack/slack_test.go | 31 ++- app/match_form_test.go | 157 ----------- docs/allowlist-yaml.md | 32 +-- docs/configuration-overview.md | 26 +- docs/denylist-yaml.md | 8 +- docs/helm.md | 7 +- examples/denylist.yaml | 8 +- go.mod | 5 +- go.sum | 4 + 23 files changed, 608 insertions(+), 726 deletions(-) create mode 100644 app/body_schema.go delete mode 100644 app/match_form_test.go diff --git a/app/allowlist.go b/app/allowlist.go index 54b8b53..96d6da2 100644 --- a/app/allowlist.go +++ b/app/allowlist.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "fmt" "net/http" "net/url" @@ -201,11 +200,11 @@ func validateRequestReason(r *http.Request, c RequestConstraint) (bool, string) } ct := strings.ToLower(r.Header.Get("Content-Type")) if strings.Contains(ct, "application/json") { - var data map[string]interface{} - if err := json.Unmarshal(bodyBytes, &data); err != nil { + data, err := decodeJSONBody(bodyBytes) + if err != nil { return false, "invalid json" } - if ok, reason := matchBodyMapReason(data, c.Body); !ok { + if ok, reason := validateBodySchema(c.Body, data); !ok { return false, reason } return true, "" @@ -215,7 +214,8 @@ func validateRequestReason(r *http.Request, c RequestConstraint) (bool, string) if err != nil { return false, "invalid form encoding" } - if ok, reason := matchFormReason(vals, c.Body); !ok { + data := formValuesToJSON(vals) + if ok, reason := validateBodySchema(c.Body, data); !ok { return false, reason } return true, "" @@ -224,253 +224,6 @@ func validateRequestReason(r *http.Request, c RequestConstraint) (bool, string) return true, "" } -func matchForm(vals url.Values, rule map[string]interface{}) bool { - for k, v := range rule { - present, ok := vals[k] - if !ok { - return false - } - switch want := v.(type) { - case string: - found := false - for _, got := range present { - if got == want { - found = true - break - } - } - if !found { - return false - } - case []interface{}: - for _, elem := range want { - s, ok := elem.(string) - if !ok { - return false - } - found := false - for _, got := range present { - if got == s { - found = true - break - } - } - if !found { - return false - } - } - default: - return false - } - } - return true -} - -func matchFormReason(vals url.Values, rule map[string]interface{}) (bool, string) { - for k, v := range rule { - present, ok := vals[k] - if !ok { - return false, "missing form field " + k - } - switch want := v.(type) { - case string: - found := false - for _, got := range present { - if got == want { - found = true - break - } - } - if !found { - return false, fmt.Sprintf("form field %s=%s not found", k, want) - } - case []interface{}: - for _, elem := range want { - s, ok := elem.(string) - if !ok { - return false, "invalid rule" - } - found := false - for _, got := range present { - if got == s { - found = true - break - } - } - if !found { - return false, fmt.Sprintf("form field %s=%s not found", k, s) - } - } - default: - return false, "invalid rule" - } - } - return true, "" -} - -func matchQuery(vals url.Values, rule map[string][]string) bool { - for k, wantVals := range rule { - present, ok := vals[k] - if !ok { - return false - } - for _, want := range wantVals { - found := false - for _, got := range present { - if got == want { - found = true - break - } - } - if !found { - return false - } - } - } - return true -} - -func matchBodyMap(data map[string]interface{}, rule map[string]interface{}) bool { - return matchValue(data, rule) -} - -func matchBodyMapReason(data map[string]interface{}, rule map[string]interface{}) (bool, string) { - return matchValueReason(data, rule, "") -} - -func matchValueReason(data, rule interface{}, path string) (bool, string) { - switch rv := rule.(type) { - case map[string]interface{}: - dm, ok := data.(map[string]interface{}) - if !ok { - return false, fmt.Sprintf("body field %s not object", path) - } - for k, v := range rv { - dv, ok := dm[k] - p := k - if path != "" { - p = path + "." + k - } - if !ok { - return false, "missing body field " + p - } - if ok2, reason := matchValueReason(dv, v, p); !ok2 { - return false, reason - } - } - return true, "" - case []interface{}: - da, ok := data.([]interface{}) - if !ok { - return false, fmt.Sprintf("body field %s not array", path) - } - for _, want := range rv { - found := false - for _, elem := range da { - if ok2, _ := matchValueReason(elem, want, path); ok2 { - found = true - break - } - } - if !found { - return false, fmt.Sprintf("body field %s missing element", path) - } - } - return true, "" - default: - if df, ok := toFloat(data); ok { - if rf, ok2 := toFloat(rv); ok2 { - if df == rf { - return true, "" - } - } - } - if data == rule { - return true, "" - } - return false, fmt.Sprintf("body field %s value mismatch", path) - } -} - -func matchValue(data, rule interface{}) bool { - switch rv := rule.(type) { - case map[string]interface{}: - dm, ok := data.(map[string]interface{}) - if !ok { - return false - } - for k, v := range rv { - dv, ok := dm[k] - if !ok { - return false - } - if !matchValue(dv, v) { - return false - } - } - return true - case []interface{}: - da, ok := data.([]interface{}) - if !ok { - return false - } - for _, want := range rv { - found := false - for _, elem := range da { - if matchValue(elem, want) { - found = true - break - } - } - if !found { - return false - } - } - return true - default: - // YAML unmarshals numbers without decimals as ints while JSON - // decoding uses float64. Normalize numeric types so the values - // compare equal regardless of how they were parsed. - if df, ok := toFloat(data); ok { - if rf, ok2 := toFloat(rv); ok2 { - return df == rf - } - } - return data == rule - } -} - -func toFloat(v interface{}) (float64, bool) { - switch n := v.(type) { - case int: - return float64(n), true - case int8: - return float64(n), true - case int16: - return float64(n), true - case int32: - return float64(n), true - case int64: - return float64(n), true - case uint: - return float64(n), true - case uint8: - return float64(n), true - case uint16: - return float64(n), true - case uint32: - return float64(n), true - case uint64: - return float64(n), true - case float32: - return float64(n), true - case float64: - return n, true - default: - return 0, false - } -} - // findConstraint returns the RequestConstraint for the given caller, path and // method if one exists. func findConstraint(i *Integration, callerID, pth, method string) (RequestConstraint, bool) { diff --git a/app/allowlist_body_filter_test.go b/app/allowlist_body_filter_test.go index 67aafa4..3789fd6 100644 --- a/app/allowlist_body_filter_test.go +++ b/app/allowlist_body_filter_test.go @@ -5,8 +5,6 @@ import ( "net/http" "net/http/httptest" "testing" - - yaml "gopkg.in/yaml.v3" ) // helper to create request preserving body @@ -16,114 +14,75 @@ func req(method string, body []byte) *http.Request { return r } -func TestBodyArrayMatching(t *testing.T) { - body := []byte(`{"arr":[1,2,3]}`) - tests := []struct { - name string - rule map[string]interface{} - want bool - }{ - { - name: "exact", - rule: map[string]interface{}{"arr": []interface{}{float64(1), float64(2), float64(3)}}, - want: true, - }, - { - name: "subset", - rule: map[string]interface{}{"arr": []interface{}{float64(1), float64(3)}}, - want: true, - }, - { - name: "unordered subset", - rule: map[string]interface{}{"arr": []interface{}{float64(3), float64(1)}}, - want: true, - }, - { - name: "missing element", - rule: map[string]interface{}{"arr": []interface{}{float64(1), float64(4)}}, - want: false, +func TestBodySchemaPattern(t *testing.T) { + body := []byte(`{"channel":"allowed-test"}`) + rule := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "channel": map[string]interface{}{ + "type": "string", + "pattern": "^allowed-", + }, }, + "required": []interface{}{"channel"}, } - for _, tt := range tests { - r := req(http.MethodPost, body) - if got := validateRequest(r, RequestConstraint{Body: tt.rule}); got != tt.want { - t.Errorf("%s: got %v want %v", tt.name, got, tt.want) - } + r := req(http.MethodPost, body) + if !validateRequest(r, RequestConstraint{Body: rule}) { + t.Fatal("expected pattern to match") } -} -func TestBodyObjectMatching(t *testing.T) { - body := []byte(`{"foo":"bar","num":1,"extra":true}`) - tests := []struct { - name string - rule map[string]interface{} - want bool - }{ - { - name: "exact", - rule: map[string]interface{}{"foo": "bar", "num": float64(1), "extra": true}, - want: true, - }, - { - name: "subset", - rule: map[string]interface{}{"foo": "bar"}, - want: true, - }, - { - name: "missing", - rule: map[string]interface{}{"foo": "bar", "missing": "x"}, - want: false, - }, - } - for _, tt := range tests { - r := req(http.MethodPost, body) - if got := validateRequest(r, RequestConstraint{Body: tt.rule}); got != tt.want { - t.Errorf("%s: got %v want %v", tt.name, got, tt.want) - } + r2 := req(http.MethodPost, []byte(`{"channel":"blocked"}`)) + if validateRequest(r2, RequestConstraint{Body: rule}) { + t.Fatal("expected pattern mismatch to fail") } } -func TestBodyNestedMatching(t *testing.T) { - body := []byte(`{"obj":{"inner":"x","arr":[1,2]}}`) - tests := []struct { - name string - rule map[string]interface{} - want bool - }{ - { - name: "nested object", - rule: map[string]interface{}{"obj": map[string]interface{}{"inner": "x"}}, - want: true, - }, - { - name: "nested array subset", - rule: map[string]interface{}{"obj": map[string]interface{}{"arr": []interface{}{float64(2)}}}, - want: true, - }, - { - name: "nested fail", - rule: map[string]interface{}{"obj": map[string]interface{}{"inner": "y"}}, - want: false, +func TestBodySchemaRange(t *testing.T) { + body := []byte(`{"limit":10}`) + rule := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "limit": map[string]interface{}{ + "type": "integer", + "minimum": 1, + "maximum": 100, + }, }, + "required": []interface{}{"limit"}, + } + + r := req(http.MethodPost, body) + if !validateRequest(r, RequestConstraint{Body: rule}) { + t.Fatal("expected range to match") } - for _, tt := range tests { - r := req(http.MethodPost, body) - if got := validateRequest(r, RequestConstraint{Body: tt.rule}); got != tt.want { - t.Errorf("%s: got %v want %v", tt.name, got, tt.want) - } + + r2 := req(http.MethodPost, []byte(`{"limit":200}`)) + if validateRequest(r2, RequestConstraint{Body: rule}) { + t.Fatal("expected range mismatch to fail") } } -func TestBodyNumericTypeMismatch(t *testing.T) { - body := []byte(`{"num":1}`) - var rule map[string]interface{} - if err := yaml.Unmarshal([]byte("num: 1"), &rule); err != nil { - t.Fatal(err) +func TestBodySchemaMinLength(t *testing.T) { + body := []byte(`{"query":"hi"}`) + rule := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "minLength": 1, + }, + }, + "required": []interface{}{"query"}, } r := req(http.MethodPost, body) if !validateRequest(r, RequestConstraint{Body: rule}) { - t.Fatal("expected numeric types to match") + t.Fatal("expected minLength to match") + } + + r2 := req(http.MethodPost, []byte(`{"query":""}`)) + if validateRequest(r2, RequestConstraint{Body: rule}) { + t.Fatal("expected minLength mismatch to fail") } } diff --git a/app/allowlist_helpers_test.go b/app/allowlist_helpers_test.go index 59d58c1..7157e87 100644 --- a/app/allowlist_helpers_test.go +++ b/app/allowlist_helpers_test.go @@ -69,21 +69,61 @@ func TestValidateRequestTable(t *testing.T) { wantOK: false, }, { - name: "json body match", - r: buildReq(http.MethodPost, "http://x", "application/json", bodyJSON), - cons: RequestConstraint{Body: map[string]interface{}{"a": "b", "arr": []interface{}{float64(1)}}}, + name: "json body match", + r: buildReq(http.MethodPost, "http://x", "application/json", bodyJSON), + cons: RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + "const": "b", + }, + "arr": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "type": "integer", + }, + }, + }, + "required": []interface{}{"a"}, + }}, wantOK: true, }, { - name: "json body mismatch", - r: buildReq(http.MethodPost, "http://x", "application/json", []byte(`{"a":"x"}`)), - cons: RequestConstraint{Body: map[string]interface{}{"a": "b"}}, + name: "json body mismatch", + r: buildReq(http.MethodPost, "http://x", "application/json", []byte(`{"a":"x"}`)), + cons: RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + "const": "b", + }, + }, + "required": []interface{}{"a"}, + }}, wantOK: false, }, { - name: "form match", - r: buildReq(http.MethodPost, "http://x", "application/x-www-form-urlencoded", []byte(form.Encode())), - cons: RequestConstraint{Body: map[string]interface{}{"a": []interface{}{"1", "3"}, "b": "2"}}, + name: "form match", + r: buildReq(http.MethodPost, "http://x", "application/x-www-form-urlencoded", []byte(form.Encode())), + cons: RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "type": "string", + }, + "minItems": 2, + }, + "b": map[string]interface{}{ + "type": "string", + "const": "2", + }, + }, + "required": []interface{}{"a", "b"}, + }}, wantOK: true, }, { @@ -130,8 +170,17 @@ func TestValidateRequestReasonFailures(t *testing.T) { { name: "body mismatch", r: buildReq(http.MethodPost, "http://x", "application/json", body), - cons: RequestConstraint{Body: map[string]interface{}{"a": "2"}}, - want: "body field a value mismatch", + cons: RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + "const": "2", + }, + }, + "required": []interface{}{"a"}, + }}, + want: "body schema mismatch", }, } for _, tt := range tests { @@ -143,7 +192,16 @@ func TestValidateRequestReasonFailures(t *testing.T) { } func TestValidateRequestUnsupportedContentType(t *testing.T) { - cons := RequestConstraint{Body: map[string]interface{}{"a": "b"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + "const": "b", + }, + }, + "required": []interface{}{"a"}, + }} r := buildReq(http.MethodPost, "http://x", "text/plain", []byte("ignored")) if !validateRequest(r, cons) { t.Fatal("expected success for unsupported content type") diff --git a/app/allowlist_match_test.go b/app/allowlist_match_test.go index a6a1a02..edd73a4 100644 --- a/app/allowlist_match_test.go +++ b/app/allowlist_match_test.go @@ -63,7 +63,22 @@ func TestValidateRequestHeaders(t *testing.T) { func TestValidateRequestJSONBody(t *testing.T) { body := []byte(`{"a":"b","arr":[1,2]}`) r := newRequest(http.MethodPost, "http://x", "application/json", body) - cons := RequestConstraint{Body: map[string]interface{}{"a": "b", "arr": []interface{}{float64(1)}}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + "const": "b", + }, + "arr": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "type": "integer", + }, + }, + }, + "required": []interface{}{"a"}, + }} if !validateRequest(r, cons) { t.Fatal("expected body match") } @@ -72,7 +87,23 @@ func TestValidateRequestJSONBody(t *testing.T) { func TestValidateRequestFormBody(t *testing.T) { form := url.Values{"a": {"1", "3"}, "b": {"2"}} r := newRequest(http.MethodPost, "http://x", "application/x-www-form-urlencoded", []byte(form.Encode())) - cons := RequestConstraint{Body: map[string]interface{}{"a": []interface{}{"1", "3"}, "b": "2"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "type": "string", + }, + "minItems": 2, + }, + "b": map[string]interface{}{ + "type": "string", + "const": "2", + }, + }, + "required": []interface{}{"a", "b"}, + }} if !validateRequest(r, cons) { t.Fatal("expected form match") } @@ -93,7 +124,16 @@ func TestValidateRequestQuery(t *testing.T) { func TestValidateRequestBodyMismatch(t *testing.T) { body := []byte(`{"a":"x"}`) r := newRequest(http.MethodPost, "http://x", "application/json", body) - cons := RequestConstraint{Body: map[string]interface{}{"a": "b"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + "const": "b", + }, + }, + "required": []interface{}{"a"}, + }} if validateRequest(r, cons) { t.Fatal("expected body mismatch to fail") } @@ -101,7 +141,16 @@ func TestValidateRequestBodyMismatch(t *testing.T) { func TestValidateRequestUnknownContentType(t *testing.T) { r := newRequest(http.MethodPost, "http://x", "text/plain", []byte("ignored")) - cons := RequestConstraint{Body: map[string]interface{}{"foo": "bar"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "string", + "const": "bar", + }, + }, + "required": []interface{}{"foo"}, + }} if !validateRequest(r, cons) { t.Fatal("expected body check skipped on unknown content type") } @@ -125,21 +174,48 @@ func TestValidateRequestBodyErrors(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "http://x", nil) r.Body = errReadCloser{} r.Header.Set("Content-Type", "application/json") - if validateRequest(r, RequestConstraint{Body: map[string]interface{}{"a": "b"}}) { + if validateRequest(r, RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + "const": "b", + }, + }, + "required": []interface{}{"a"}, + }}) { t.Fatal("expected false on body read error") } // bad JSON r2 := httptest.NewRequest(http.MethodPost, "http://x", strings.NewReader("{")) r2.Header.Set("Content-Type", "application/json") - if validateRequest(r2, RequestConstraint{Body: map[string]interface{}{"a": "b"}}) { + if validateRequest(r2, RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + "const": "b", + }, + }, + "required": []interface{}{"a"}, + }}) { t.Fatal("expected false on json parse error") } // bad form encoding r3 := httptest.NewRequest(http.MethodPost, "http://x", strings.NewReader("%zz")) r3.Header.Set("Content-Type", "application/x-www-form-urlencoded") - if validateRequest(r3, RequestConstraint{Body: map[string]interface{}{"a": "1"}}) { + if validateRequest(r3, RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "string", + "const": "1", + }, + }, + "required": []interface{}{"a"}, + }}) { t.Fatal("expected false on form parse error") } @@ -170,86 +246,3 @@ func TestMatchSegmentsEdgeCases(t *testing.T) { } } } - -func TestToFloatVariousTypes(t *testing.T) { - cases := []struct { - val interface{} - want float64 - ok bool - }{ - {int(1), 1, true}, - {int8(2), 2, true}, - {int16(3), 3, true}, - {int32(4), 4, true}, - {int64(5), 5, true}, - {uint(6), 6, true}, - {uint8(7), 7, true}, - {uint16(8), 8, true}, - {uint32(9), 9, true}, - {uint64(10), 10, true}, - {float32(11.5), 11.5, true}, - {float64(12.5), 12.5, true}, - {"nope", 0, false}, - } - for i, tt := range cases { - got, ok := toFloat(tt.val) - if ok != tt.ok || (ok && got != tt.want) { - t.Errorf("case %d: toFloat(%T)=(%v,%v) want (%v,%v)", i, tt.val, got, ok, tt.want, tt.ok) - } - } -} - -func TestMatchBodyMapReasonSuccess(t *testing.T) { - data := map[string]interface{}{ - "a": "b", - "arr": []interface{}{float64(1), float64(2)}, - } - rule := map[string]interface{}{ - "a": "b", - "arr": []interface{}{float64(1)}, - } - ok, reason := matchBodyMapReason(data, rule) - if !ok || reason != "" { - t.Fatalf("expected success, got ok=%v reason=%q", ok, reason) - } -} - -func TestMatchBodyMapReasonMissingField(t *testing.T) { - data := map[string]interface{}{"a": "b"} - rule := map[string]interface{}{"a": "b", "c": "d"} - ok, reason := matchBodyMapReason(data, rule) - if ok || reason != "missing body field c" { - t.Fatalf("expected missing field failure, got ok=%v reason=%q", ok, reason) - } -} - -func TestMatchBodyMapReasonNestedMismatch(t *testing.T) { - data := map[string]interface{}{"a": map[string]interface{}{"b": "c"}} - rule := map[string]interface{}{"a": map[string]interface{}{"b": "d"}} - ok, reason := matchBodyMapReason(data, rule) - if ok || reason != "body field a.b value mismatch" { - t.Fatalf("expected mismatch failure, got ok=%v reason=%q", ok, reason) - } -} - -func TestMatchBodyMapSuccess(t *testing.T) { - data := map[string]interface{}{ - "a": "b", - "arr": []interface{}{float64(1), float64(2)}, - } - rule := map[string]interface{}{ - "a": "b", - "arr": []interface{}{float64(1)}, - } - if !matchBodyMap(data, rule) { - t.Fatal("expected matchBodyMap to succeed") - } -} - -func TestMatchBodyMapFailure(t *testing.T) { - data := map[string]interface{}{"a": "b"} - rule := map[string]interface{}{"a": "b", "c": "d"} - if matchBodyMap(data, rule) { - t.Fatal("expected matchBodyMap to fail") - } -} diff --git a/app/allowlist_test.go b/app/allowlist_test.go index 79eba30..763410b 100644 --- a/app/allowlist_test.go +++ b/app/allowlist_test.go @@ -3,7 +3,6 @@ package main import ( "net/http" "net/http/httptest" - "net/url" "reflect" "strings" "testing" @@ -318,8 +317,17 @@ func TestValidateRequestReasonFormMismatch(t *testing.T) { body := strings.NewReader("other=x") req := httptest.NewRequest(http.MethodPost, "http://example.com/", body) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - constraint := RequestConstraint{Body: map[string]interface{}{"field": "value"}} - if ok, reason := validateRequestReason(req, constraint); ok || reason == "" { + constraint := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "field": map[string]interface{}{ + "type": "string", + "const": "value", + }, + }, + "required": []interface{}{"field"}, + }} + if ok, reason := validateRequestReason(req, constraint); ok || !strings.Contains(reason, "body schema mismatch") { t.Fatalf("expected form validation failure, got ok=%v reason=%q", ok, reason) } } @@ -422,7 +430,16 @@ func TestFindConstraintCapability(t *testing.T) { func TestValidateRequestReasonJSONContentTypeCaseInsensitive(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://case/json", strings.NewReader(`{"foo":"bar"}`)) req.Header.Set("Content-Type", "Application/JSON; charset=utf-8") - cons := RequestConstraint{Body: map[string]interface{}{"foo": "bar"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "string", + "const": "bar", + }, + }, + "required": []interface{}{"foo"}, + }} if ok, reason := validateRequestReason(req, cons); !ok { t.Fatalf("expected JSON constraint to pass regardless of content type case: %s", reason) } @@ -431,56 +448,17 @@ func TestValidateRequestReasonJSONContentTypeCaseInsensitive(t *testing.T) { func TestValidateRequestReasonFormContentTypeCaseInsensitive(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://case/form", strings.NewReader("foo=bar")) req.Header.Set("Content-Type", "Application/X-Www-Form-Urlencoded") - cons := RequestConstraint{Body: map[string]interface{}{"foo": "bar"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "string", + "const": "bar", + }, + }, + "required": []interface{}{"foo"}, + }} if ok, reason := validateRequestReason(req, cons); !ok { t.Fatalf("expected form constraint to pass regardless of content type case: %s", reason) } } - -func TestMatchValueNotOkBranches(t *testing.T) { - if matchValue("not-a-map", map[string]interface{}{"a": 1}) { - t.Fatal("expected map type mismatch to fail") - } - if matchValue(map[string]interface{}{"a": 1}, map[string]interface{}{"a": 1, "b": 2}) { - t.Fatal("expected missing map key to fail") - } - if matchValue("not-an-array", []interface{}{"a"}) { - t.Fatal("expected array type mismatch to fail") - } -} - -func TestMatchValueReasonNotOkBranches(t *testing.T) { - if ok, reason := matchValueReason("not-a-map", map[string]interface{}{"a": 1}, ""); ok { - t.Fatalf("expected map type mismatch to fail, got reason: %s", reason) - } - if ok, reason := matchValueReason(map[string]interface{}{"a": 1}, map[string]interface{}{"a": 1, "b": 2}, ""); ok { - t.Fatalf("expected missing field to fail, got reason: %s", reason) - } - if ok, reason := matchValueReason("not-an-array", []interface{}{"a"}, "items"); ok { - t.Fatalf("expected array type mismatch to fail, got reason: %s", reason) - } - if ok, reason := matchValueReason([]interface{}{"x"}, []interface{}{"y"}, "items"); ok { - t.Fatalf("expected missing array element to fail, got reason: %s", reason) - } - if ok, reason := matchValueReason(map[string]interface{}{"a": "b"}, map[string]interface{}{"a": "c"}, ""); ok { - t.Fatalf("expected value mismatch to fail, got reason: %s", reason) - } -} - -func TestMatchFormReasonAndValidateRequestFailures(t *testing.T) { - vals := url.Values{"foo": {"bar"}} - if ok, reason := matchFormReason(vals, map[string]interface{}{"foo": "baz"}); ok { - t.Fatalf("expected form value mismatch to fail, got reason: %s", reason) - } - - req := httptest.NewRequest(http.MethodPost, "http://example.com", strings.NewReader("foo=bar")) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("X-Test", "wrong") - cons := RequestConstraint{ - Headers: map[string][]string{"X-Test": {"expected"}}, - Body: map[string]interface{}{"foo": "baz"}, - } - if ok, reason := validateRequestReason(req, cons); ok { - t.Fatalf("expected validateRequestReason to fail, got success with reason: %s", reason) - } -} diff --git a/app/allowlist_validate.go b/app/allowlist_validate.go index 1601bc8..713de1a 100644 --- a/app/allowlist_validate.go +++ b/app/allowlist_validate.go @@ -64,6 +64,11 @@ func validateAllowlistEntry(name string, callers []CallerConfig) error { return fmt.Errorf("caller %q rule %d invalid method %q", id, ri, m) } upper := strings.ToUpper(trimmed) + if len(r.Methods[m].Body) > 0 { + if err := validateBodySchemaDefinition(r.Methods[m].Body); err != nil { + return fmt.Errorf("caller %q rule %d invalid body schema for method %s: %w", id, ri, upper, err) + } + } if _, dup := ruleSeen[normPath][upper]; dup { return fmt.Errorf("duplicate rule for caller %q path %q method %s", id, r.Path, upper) } diff --git a/app/allowlist_validate_test.go b/app/allowlist_validate_test.go index 79e778a..c07106f 100644 --- a/app/allowlist_validate_test.go +++ b/app/allowlist_validate_test.go @@ -123,6 +123,25 @@ func TestValidateAllowlistEntriesMethodWhitespaceDuplicate(t *testing.T) { } } +func TestValidateAllowlistEntriesInvalidBodySchema(t *testing.T) { + entries := []AllowlistEntry{{ + Integration: "test", + Callers: []CallerConfig{{ + ID: "c", + Rules: []CallRule{{ + Path: "/x", + Methods: map[string]RequestConstraint{ + "POST": {Body: map[string]interface{}{"type": "object", "properties": "bad"}}, + }, + }}, + }}, + }} + err := validateAllowlistEntries(entries) + if err == nil || !strings.Contains(err.Error(), "invalid body schema") { + t.Fatalf("expected invalid body schema error, got %v", err) + } +} + func TestValidateAllowlistEntriesCapabilityParamErrors(t *testing.T) { entries := []AllowlistEntry{{ Integration: "slack", diff --git a/app/body_schema.go b/app/body_schema.go new file mode 100644 index 0000000..f5720a2 --- /dev/null +++ b/app/body_schema.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +func validateBodySchemaDefinition(schema map[string]interface{}) error { + if len(schema) == 0 { + return nil + } + _, err := compileBodySchema(schema) + return err +} + +func validateBodySchema(schema map[string]interface{}, data interface{}) (bool, string) { + if len(schema) == 0 { + return true, "" + } + compiled, err := compileBodySchema(schema) + if err != nil { + return false, fmt.Sprintf("invalid body schema: %v", err) + } + if err := compiled.Validate(data); err != nil { + return false, fmt.Sprintf("body schema mismatch: %v", err) + } + return true, "" +} + +func compileBodySchema(schema map[string]interface{}) (*jsonschema.Schema, error) { + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft7) + if err := compiler.AddResource("body-schema.json", schema); err != nil { + return nil, err + } + return compiler.Compile("body-schema.json") +} + +func decodeJSONBody(bodyBytes []byte) (interface{}, error) { + var data interface{} + if err := json.Unmarshal(bodyBytes, &data); err != nil { + return nil, err + } + return data, nil +} + +func formValuesToJSON(vals url.Values) map[string]interface{} { + data := make(map[string]interface{}, len(vals)) + for k, v := range vals { + if len(v) == 1 { + data[k] = v[0] + continue + } + converted := make([]interface{}, len(v)) + for i, value := range v { + converted[i] = value + } + data[k] = converted + } + return data +} diff --git a/app/denylist.go b/app/denylist.go index 1c74a3c..97b718b 100644 --- a/app/denylist.go +++ b/app/denylist.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "fmt" "net/http" "net/url" @@ -35,6 +34,11 @@ func validateDenylist(name, caller string, rules []CallRule) error { return fmt.Errorf("caller %s rule %d invalid method %q", caller, ri, m) } method = strings.ToUpper(method) + if len(r.Methods[m].Body) > 0 { + if err := validateBodySchemaDefinition(r.Methods[m].Body); err != nil { + return fmt.Errorf("caller %s rule %d invalid body schema for method %s: %w", caller, ri, method, err) + } + } if _, dup := seen[path][method]; dup { return fmt.Errorf("duplicate rule for caller %s path %q method %s (rule %d)", caller, path, method, ri) } @@ -210,17 +214,20 @@ func constraintMatchesRequest(r *http.Request, c RequestConstraint) bool { ct := strings.ToLower(r.Header.Get("Content-Type")) switch { case strings.Contains(ct, "application/json"): - var data map[string]interface{} - if err := json.Unmarshal(bodyBytes, &data); err != nil { + data, err := decodeJSONBody(bodyBytes) + if err != nil { return false } - return matchBodyMap(data, c.Body) + ok, _ := validateBodySchema(c.Body, data) + return ok case strings.Contains(ct, "application/x-www-form-urlencoded"): vals, err := url.ParseQuery(string(bodyBytes)) if err != nil { return false } - return matchForm(vals, c.Body) + data := formValuesToJSON(vals) + ok, _ := validateBodySchema(c.Body, data) + return ok default: return false } diff --git a/app/denylist_test.go b/app/denylist_test.go index 0cb2c40..03a3c4c 100644 --- a/app/denylist_test.go +++ b/app/denylist_test.go @@ -345,8 +345,26 @@ func TestConstraintMatchesRequestJSON(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://json/path", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") cons := RequestConstraint{Body: map[string]interface{}{ - "foo": map[string]interface{}{"bar": "baz"}, - "tags": []interface{}{"a"}, + "type": "object", + "properties": map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "bar": map[string]interface{}{ + "type": "string", + "const": "baz", + }, + }, + "required": []interface{}{"bar"}, + }, + "tags": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "type": "string", + }, + }, + }, + "required": []interface{}{"foo", "tags"}, }} if !constraintMatchesRequest(req, cons) { t.Fatal("expected JSON body to match constraint") @@ -358,7 +376,14 @@ func TestConstraintMatchesRequestJSONContentTypeCaseInsensitive(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://json/path", strings.NewReader(body)) req.Header.Set("Content-Type", "Application/JSON; charset=utf-8") cons := RequestConstraint{Body: map[string]interface{}{ - "foo": "bar", + "type": "object", + "properties": map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "string", + "const": "bar", + }, + }, + "required": []interface{}{"foo"}, }} if !constraintMatchesRequest(req, cons) { t.Fatal("expected JSON body to match constraint regardless of content type case") @@ -370,8 +395,20 @@ func TestConstraintMatchesRequestForm(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://form/path", strings.NewReader(vals.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") cons := RequestConstraint{Body: map[string]interface{}{ - "a": []interface{}{"1"}, - "b": "x", + "type": "object", + "properties": map[string]interface{}{ + "a": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "type": "string", + }, + }, + "b": map[string]interface{}{ + "type": "string", + "const": "x", + }, + }, + "required": []interface{}{"a", "b"}, }} if !constraintMatchesRequest(req, cons) { t.Fatal("expected form body to match constraint") @@ -383,7 +420,14 @@ func TestConstraintMatchesRequestFormContentTypeCaseInsensitive(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://form/path", strings.NewReader(vals.Encode())) req.Header.Set("Content-Type", "Application/X-Www-Form-Urlencoded") cons := RequestConstraint{Body: map[string]interface{}{ - "foo": "bar", + "type": "object", + "properties": map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "string", + "const": "bar", + }, + }, + "required": []interface{}{"foo"}, }} if !constraintMatchesRequest(req, cons) { t.Fatal("expected form body to match constraint regardless of content type case") @@ -393,7 +437,16 @@ func TestConstraintMatchesRequestFormContentTypeCaseInsensitive(t *testing.T) { func TestConstraintMatchesRequestUnsupportedBody(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://plain/path", strings.NewReader("test")) req.Header.Set("Content-Type", "text/plain") - cons := RequestConstraint{Body: map[string]interface{}{"plain": "text"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "plain": map[string]interface{}{ + "type": "string", + "const": "text", + }, + }, + "required": []interface{}{"plain"}, + }} if constraintMatchesRequest(req, cons) { t.Fatal("expected unsupported content type not to match") } @@ -402,7 +455,16 @@ func TestConstraintMatchesRequestUnsupportedBody(t *testing.T) { func TestConstraintMatchesRequestBodyReadError(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://limit/path", strings.NewReader("{\"foo\":\"bar\"}")) req.Header.Set("Content-Type", "application/json") - cons := RequestConstraint{Body: map[string]interface{}{"foo": "bar"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "string", + "const": "bar", + }, + }, + "required": []interface{}{"foo"}, + }} oldMax := authplugins.MaxBodySize authplugins.MaxBodySize = 1 @@ -492,7 +554,16 @@ func TestConstraintMatchesRequestQueryMissingKey(t *testing.T) { func TestConstraintMatchesRequestJSONUnmarshalError(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://json/error", strings.NewReader("{")) req.Header.Set("Content-Type", "application/json") - cons := RequestConstraint{Body: map[string]interface{}{"foo": "bar"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "string", + "const": "bar", + }, + }, + "required": []interface{}{"foo"}, + }} if constraintMatchesRequest(req, cons) { t.Fatal("expected JSON unmarshal error to fail constraint match") } @@ -501,7 +572,16 @@ func TestConstraintMatchesRequestJSONUnmarshalError(t *testing.T) { func TestConstraintMatchesRequestFormParseError(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://form/error", strings.NewReader("bad%%encoding")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - cons := RequestConstraint{Body: map[string]interface{}{"foo": "bar"}} + cons := RequestConstraint{Body: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "string", + "const": "bar", + }, + }, + "required": []interface{}{"foo"}, + }} if constraintMatchesRequest(req, cons) { t.Fatal("expected form parse error to fail constraint match") } diff --git a/app/denylist_validate_test.go b/app/denylist_validate_test.go index 25a2bdb..8c9c97a 100644 --- a/app/denylist_validate_test.go +++ b/app/denylist_validate_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "strings" + "testing" +) func TestValidateDenylistEntriesDuplicateIntegration(t *testing.T) { entries := []DenylistEntry{{Integration: "a"}, {Integration: "a"}} @@ -54,3 +57,22 @@ func TestValidateDenylistEntries(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestValidateDenylistEntriesInvalidBodySchema(t *testing.T) { + entries := []DenylistEntry{{ + Integration: "test", + Callers: []DenylistCaller{{ + ID: "caller", + Rules: []CallRule{{ + Path: "/blocked", + Methods: map[string]RequestConstraint{ + "POST": {Body: map[string]interface{}{"type": "object", "properties": "bad"}}, + }, + }}, + }}, + }} + err := validateDenylistEntries(entries) + if err == nil || !strings.Contains(err.Error(), "invalid body schema") { + t.Fatalf("expected invalid body schema error, got %v", err) + } +} diff --git a/app/integrations/plugins/sendgrid/capabilities.go b/app/integrations/plugins/sendgrid/capabilities.go index 13d55a7..48591b1 100644 --- a/app/integrations/plugins/sendgrid/capabilities.go +++ b/app/integrations/plugins/sendgrid/capabilities.go @@ -15,11 +15,21 @@ func init() { return nil, fmt.Errorf("from parameter required") } reply, replyOK := p["replyTo"] - body := map[string]interface{}{"from": from} + replyConst := interface{}(nil) if replyOK { - body["reply_to"] = reply - } else { - body["reply_to"] = nil + replyConst = reply + } + body := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "from": map[string]interface{}{ + "const": from, + }, + "reply_to": map[string]interface{}{ + "const": replyConst, + }, + }, + "required": []interface{}{"from", "reply_to"}, } rule := integrationplugins.CallRule{Path: "/v3/mail/send", Methods: map[string]integrationplugins.RequestConstraint{"POST": {Body: body}}} return []integrationplugins.CallRule{rule}, nil diff --git a/app/integrations/plugins/sendgrid/sendgrid_test.go b/app/integrations/plugins/sendgrid/sendgrid_test.go index a0e8324..e5f2c4d 100644 --- a/app/integrations/plugins/sendgrid/sendgrid_test.go +++ b/app/integrations/plugins/sendgrid/sendgrid_test.go @@ -49,11 +49,17 @@ func TestSendgridCapabilities(t *testing.T) { continue } if tt.name == "send_email" { - if rc.Body["from"] != "me@example.com" { + props, ok := rc.Body["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("expected body properties for send_email") + } + from, ok := props["from"].(map[string]interface{}) + if !ok || from["const"] != "me@example.com" { t.Errorf("from not propagated") } - if rc.Body["reply_to"] != nil { - t.Errorf("reply_to default unexpected: %#v", rc.Body["reply_to"]) + replyTo, ok := props["reply_to"].(map[string]interface{}) + if !ok || replyTo["const"] != nil { + t.Errorf("reply_to default unexpected: %#v", replyTo) } } } @@ -68,7 +74,12 @@ func TestSendgridCapabilities(t *testing.T) { t.Fatalf("unexpected error: %v", err) } rc := rules[0].Methods["POST"] - if rc.Body["reply_to"] != "r@example.com" { + props, ok := rc.Body["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("expected body properties for reply_to") + } + replyTo, ok := props["reply_to"].(map[string]interface{}) + if !ok || replyTo["const"] != "r@example.com" { t.Errorf("reply_to value not propagated") } @@ -77,7 +88,12 @@ func TestSendgridCapabilities(t *testing.T) { t.Fatalf("unexpected error: %v", err) } rc = rules[0].Methods["POST"] - if rc.Body["reply_to"] != nil { + props, ok = rc.Body["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("expected body properties for reply_to nil") + } + replyTo, ok = props["reply_to"].(map[string]interface{}) + if !ok || replyTo["const"] != nil { t.Errorf("reply_to nil not set") } } diff --git a/app/integrations/plugins/slack/capabilities.go b/app/integrations/plugins/slack/capabilities.go index bacc8bc..4e98382 100644 --- a/app/integrations/plugins/slack/capabilities.go +++ b/app/integrations/plugins/slack/capabilities.go @@ -14,7 +14,17 @@ func init() { if user == "" { return nil, fmt.Errorf("username required") } - rule := integrationplugins.CallRule{Path: "/api/chat.postMessage", Methods: map[string]integrationplugins.RequestConstraint{"POST": {Body: map[string]interface{}{"username": user}}}} + body := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "username": map[string]interface{}{ + "type": "string", + "const": user, + }, + }, + "required": []interface{}{"username"}, + } + rule := integrationplugins.CallRule{Path: "/api/chat.postMessage", Methods: map[string]integrationplugins.RequestConstraint{"POST": {Body: body}}} return []integrationplugins.CallRule{rule}, nil }, }) @@ -31,7 +41,21 @@ func init() { for i, c := range ch { allowed[i] = c } - rule := integrationplugins.CallRule{Path: "/api/chat.postMessage", Methods: map[string]integrationplugins.RequestConstraint{"POST": {Body: map[string]interface{}{"username": user, "channel": allowed}}}} + body := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "username": map[string]interface{}{ + "type": "string", + "const": user, + }, + "channel": map[string]interface{}{ + "type": "string", + "enum": allowed, + }, + }, + "required": []interface{}{"username", "channel"}, + } + rule := integrationplugins.CallRule{Path: "/api/chat.postMessage", Methods: map[string]integrationplugins.RequestConstraint{"POST": {Body: body}}} return []integrationplugins.CallRule{rule}, nil }, }) @@ -47,7 +71,17 @@ func init() { for i, c := range ch { allowed[i] = c } - rule := integrationplugins.CallRule{Path: "/api/chat.postMessage", Methods: map[string]integrationplugins.RequestConstraint{"POST": {Body: map[string]interface{}{"channel": allowed}}}} + body := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "channel": map[string]interface{}{ + "type": "string", + "enum": allowed, + }, + }, + "required": []interface{}{"channel"}, + } + rule := integrationplugins.CallRule{Path: "/api/chat.postMessage", Methods: map[string]integrationplugins.RequestConstraint{"POST": {Body: body}}} return []integrationplugins.CallRule{rule}, nil }, }) diff --git a/app/integrations/plugins/slack/slack_test.go b/app/integrations/plugins/slack/slack_test.go index a2d9fb9..7d623f9 100644 --- a/app/integrations/plugins/slack/slack_test.go +++ b/app/integrations/plugins/slack/slack_test.go @@ -33,7 +33,12 @@ func TestSlackCapabilities(t *testing.T) { if !ok { t.Fatalf("missing POST method") } - if rc.Body["username"] != "bot" { + props, ok := rc.Body["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("expected body properties for post_as") + } + username, ok := props["username"].(map[string]interface{}) + if !ok || username["const"] != "bot" { t.Errorf("username not propagated") } @@ -62,9 +67,17 @@ func TestSlackCapabilities(t *testing.T) { if !ok { t.Fatalf("missing POST method") } - chVal, ok := rc.Body["channel"].([]interface{}) + props, ok = rc.Body["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("expected body properties for post_channels_as") + } + channel, ok := props["channel"].(map[string]interface{}) + if !ok { + t.Fatalf("expected channel schema") + } + chVal, ok := channel["enum"].([]interface{}) if !ok || !reflect.DeepEqual(chVal, []interface{}{"c1", "c2"}) { - t.Errorf("channels not propagated: %v", rc.Body["channel"]) + t.Errorf("channels not propagated: %v", channel["enum"]) } // missing fields should error @@ -91,9 +104,17 @@ func TestSlackCapabilities(t *testing.T) { if !ok { t.Fatalf("missing POST method") } - chVal, ok = rc.Body["channel"].([]interface{}) + props, ok = rc.Body["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("expected body properties for post_channels") + } + channel, ok = props["channel"].(map[string]interface{}) + if !ok { + t.Fatalf("expected channel schema") + } + chVal, ok = channel["enum"].([]interface{}) if !ok || !reflect.DeepEqual(chVal, []interface{}{"c1"}) { - t.Errorf("channels not propagated: %v", rc.Body["channel"]) + t.Errorf("channels not propagated: %v", channel["enum"]) } // missing channels should error diff --git a/app/match_form_test.go b/app/match_form_test.go deleted file mode 100644 index d2f7d4f..0000000 --- a/app/match_form_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package main - -import ( - "net/url" - "strings" - "testing" -) - -func TestMatchForm(t *testing.T) { - vals := url.Values{"a": {"1", "2"}, "b": {"x"}} - rule := map[string]interface{}{"a": []interface{}{"1"}, "b": "x"} - if !matchForm(vals, rule) { - t.Fatal("expected match") - } -} - -func TestMatchFormMissingKey(t *testing.T) { - vals := url.Values{"a": {"1"}} - rule := map[string]interface{}{"b": "x"} - if matchForm(vals, rule) { - t.Fatal("expected missing key to fail") - } -} - -func TestMatchFormMissingValue(t *testing.T) { - vals := url.Values{"a": {"1"}} - rule := map[string]interface{}{"a": []interface{}{"1", "2"}} - if matchForm(vals, rule) { - t.Fatal("expected missing value to fail") - } -} - -func TestMatchFormNonString(t *testing.T) { - vals := url.Values{"a": {"1"}} - rule := map[string]interface{}{"a": []interface{}{1}} - if matchForm(vals, rule) { - t.Fatal("expected non-string to fail") - } -} - -func TestMatchFormStringValue(t *testing.T) { - vals := url.Values{"a": {"1"}} - rule := map[string]interface{}{"a": "1"} - if !matchForm(vals, rule) { - t.Fatal("expected string value match") - } - rule = map[string]interface{}{"a": "2"} - if matchForm(vals, rule) { - t.Fatal("expected string value mismatch to fail") - } -} - -func TestMatchFormInvalidType(t *testing.T) { - vals := url.Values{"a": {"1"}} - rule := map[string]interface{}{"a": 1} - if matchForm(vals, rule) { - t.Fatal("expected invalid type to fail") - } -} - -func TestMatchQuerySuccess(t *testing.T) { - vals := url.Values{"a": {"1", "2"}, "b": {"x"}} - rule := map[string][]string{"a": {"1"}, "b": {"x"}} - if !matchQuery(vals, rule) { - t.Fatal("expected match") - } -} - -func TestMatchQueryMissingKey(t *testing.T) { - vals := url.Values{"a": {"1"}} - rule := map[string][]string{"a": {"1"}, "b": {"2"}} - if matchQuery(vals, rule) { - t.Fatal("expected missing key to fail") - } -} - -func TestMatchQueryMissingValue(t *testing.T) { - vals := url.Values{"a": {"1"}, "b": {"x"}} - rule := map[string][]string{"a": {"1", "2"}} - if matchQuery(vals, rule) { - t.Fatal("expected missing value to fail") - } -} - -func TestMatchValuePrimitive(t *testing.T) { - if !matchValue("a", "a") { - t.Fatal("expected primitive match") - } - if matchValue("a", "b") { - t.Fatal("expected primitive mismatch") - } -} - -func TestMatchValueMap(t *testing.T) { - data := map[string]interface{}{"a": "1", "b": "2"} - rule := map[string]interface{}{"a": "1"} - if !matchValue(data, rule) { - t.Fatal("expected map match") - } - rule2 := map[string]interface{}{"c": "3"} - if matchValue(data, rule2) { - t.Fatal("expected missing key to fail") - } -} - -func TestMatchValueArray(t *testing.T) { - data := []interface{}{"a", "b", "c"} - rule := []interface{}{"a", "c"} - if !matchValue(data, rule) { - t.Fatal("expected array match") - } - rule2 := []interface{}{"a", "d"} - if matchValue(data, rule2) { - t.Fatal("expected array mismatch") - } -} - -func TestMatchValueNested(t *testing.T) { - data := map[string]interface{}{ - "arr": []interface{}{ - map[string]interface{}{"x": "1"}, - map[string]interface{}{"x": "2"}, - }, - } - rule := map[string]interface{}{ - "arr": []interface{}{ - map[string]interface{}{"x": "2"}, - }, - } - if !matchValue(data, rule) { - t.Fatal("expected nested match") - } -} - -func TestMatchFormReasonVariants(t *testing.T) { - vals := url.Values{"a": {"1"}} - - if ok, reason := matchFormReason(vals, map[string]interface{}{"a": "1"}); !ok || reason != "" { - t.Fatalf("expected success, got %v %q", ok, reason) - } - - if ok, reason := matchFormReason(vals, map[string]interface{}{"b": "2"}); ok || !strings.Contains(reason, "missing form field b") { - t.Fatalf("missing field: %v %q", ok, reason) - } - - if ok, reason := matchFormReason(vals, map[string]interface{}{"a": []interface{}{"1", "2"}}); ok || !strings.Contains(reason, "form field a=2 not found") { - t.Fatalf("missing value: %v %q", ok, reason) - } - - if ok, reason := matchFormReason(vals, map[string]interface{}{"a": []interface{}{"1", 2}}); ok || reason != "invalid rule" { - t.Fatalf("bad slice element: %v %q", ok, reason) - } - - if ok, reason := matchFormReason(vals, map[string]interface{}{"a": 1}); ok || reason != "invalid rule" { - t.Fatalf("bad type: %v %q", ok, reason) - } -} diff --git a/docs/allowlist-yaml.md b/docs/allowlist-yaml.md index e783fb2..fe0119d 100644 --- a/docs/allowlist-yaml.md +++ b/docs/allowlist-yaml.md @@ -79,19 +79,24 @@ rules: channel: [C12345678] headers: # header=value list; empty list checks only presence X-Custom-Trace: [abc123] - body: # optional JSON or form filters - text: "Hello world" # matched recursively + body: # optional JSON Schema for JSON/form bodies + type: object + properties: + text: + type: string + pattern: "^Hello" + required: [text] # body format is detected via Content-Type; other types skip matching ``` -Allowed values are matched **exactly**; the proxy does not interpret regular expressions. +Allowed values are matched **exactly** for headers and query parameters. Body constraints use +JSON Schema draft‑07 so you can express regex patterns, string lengths, and numeric ranges. Each key under `methods:` represents an HTTP method. Mapping a method to `{}` means the request is allowed for that verb as soon as the path matches. Add `query`, `headers`, or `body` constraints inside a method block to further limit -which requests are permitted. - -> **Subset principle** *Every* field you specify must match the request; unspecified fields are ignored. This means your rule must be a **subset** of the incoming request. +which requests are permitted. Body constraints are evaluated against the parsed JSON or +form payload. | Request part | Matching logic | | ------------ | --------------------------------------------------------------------------------------------------- | @@ -99,20 +104,7 @@ which requests are permitted. | Method | Case-insensitive string compare. Each method key contains its own constraints. | | Query params | In `methods..query`, each key maps to allowed value list. Extra params allowed. Values match exactly. | Headers | In `methods..headers`, each key has required values; an empty list only checks for presence. Values match exactly. -| Body | `methods..body` must be a recursive subset of the request body (JSON or form). Arrays matched unordered. Detection relies on the `Content-Type` header; if it's neither JSON nor form, body checks are skipped. - -A rule like: - -```yaml - body: - obj: - inner: - more_inner: x - arr: [2, 1] -``` - -matches a request body -`{"obj": {"inner": {"more_inner": "x", "extra_more_inner": "y"}, "arr": [1, 2, 3], "extra": true}}`. +| Body | `methods..body` is a JSON Schema (draft‑07) evaluated against the JSON or form body. Detection relies on the `Content-Type` header; if it's neither JSON nor form, body checks are skipped. A request passes if **any** rule (or capability‑expanded rule) matches. diff --git a/docs/configuration-overview.md b/docs/configuration-overview.md index 781b7d8..9126df4 100644 --- a/docs/configuration-overview.md +++ b/docs/configuration-overview.md @@ -6,7 +6,7 @@ AuthTranslator loads up to **three** YAML (or pure‑JSON) documents at runtime: | ---------------- | --------- | ----------- | ---------------------------------------------------------------------------------- | | `config.yaml` | ✅ | ✅ | Declares *integrations* – where to proxy traffic and how to authenticate outwards. | | `allowlist.yaml` | – | ✅ | Grants each *caller ID* a set of capabilities **or** low‑level request filters. | -| `denylist.yaml` | – | ✅ | Blocks requests whose headers/query/body match predefined subsets for a path/method. | +| `denylist.yaml` | – | ✅ | Blocks requests whose headers/query/body schema match for a path/method. | If no allowlist is provided, every request is permitted once inbound authentication succeeds. Running without an allowlist effectively gives all authenticated callers unrestricted access, so supplying `allowlist.yaml` is **strongly recommended** even if it just contains a single wildcard entry to start. The denylist stays optional as well; omit it when you have no hard blocks to enforce. @@ -110,14 +110,20 @@ Two ways to authorise a caller: query: channel: [C12345678] # workspace channel IDs (exact match) body: - text: "Hello world" # match the whole value exactly + type: object + properties: + text: + type: string + pattern: "^Hello" + required: ["text"] # format detection uses Content-Type; other types skip body matching headers: X-Custom-Trace: [abc123] ``` -Values for `query`, `headers`, and `body` are compared using **exact string equality**. -Regular expressions are not supported. +Values for `query` and `headers` are compared using **exact string equality**. Body +constraints are JSON Schema draft‑07, which supports regex patterns, string length +checks, and numeric ranges. ### Entry fields @@ -144,7 +150,7 @@ authorised to use it. | `methods` | map[string]RequestConstraint | Keys are HTTP verbs. Map a verb to `{}` to allow it without extra checks. | | `methods..query` | map[string][]string | Each element is a list of allowed values per query key. All must match. | | `methods..headers` | map[string][]string | Header names and required values. Empty list checks only presence. | -| `methods..body` | map[string]interface{} | Recursive subset of the request body (JSON or form). Arrays matched unordered. The proxy inspects `Content-Type`; unknown types skip body checks. | +| `methods..body` | map[string]interface{} | JSON Schema (draft‑07) evaluated against the request body (JSON or form). The proxy inspects `Content-Type`; unknown types skip body checks. | --- @@ -170,10 +176,15 @@ Denylists complement allowlists by describing requests that must never be forwar headers: X-Feature-Flag: [disabled] body: - channel: forbidden-room + type: object + properties: + channel: + type: string + const: forbidden-room + required: [channel] ``` -* Only the provided fields must match; extra headers/query/body keys are ignored. +* Only the provided headers/query values must match; extra headers/query keys are ignored. * JSON/form bodies are parsed using `Content-Type`. Unknown types cause the rule to be skipped (no deny). * Duplicate path/method combinations fail validation during reload, mirroring the allowlist behaviour. @@ -215,4 +226,3 @@ CI fails fast on typos so you never ship an invalid proxy. * [Auth Plugins](auth-plugins.md) * [Secret Back-Ends](secret-backends.md) * [Rate-Limiting](rate-limiting.md) - diff --git a/docs/denylist-yaml.md b/docs/denylist-yaml.md index 98c6dc8..10a1c38 100644 --- a/docs/denylist-yaml.md +++ b/docs/denylist-yaml.md @@ -55,11 +55,11 @@ Denylist rules reuse the same `RequestConstraint` syntax as granular allowlist r strict: * **Path and method must match first.** Paths are anchored and support `*` (single segment) and `**` (remainder) wildcards. -* **Every listed constraint must be present.** Headers, query parameters, and body fragments are all ANDed together. If a rule +* **Every listed constraint must be present.** Headers, query parameters, and body schemas are all ANDed together. If a rule references both a query parameter and a header, for example, **both must be present with one of the listed values** for the rule to fire. -* **Value comparisons are exact string matches.** There is no regex or partial matching. -* **Bodies require a supported content type.** JSON and form bodies are parsed; other types skip matching and the rule will not - apply. +* **Value comparisons are exact string matches** for headers and query parameters. +* **Bodies are validated with JSON Schema (draft‑07).** JSON and form bodies are parsed; other types skip matching and the rule will not + apply. This makes regex patterns, ranges, and string length checks available in denylist rules. > **Need an OR?** Create multiple rules (or duplicate caller entries) that each express one alternative. The proxy only blocks a > request when a *single* rule’s entire constraint set matches the request. This makes it safe to layer narrow kill-switches diff --git a/docs/helm.md b/docs/helm.md index cefdf72..4235ca3 100644 --- a/docs/helm.md +++ b/docs/helm.md @@ -79,7 +79,12 @@ denylist: | methods: POST: body: - channel: forbidden-room + type: object + properties: + channel: + type: string + const: forbidden-room + required: [channel] ``` diff --git a/examples/denylist.yaml b/examples/denylist.yaml index 62959ba..d6581fb 100644 --- a/examples/denylist.yaml +++ b/examples/denylist.yaml @@ -6,4 +6,10 @@ methods: POST: body: - channel: bad-channel + type: object + properties: + channel: + type: string + const: bad-channel + required: + - channel diff --git a/go.mod b/go.mod index 6b317c8..b5c3d14 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/quic-go/quic-go v0.56.0 +require ( + github.com/quic-go/quic-go v0.56.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 +) require ( github.com/kr/text v0.2.0 // indirect diff --git a/go.sum b/go.sum index f3c9ac1..3cf3981 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -15,6 +17,8 @@ github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxv github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= From 109b7ec847ecb69a96d050fb03970cbdb9870c80 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Tue, 20 Jan 2026 15:48:28 -0800 Subject: [PATCH 2/3] Cache compiled JSON schemas per constraint --- app/allowlist.go | 7 +++++-- app/body_schema.go | 29 +++++++++++++++++++++++++++++ app/denylist.go | 7 +++++-- app/integrations/types.go | 9 ++++++--- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/app/allowlist.go b/app/allowlist.go index 96d6da2..ac180cd 100644 --- a/app/allowlist.go +++ b/app/allowlist.go @@ -80,6 +80,9 @@ func SetAllowlist(name string, callers []CallerConfig) error { if method == "" { continue } + if err := compileBodySchemaInto(&cons); err != nil { + return fmt.Errorf("invalid body schema for %s %s: %w", r.Path, method, err) + } methods[strings.ToUpper(method)] = cons } r.Methods = methods @@ -204,7 +207,7 @@ func validateRequestReason(r *http.Request, c RequestConstraint) (bool, string) if err != nil { return false, "invalid json" } - if ok, reason := validateBodySchema(c.Body, data); !ok { + if ok, reason := validateBodySchemaCompiled(c.BodySchema, c.Body, data); !ok { return false, reason } return true, "" @@ -215,7 +218,7 @@ func validateRequestReason(r *http.Request, c RequestConstraint) (bool, string) return false, "invalid form encoding" } data := formValuesToJSON(vals) - if ok, reason := validateBodySchema(c.Body, data); !ok { + if ok, reason := validateBodySchemaCompiled(c.BodySchema, c.Body, data); !ok { return false, reason } return true, "" diff --git a/app/body_schema.go b/app/body_schema.go index f5720a2..a31cec4 100644 --- a/app/body_schema.go +++ b/app/body_schema.go @@ -30,6 +30,23 @@ func validateBodySchema(schema map[string]interface{}, data interface{}) (bool, return true, "" } +func validateBodySchemaCompiled(compiled *jsonschema.Schema, schema map[string]interface{}, data interface{}) (bool, string) { + if len(schema) == 0 { + return true, "" + } + if compiled == nil { + var err error + compiled, err = compileBodySchema(schema) + if err != nil { + return false, fmt.Sprintf("invalid body schema: %v", err) + } + } + if err := compiled.Validate(data); err != nil { + return false, fmt.Sprintf("body schema mismatch: %v", err) + } + return true, "" +} + func compileBodySchema(schema map[string]interface{}) (*jsonschema.Schema, error) { compiler := jsonschema.NewCompiler() compiler.DefaultDraft(jsonschema.Draft7) @@ -39,6 +56,18 @@ func compileBodySchema(schema map[string]interface{}) (*jsonschema.Schema, error return compiler.Compile("body-schema.json") } +func compileBodySchemaInto(cons *RequestConstraint) error { + if len(cons.Body) == 0 { + return nil + } + compiled, err := compileBodySchema(cons.Body) + if err != nil { + return err + } + cons.BodySchema = compiled + return nil +} + func decodeJSONBody(bodyBytes []byte) (interface{}, error) { var data interface{} if err := json.Unmarshal(bodyBytes, &data); err != nil { diff --git a/app/denylist.go b/app/denylist.go index 97b718b..bbd53ae 100644 --- a/app/denylist.go +++ b/app/denylist.go @@ -85,6 +85,9 @@ func SetDenylist(name string, callers []DenylistCaller) error { methods := make(map[string]RequestConstraint, len(r.Methods)) for method, cons := range r.Methods { cleaned := strings.ToUpper(strings.TrimSpace(method)) + if err := compileBodySchemaInto(&cons); err != nil { + return fmt.Errorf("invalid body schema for %s %s: %w", r.Path, cleaned, err) + } methods[cleaned] = cons } r.Methods = methods @@ -218,7 +221,7 @@ func constraintMatchesRequest(r *http.Request, c RequestConstraint) bool { if err != nil { return false } - ok, _ := validateBodySchema(c.Body, data) + ok, _ := validateBodySchemaCompiled(c.BodySchema, c.Body, data) return ok case strings.Contains(ct, "application/x-www-form-urlencoded"): vals, err := url.ParseQuery(string(bodyBytes)) @@ -226,7 +229,7 @@ func constraintMatchesRequest(r *http.Request, c RequestConstraint) bool { return false } data := formValuesToJSON(vals) - ok, _ := validateBodySchema(c.Body, data) + ok, _ := validateBodySchemaCompiled(c.BodySchema, c.Body, data) return ok default: return false diff --git a/app/integrations/types.go b/app/integrations/types.go index 5f775f0..f8a2875 100644 --- a/app/integrations/types.go +++ b/app/integrations/types.go @@ -1,5 +1,7 @@ package integrationplugins +import "github.com/santhosh-tekuri/jsonschema/v6" + // CallRule ties a path pattern to method-specific constraints. type CallRule struct { Path string `json:"path" yaml:"path"` @@ -9,9 +11,10 @@ type CallRule struct { // RequestConstraint lists required headers and body parameters. type RequestConstraint struct { - Headers map[string][]string `json:"headers" yaml:"headers,omitempty"` - Query map[string][]string `json:"query" yaml:"query,omitempty"` - Body map[string]interface{} `json:"body" yaml:"body,omitempty"` + Headers map[string][]string `json:"headers" yaml:"headers,omitempty"` + Query map[string][]string `json:"query" yaml:"query,omitempty"` + Body map[string]interface{} `json:"body" yaml:"body,omitempty"` + BodySchema *jsonschema.Schema `json:"-" yaml:"-"` } type CallerConfig struct { From 445583fe2ec21ccf7bca536819d7979bf44c7e04 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Tue, 20 Jan 2026 23:26:55 -0800 Subject: [PATCH 3/3] Reject legacy body maps in schema validation --- app/allowlist_validate_test.go | 19 +++++++++++++++++++ app/body_schema.go | 25 +++++++++++++++++++++++++ app/denylist_validate_test.go | 19 +++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/app/allowlist_validate_test.go b/app/allowlist_validate_test.go index c07106f..5e8bfb3 100644 --- a/app/allowlist_validate_test.go +++ b/app/allowlist_validate_test.go @@ -142,6 +142,25 @@ func TestValidateAllowlistEntriesInvalidBodySchema(t *testing.T) { } } +func TestValidateAllowlistEntriesLegacyBodyRejected(t *testing.T) { + entries := []AllowlistEntry{{ + Integration: "test", + Callers: []CallerConfig{{ + ID: "c", + Rules: []CallRule{{ + Path: "/x", + Methods: map[string]RequestConstraint{ + "POST": {Body: map[string]interface{}{"text": "Hello"}}, + }, + }}, + }}, + }} + err := validateAllowlistEntries(entries) + if err == nil || !strings.Contains(err.Error(), "body schema must use JSON Schema keywords") { + t.Fatalf("expected legacy body schema error, got %v", err) + } +} + func TestValidateAllowlistEntriesCapabilityParamErrors(t *testing.T) { entries := []AllowlistEntry{{ Integration: "slack", diff --git a/app/body_schema.go b/app/body_schema.go index a31cec4..d65312f 100644 --- a/app/body_schema.go +++ b/app/body_schema.go @@ -12,6 +12,9 @@ func validateBodySchemaDefinition(schema map[string]interface{}) error { if len(schema) == 0 { return nil } + if !looksLikeJSONSchema(schema) { + return fmt.Errorf("body schema must use JSON Schema keywords (example: type, properties, $schema)") + } _, err := compileBodySchema(schema) return err } @@ -20,6 +23,9 @@ func validateBodySchema(schema map[string]interface{}, data interface{}) (bool, if len(schema) == 0 { return true, "" } + if !looksLikeJSONSchema(schema) { + return false, "body schema must use JSON Schema keywords (example: type, properties, $schema)" + } compiled, err := compileBodySchema(schema) if err != nil { return false, fmt.Sprintf("invalid body schema: %v", err) @@ -34,6 +40,9 @@ func validateBodySchemaCompiled(compiled *jsonschema.Schema, schema map[string]i if len(schema) == 0 { return true, "" } + if !looksLikeJSONSchema(schema) { + return false, "body schema must use JSON Schema keywords (example: type, properties, $schema)" + } if compiled == nil { var err error compiled, err = compileBodySchema(schema) @@ -60,6 +69,9 @@ func compileBodySchemaInto(cons *RequestConstraint) error { if len(cons.Body) == 0 { return nil } + if !looksLikeJSONSchema(cons.Body) { + return fmt.Errorf("body schema must use JSON Schema keywords (example: type, properties, $schema)") + } compiled, err := compileBodySchema(cons.Body) if err != nil { return err @@ -68,6 +80,19 @@ func compileBodySchemaInto(cons *RequestConstraint) error { return nil } +func looksLikeJSONSchema(schema map[string]interface{}) bool { + for key := range schema { + switch key { + case "$schema", "$id", "type", "properties", "items", "required", "additionalProperties", + "pattern", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems", + "enum", "const", "anyOf", "allOf", "oneOf", "not", "if", "then", "else", + "patternProperties", "dependentRequired", "dependentSchemas", "$defs", "definitions": + return true + } + } + return false +} + func decodeJSONBody(bodyBytes []byte) (interface{}, error) { var data interface{} if err := json.Unmarshal(bodyBytes, &data); err != nil { diff --git a/app/denylist_validate_test.go b/app/denylist_validate_test.go index 8c9c97a..cabd359 100644 --- a/app/denylist_validate_test.go +++ b/app/denylist_validate_test.go @@ -76,3 +76,22 @@ func TestValidateDenylistEntriesInvalidBodySchema(t *testing.T) { t.Fatalf("expected invalid body schema error, got %v", err) } } + +func TestValidateDenylistEntriesLegacyBodyRejected(t *testing.T) { + entries := []DenylistEntry{{ + Integration: "test", + Callers: []DenylistCaller{{ + ID: "caller", + Rules: []CallRule{{ + Path: "/blocked", + Methods: map[string]RequestConstraint{ + "POST": {Body: map[string]interface{}{"text": "Hello"}}, + }, + }}, + }}, + }} + err := validateDenylistEntries(entries) + if err == nil || !strings.Contains(err.Error(), "body schema must use JSON Schema keywords") { + t.Fatalf("expected legacy body schema error, got %v", err) + } +}