diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index e5f3060..6889800 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -51,7 +51,7 @@ jobs: needs: - draft-release env: - X_GO_DISTRIBUTION: "https://go.dev/dl/go1.24.10.linux-amd64.tar.gz" + X_GO_DISTRIBUTION: "https://go.dev/dl/go1.24.11.linux-amd64.tar.gz" APIFIREWALL_NAMESPACE: "github.com/wallarm/api-firewall" strategy: matrix: @@ -162,7 +162,7 @@ jobs: needs: - draft-release env: - X_GO_VERSION: "1.24.10" + X_GO_VERSION: "1.24.11" APIFIREWALL_NAMESPACE: "github.com/wallarm/api-firewall" strategy: matrix: @@ -272,19 +272,19 @@ jobs: include: - arch: armv6 distro: bookworm - go_distribution: https://go.dev/dl/go1.24.10.linux-armv6l.tar.gz + go_distribution: https://go.dev/dl/go1.24.11.linux-armv6l.tar.gz artifact: armv6-libc - arch: aarch64 distro: bookworm - go_distribution: https://go.dev/dl/go1.24.10.linux-arm64.tar.gz + go_distribution: https://go.dev/dl/go1.24.11.linux-arm64.tar.gz artifact: arm64-libc - arch: armv6 distro: alpine_latest - go_distribution: https://go.dev/dl/go1.24.10.linux-armv6l.tar.gz + go_distribution: https://go.dev/dl/go1.24.11.linux-armv6l.tar.gz artifact: armv6-musl - arch: aarch64 distro: alpine_latest - go_distribution: https://go.dev/dl/go1.24.10.linux-arm64.tar.gz + go_distribution: https://go.dev/dl/go1.24.11.linux-arm64.tar.gz artifact: arm64-musl steps: - uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index fcb3378..8ea318b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION := 0.9.4 +VERSION := 0.9.5 NAMESPACE := github.com/wallarm/api-firewall .DEFAULT_GOAL := build diff --git a/demo/docker-compose/OWASP_CoreRuleSet/docker-compose.yml b/demo/docker-compose/OWASP_CoreRuleSet/docker-compose.yml index 12692c3..1b686e6 100644 --- a/demo/docker-compose/OWASP_CoreRuleSet/docker-compose.yml +++ b/demo/docker-compose/OWASP_CoreRuleSet/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.4 + image: wallarm/api-firewall:v0.9.5 restart: on-failure environment: APIFW_URL: "http://0.0.0.0:8080" diff --git a/demo/docker-compose/docker-compose-api-mode.yml b/demo/docker-compose/docker-compose-api-mode.yml index aa2afc2..98ff2c9 100644 --- a/demo/docker-compose/docker-compose-api-mode.yml +++ b/demo/docker-compose/docker-compose-api-mode.yml @@ -2,7 +2,7 @@ version: '3.8' services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.4 + image: wallarm/api-firewall:v0.9.5 restart: on-failure environment: APIFW_MODE: "api" diff --git a/demo/docker-compose/docker-compose-graphql-mode.yml b/demo/docker-compose/docker-compose-graphql-mode.yml index e71b445..89c6620 100644 --- a/demo/docker-compose/docker-compose-graphql-mode.yml +++ b/demo/docker-compose/docker-compose-graphql-mode.yml @@ -2,7 +2,7 @@ version: '3.8' services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.4 + image: wallarm/api-firewall:v0.9.5 restart: on-failure environment: APIFW_MODE: "graphql" diff --git a/demo/docker-compose/docker-compose.yml b/demo/docker-compose/docker-compose.yml index 1e72822..22449f7 100644 --- a/demo/docker-compose/docker-compose.yml +++ b/demo/docker-compose/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.4 + image: wallarm/api-firewall:v0.9.5 restart: on-failure environment: APIFW_URL: "http://0.0.0.0:8080" diff --git a/demo/kubernetes/volumes/helm/api-firewall.yaml b/demo/kubernetes/volumes/helm/api-firewall.yaml index 3ab1196..f53b299 100644 --- a/demo/kubernetes/volumes/helm/api-firewall.yaml +++ b/demo/kubernetes/volumes/helm/api-firewall.yaml @@ -10,7 +10,7 @@ manifest: "url": "https://kennethreitz.org", "email": "me@kennethreitz.org" }, - "version": "0.9.4" + "version": "0.9.5" }, "servers": [ { diff --git a/docs/configuration-guides/allowlist.md b/docs/configuration-guides/allowlist.md index 402a431..4a17dfc 100644 --- a/docs/configuration-guides/allowlist.md +++ b/docs/configuration-guides/allowlist.md @@ -33,7 +33,7 @@ docker run --rm -it --network api-firewall-network --network-alias api-firewall -e APIFW_URL= -e APIFW_SERVER_URL= \ -e APIFW_REQUEST_VALIDATION= -e APIFW_RESPONSE_VALIDATION= \ -e APIFW_ALLOW_IP_FILE=/opt/ip-allowlist.txt -e APIFW_ALLOW_IP_HEADER_NAME="X-Real-IP" \ - -p 8088:8088 wallarm/api-firewall:v0.9.4 + -p 8088:8088 wallarm/api-firewall:v0.9.5 ``` | Environment variable | Description | diff --git a/docs/installation-guides/api-mode.md b/docs/installation-guides/api-mode.md index bf0b526..970f386 100644 --- a/docs/installation-guides/api-mode.md +++ b/docs/installation-guides/api-mode.md @@ -38,7 +38,7 @@ Use the following command to run the API Firewall container: ``` docker run --rm -it -v :/var/lib/wallarm-api/1/wallarm_api.db \ - -e APIFW_MODE=API -p 8282:8282 wallarm/api-firewall:v0.9.4 + -e APIFW_MODE=API -p 8282:8282 wallarm/api-firewall:v0.9.5 ``` You can pass to the container the following variables: diff --git a/docs/installation-guides/docker-container.md b/docs/installation-guides/docker-container.md index fadc075..e9aa6ab 100644 --- a/docs/installation-guides/docker-container.md +++ b/docs/installation-guides/docker-container.md @@ -27,7 +27,7 @@ networks: services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.4 + image: wallarm/api-firewall:v0.9.5 restart: on-failure volumes: - : @@ -171,6 +171,6 @@ To start API Firewall on Docker, you can also use regular Docker commands as in -v : -e APIFW_API_SPECS= \ -e APIFW_URL= -e APIFW_SERVER_URL= \ -e APIFW_REQUEST_VALIDATION= -e APIFW_RESPONSE_VALIDATION= \ - -p 8088:8088 wallarm/api-firewall:v0.9.4 + -p 8088:8088 wallarm/api-firewall:v0.9.5 ``` 4. When the environment is started, test it and enable traffic on API Firewall following steps 6 and 7. diff --git a/docs/installation-guides/graphql/docker-container.md b/docs/installation-guides/graphql/docker-container.md index 02e353f..a0c14c0 100644 --- a/docs/installation-guides/graphql/docker-container.md +++ b/docs/installation-guides/graphql/docker-container.md @@ -29,7 +29,7 @@ networks: services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.4 + image: wallarm/api-firewall:v0.9.5 restart: on-failure volumes: - : @@ -200,6 +200,6 @@ To start API Firewall on Docker, you can also use regular Docker commands as in -e APIFW_GRAPHQL_MAX_QUERY_COMPLEXITY= \ -e APIFW_GRAPHQL_MAX_QUERY_DEPTH= -e APIFW_GRAPHQL_NODE_COUNT_LIMIT= \ -e APIFW_GRAPHQL_INTROSPECTION= \ - -p 8088:8088 wallarm/api-firewall:v0.9.4 + -p 8088:8088 wallarm/api-firewall:v0.9.5 ``` 4. When the environment is started, test it and enable traffic on API Firewall following steps 6 and 7. diff --git a/docs/release-notes.md b/docs/release-notes.md index 543fe36..9c813ca 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,6 +2,11 @@ This page describes new releases of Wallarm API Firewall. +## v0.9.5 (2025-12-05) + +* Upgrade Go to 1.24.11 +* Fix the unknown parameters detection issue + ## v0.9.4 (2025-11-28) * Upgrade Go to 1.24.10 diff --git a/helm/api-firewall/Chart.yaml b/helm/api-firewall/Chart.yaml index 0e39a48..0f3977e 100644 --- a/helm/api-firewall/Chart.yaml +++ b/helm/api-firewall/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v1 name: api-firewall version: 0.7.2 -appVersion: 0.9.4 +appVersion: 0.9.5 description: Wallarm OpenAPI-based API Firewall home: https://github.com/wallarm/api-firewall icon: https://static.wallarm.com/wallarm-logo.svg diff --git a/internal/platform/validator/unknown_parameters_request.go b/internal/platform/validator/unknown_parameters_request.go index b4d62b8..8c848f3 100644 --- a/internal/platform/validator/unknown_parameters_request.go +++ b/internal/platform/validator/unknown_parameters_request.go @@ -24,6 +24,16 @@ var ErrUnknownBodyParameter = errors.New("body parameter not defined in the Open // ErrDecodingFailed is returned when the API FW got error or unexpected value from the decoder var ErrDecodingFailed = errors.New("the decoder returned the error") +// ErrMaxDepthExceeded is returned when the JSON body exceeds the maximum allowed nesting depth +var ErrMaxDepthExceeded = errors.New("maximum JSON nesting depth exceeded") + +const ( + // maxJSONArrayDepth is the maximum allowed nesting depth for JSON arrays + maxJSONArrayDepth = 32 + // maxJSONArrayElements is the maximum number of array elements to process + maxJSONArrayElements = 10000 +) + // RequestParameterDetails contains details about found unknown parameter type RequestParameterDetails struct { Name string `json:"name"` @@ -74,6 +84,50 @@ func identifyData(data any) string { return "unknown" } +// findUnknownParamsInJSONBody searches for unknown parameters in the body. +// The function covers array of objects and single object. +// The depth parameter limits recursion to prevent stack overflow from deeply nested arrays. +func findUnknownParamsInJSONBody(decodedBody any, contentType *openapi3.MediaType, depth int) ([]RequestParameterDetails, error) { + if depth > maxJSONArrayDepth { + return nil, ErrMaxDepthExceeded + } + + var unknownParameters []RequestParameterDetails + + paramLists, ok := decodedBody.([]interface{}) + if ok { + // Limit the number of array elements to process + if len(paramLists) > maxJSONArrayElements { + paramLists = paramLists[:maxJSONArrayElements] + } + for _, paramList := range paramLists { + currentUnknownParameters, err := findUnknownParamsInJSONBody(paramList, contentType, depth+1) + if err != nil { + return nil, err + } + unknownParameters = append(unknownParameters, currentUnknownParameters...) + } + return unknownParameters, nil + } + + paramList, ok := decodedBody.(map[string]any) + if !ok { + return unknownParameters, ErrDecodingFailed + } + + for paramName := range paramList { + if _, found := contentType.Schema.Value.Properties[paramName]; !found { + unknownParameters = append(unknownParameters, RequestParameterDetails{ + Name: paramName, + Placeholder: "body", + Type: identifyData(paramList[paramName]), + }) + } + } + + return unknownParameters, nil +} + // ValidateUnknownRequestParameters is used to get a list of request parameters that are not specified in the OpenAPI specification func ValidateUnknownRequestParameters(ctx *fasthttp.RequestCtx, route *routers.Route, header http.Header, jsonParser *fastjson.Parser) (foundUnknownParams []RequestUnknownParameterError, valError error) { @@ -193,21 +247,16 @@ func ValidateUnknownRequestParameters(ctx *fasthttp.RequestCtx, route *routers.R } }) case mType == "application/json" || mType == "multipart/form-data" || suffix == "+json": - paramList, ok := value.(map[string]any) - if !ok { - return foundUnknownParams, nil - } - for paramName := range paramList { - if _, found := contentType.Schema.Value.Properties[paramName]; !found { - unknownBodyParams.Message = ErrUnknownBodyParameter.Error() - unknownBodyParams.Parameters = append(unknownBodyParams.Parameters, RequestParameterDetails{ - Name: paramName, - Placeholder: "body", - Type: identifyData(paramList[paramName]), - }) - } + currentUBParams, err := findUnknownParamsInJSONBody(value, contentType, 0) + if err != nil { + return foundUnknownParams, err + } + if len(currentUBParams) > 0 { + unknownBodyParams.Message = ErrUnknownBodyParameter.Error() + unknownBodyParams.Parameters = append(unknownBodyParams.Parameters, currentUBParams...) } + case mType == "application/xml" || suffix == "+xml": var propKeys []string for key := range contentType.Schema.Value.Properties { diff --git a/internal/platform/validator/unknown_parameters_request_test.go b/internal/platform/validator/unknown_parameters_request_test.go index fd31274..23b33fa 100644 --- a/internal/platform/validator/unknown_parameters_request_test.go +++ b/internal/platform/validator/unknown_parameters_request_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -116,6 +117,7 @@ paths: requestBody *testRequestBody ct string url string + array bool } tests := []struct { name string @@ -219,6 +221,26 @@ paths: }, }, }, + { + name: "Unknown JSON param in array", + args: args{ + requestBody: &testRequestBody{SubCategory: "Chocolate", Category: &categoryFood, UnknownParameter: "test"}, + url: "/category?category=cookies", + ct: "application/json", + array: true, + }, + expectedErr: nil, + expectedResp: []*RequestUnknownParameterError{ + { + Parameters: []RequestParameterDetails{{ + Name: "unknown", + Placeholder: "body", + Type: "string", + }}, + Message: ErrUnknownBodyParameter.Error(), + }, + }, + }, { name: "Unknown POST param", args: args{ @@ -399,7 +421,14 @@ paths: } requestBody = strings.NewReader(req.PostArgs().String()) case "application/json": - testingBody, err := json.Marshal(tc.args.requestBody) + var err error + var testingBody []byte + if tc.args.array { + testingBody, err = json.Marshal([]testRequestBody{*tc.args.requestBody}) + } else { + testingBody, err = json.Marshal(tc.args.requestBody) + } + require.NoError(t, err) requestBody = bytes.NewReader(testingBody) case "application/xml": @@ -513,3 +542,51 @@ func matchUnknownParamsResp(expected []*RequestUnknownParameterError, actual []R return true } + +func TestFindUnknownParamsInJSONBody_DepthLimit(t *testing.T) { + schema := &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Properties: openapi3.Schemas{ + "field": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + }, + }, + }, + } + + // Create deeply nested array that exceeds maxJSONArrayDepth (32) + var nested any = map[string]any{"field": "value"} + for i := 0; i < 35; i++ { + nested = []interface{}{nested} + } + + _, err := findUnknownParamsInJSONBody(nested, schema, 0) + assert.ErrorIs(t, err, ErrMaxDepthExceeded, "expected ErrMaxDepthExceeded for deeply nested array") +} + +func TestFindUnknownParamsInJSONBody_ElementLimit(t *testing.T) { + schema := &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Properties: openapi3.Schemas{ + "field": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + }, + }, + }, + } + + // Create array with more than maxJSONArrayElements (10000) elements + largeArray := make([]interface{}, 15000) + for i := 0; i < 15000; i++ { + largeArray[i] = map[string]any{"unknown": "value"} + } + + result, err := findUnknownParamsInJSONBody(largeArray, schema, 0) + require.NoError(t, err) + // Should only process maxJSONArrayElements (10000) elements + assert.LessOrEqual(t, len(result), maxJSONArrayElements, "should limit processed elements to maxJSONArrayElements") +} diff --git a/resources/test/docker-compose-api-mode.yml b/resources/test/docker-compose-api-mode.yml index 35f403e..93247a1 100644 --- a/resources/test/docker-compose-api-mode.yml +++ b/resources/test/docker-compose-api-mode.yml @@ -2,7 +2,7 @@ version: '3.8' services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.9.4 + image: wallarm/api-firewall:v0.9.5 build: context: ../../ dockerfile: Dockerfile