From 87036c18a9faa534bd0bc040b34e7acc3c8f2ef0 Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 14:33:15 -0500 Subject: [PATCH 1/9] Add golden-file snapshot tests and reserve rule-id headroom GoldenFileTest compares generated .conf/schema.json for every sample spec and engine flavor against checked-in goldens (regenerate with -DupdateGoldenFiles=true). MODSECURITY_INDEX_MAX 30->40 and PROP_INDEX_MAX 6->12 reserve id slots for upcoming rule types so ids stay stable. Co-Authored-By: Claude Fable 5 --- pom.xml | 6 + .../modsecurity3/Modsecurity3Generator.java | 4 +- .../modsecurity3/tests/GoldenFileTest.java | 101 ++++++ .../golden/composed/coraza/DefaultApi.conf | 115 +++++++ .../golden/composed/coraza/mainconfig.conf | 20 ++ .../golden/composed/coraza/schema.json | 95 ++++++ .../composed/modsecurity3/DefaultApi.conf | 109 +++++++ .../composed/modsecurity3/mainconfig.conf | 20 ++ .../golden/composed/modsecurity3/schema.json | 95 ++++++ .../golden/getparam/coraza/PetApi.conf | 95 ++++++ .../golden/getparam/coraza/mainconfig.conf | 20 ++ .../golden/getparam/coraza/schema.json | 154 +++++++++ .../golden/getparam/modsecurity3/PetApi.conf | 95 ++++++ .../getparam/modsecurity3/mainconfig.conf | 20 ++ .../golden/getparam/modsecurity3/schema.json | 154 +++++++++ .../golden/petstore/coraza/PetApi.conf | 292 ++++++++++++++++++ .../golden/petstore/coraza/StoreApi.conf | 131 ++++++++ .../golden/petstore/coraza/UserApi.conf | 288 +++++++++++++++++ .../golden/petstore/coraza/mainconfig.conf | 22 ++ .../golden/petstore/coraza/schema.json | 154 +++++++++ .../golden/petstore/modsecurity3/PetApi.conf | 286 +++++++++++++++++ .../petstore/modsecurity3/StoreApi.conf | 128 ++++++++ .../golden/petstore/modsecurity3/UserApi.conf | 276 +++++++++++++++++ .../petstore/modsecurity3/mainconfig.conf | 22 ++ .../golden/petstore/modsecurity3/schema.json | 154 +++++++++ .../golden/urlintparam/coraza/PetApi.conf | 137 ++++++++ .../golden/urlintparam/coraza/mainconfig.conf | 20 ++ .../golden/urlintparam/coraza/schema.json | 154 +++++++++ .../urlintparam/modsecurity3/PetApi.conf | 131 ++++++++ .../urlintparam/modsecurity3/mainconfig.conf | 20 ++ .../urlintparam/modsecurity3/schema.json | 154 +++++++++ 31 files changed, 3470 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java create mode 100644 src/test/resources/golden/composed/coraza/DefaultApi.conf create mode 100644 src/test/resources/golden/composed/coraza/mainconfig.conf create mode 100644 src/test/resources/golden/composed/coraza/schema.json create mode 100644 src/test/resources/golden/composed/modsecurity3/DefaultApi.conf create mode 100644 src/test/resources/golden/composed/modsecurity3/mainconfig.conf create mode 100644 src/test/resources/golden/composed/modsecurity3/schema.json create mode 100644 src/test/resources/golden/getparam/coraza/PetApi.conf create mode 100644 src/test/resources/golden/getparam/coraza/mainconfig.conf create mode 100644 src/test/resources/golden/getparam/coraza/schema.json create mode 100644 src/test/resources/golden/getparam/modsecurity3/PetApi.conf create mode 100644 src/test/resources/golden/getparam/modsecurity3/mainconfig.conf create mode 100644 src/test/resources/golden/getparam/modsecurity3/schema.json create mode 100644 src/test/resources/golden/petstore/coraza/PetApi.conf create mode 100644 src/test/resources/golden/petstore/coraza/StoreApi.conf create mode 100644 src/test/resources/golden/petstore/coraza/UserApi.conf create mode 100644 src/test/resources/golden/petstore/coraza/mainconfig.conf create mode 100644 src/test/resources/golden/petstore/coraza/schema.json create mode 100644 src/test/resources/golden/petstore/modsecurity3/PetApi.conf create mode 100644 src/test/resources/golden/petstore/modsecurity3/StoreApi.conf create mode 100644 src/test/resources/golden/petstore/modsecurity3/UserApi.conf create mode 100644 src/test/resources/golden/petstore/modsecurity3/mainconfig.conf create mode 100644 src/test/resources/golden/petstore/modsecurity3/schema.json create mode 100644 src/test/resources/golden/urlintparam/coraza/PetApi.conf create mode 100644 src/test/resources/golden/urlintparam/coraza/mainconfig.conf create mode 100644 src/test/resources/golden/urlintparam/coraza/schema.json create mode 100644 src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf create mode 100644 src/test/resources/golden/urlintparam/modsecurity3/mainconfig.conf create mode 100644 src/test/resources/golden/urlintparam/modsecurity3/schema.json diff --git a/pom.xml b/pom.xml index 0004300..d14d486 100644 --- a/pom.xml +++ b/pom.xml @@ -223,6 +223,12 @@ ${junit-version} test + + org.junit.jupiter + junit-jupiter-params + ${junit-version} + test + com.fasterxml.jackson.core jackson-databind diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index ccae54f..7dbbf1c 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -179,7 +179,7 @@ private String buildDenyActionDirective() { private static final Logger LOGGER = LoggerFactory.getLogger(Modsecurity3Generator.class); private static final String MODSECURITY_INDEX_KEY = "x-codegen-globalIndex"; - private static final int MODSECURITY_INDEX_MAX = 30; + private static final int MODSECURITY_INDEX_MAX = 40; private static final String MODSECURITY_PATH_REGEX_KEY = "x-codegen-pathRegex"; private static final String VENDOR_EXTENSIONS_KEY = "vendorExtensions"; private static final String MODSECURITY_HAS_ARRAY_MIN = "x-codegen-hasArrayMin"; @@ -199,7 +199,7 @@ private String buildDenyActionDirective() { // this fragment matches either so generated selectors work on both engines. private static final String ARRAY_INDEX_REGEX = "(?:array_)?\\d{1,9}"; private static final String PROP_INDEX_KEY = "x-codegen-propIndex"; - private static final int PROP_INDEX_MAX = 6; + private static final int PROP_INDEX_MAX = 12; private static final int MAX_FLATTEN_DEPTH = 5; diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java new file mode 100644 index 0000000..2ec9ef7 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java @@ -0,0 +1,101 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; + +/** + * Golden-file snapshot tests: the generated rule and schema text for every sample + * spec and engine flavor is compared against checked-in golden files, so any change + * to generated output shows up as a reviewable diff. + * + * To regenerate after an intentional change: mvn test -Dtest=GoldenFileTest -DupdateGoldenFiles=true + */ +public class GoldenFileTest { + + private static final Path GOLDEN_ROOT = Paths.get("src/test/resources/golden"); + private static final List SAMPLES = List.of("petstore", "composed", "getparam", "urlintparam"); + private static final List FLAVORS = List.of("modsecurity3", "coraza"); + + static Stream cases() { + return SAMPLES.stream().flatMap(sample -> FLAVORS.stream().map(flavor -> Arguments.of(sample, flavor))); + } + + @ParameterizedTest(name = "{0}/{1}") + @MethodSource("cases") + void generatedOutputMatchesGolden(String sample, String flavor, @TempDir Path outputDir) throws IOException { + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("samples/" + sample + ".yaml") + .setOutputDir(outputDir.toString()) + .addAdditionalProperty("engineFlavor", flavor) + .toClientOptInput()) + .generate(); + + List generated; + try (Stream files = Files.list(outputDir)) { + generated = files + .filter(p -> p.getFileName().toString().endsWith(".conf") + || p.getFileName().toString().endsWith(".json")) + .sorted() + .collect(Collectors.toList()); + } + assertFalse(generated.isEmpty(), "generator produced no .conf/.json files"); + + Path goldenDir = GOLDEN_ROOT.resolve(sample).resolve(flavor); + if (Boolean.getBoolean("updateGoldenFiles")) { + updateGoldenFiles(goldenDir, generated); + return; + } + + assertTrue(Files.isDirectory(goldenDir), + "Missing golden directory " + goldenDir + "; create it with -DupdateGoldenFiles=true"); + List goldenNames; + try (Stream files = Files.list(goldenDir)) { + goldenNames = files.map(p -> p.getFileName().toString()).sorted().collect(Collectors.toList()); + } + List generatedNames = generated.stream() + .map(p -> p.getFileName().toString()) + .collect(Collectors.toList()); + assertEquals(goldenNames, generatedNames, + "generated file set differs from golden for " + sample + "/" + flavor); + + for (Path p : generated) { + String actual = normalize(Files.readString(p)); + String expected = normalize(Files.readString(goldenDir.resolve(p.getFileName().toString()))); + assertEquals(expected, actual, "golden mismatch: " + sample + "/" + flavor + "/" + p.getFileName()); + } + } + + private static void updateGoldenFiles(Path goldenDir, List generated) throws IOException { + Files.createDirectories(goldenDir); + try (Stream old = Files.list(goldenDir)) { + for (Path p : old.collect(Collectors.toList())) { + Files.delete(p); + } + } + for (Path p : generated) { + Files.copy(p, goldenDir.resolve(p.getFileName().toString())); + } + } + + private static String normalize(String s) { + return s.replace("\r\n", "\n"); + } +} diff --git a/src/test/resources/golden/composed/coraza/DefaultApi.conf b/src/test/resources/golden/composed/coraza/DefaultApi.conf new file mode 100644 index 0000000..463b212 --- /dev/null +++ b/src/test/resources/golden/composed/coraza/DefaultApi.conf @@ -0,0 +1,115 @@ + +# addContact: POST /contact +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/contact$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addContact" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addContact" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.name|json\.id|json\.contactMethod|json\.contactMethod\.email|json\.contactMethod\.phone)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addContact" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210001,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210002,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^(?:[0-9]{1,19}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" "id:4210014,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.contactMethod.email "!@rx ^([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})$" "id:4210026,phase:2,block,msg:'Invalid value for property json.contactMethod.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.contactMethod.phone "!@rx ^[0-9]{10}$" "id:4210038,phase:2,block,msg:'Invalid value for property json.contactMethod.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200018,phase:2,block,msg:'JSON schema validation failed for addContact',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addContact" + +SecMarker ENDJSON_addContact + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_addContact + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_addContact + +# addDog: POST /dog +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/dog$" "id:4200042,phase:2,pass,nolog,skipAfter:END_addDog" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200043,phase:2,pass,nolog,skipAfter:END_addDog" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.species|json\.breed)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200056,phase:2,pass,nolog,skipAfter:ENDJSON_addDog" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200057,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.species "@eq 0" "id:4210089,phase:2,block,msg:'Missing required property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.species "!@rx ^.+$" "id:4210090,phase:2,block,msg:'Invalid value for property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.breed "!@rx ^.+$" "id:4210102,phase:2,block,msg:'Invalid value for property json.breed',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200058,phase:2,block,msg:'JSON schema validation failed for addDog',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200059,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addDog" + +SecMarker ENDJSON_addDog + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_addDog + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_addDog + +# findItems: GET /items +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/items$" "id:4200082,phase:2,pass,nolog,skipAfter:END_findItems" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findItems" + +SecRule ARGS_GET:code "!@rx ^(?:[0-9]{1,19}|(red|green|blue))?$" "id:4210158,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:code "@gt 1" "id:4210159,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:code)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findItems" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findItems + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_findItems diff --git a/src/test/resources/golden/composed/coraza/mainconfig.conf b/src/test/resources/golden/composed/coraza/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/composed/coraza/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/composed/coraza/schema.json b/src/test/resources/golden/composed/coraza/schema.json new file mode 100644 index 0000000..128233b --- /dev/null +++ b/src/test/resources/golden/composed/coraza/schema.json @@ -0,0 +1,95 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "Animal" : { + "title" : "Animal", + "type" : "object", + "properties" : { + "species" : { + "type" : "string" + } + }, + "required" : [ "species" ] + }, + "Contact" : { + "title" : "Contact", + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "id" : { + "$ref" : "#/definitions/Contact_id" + }, + "contactMethod" : { + "$ref" : "#/definitions/Contact_contactMethod" + } + }, + "required" : [ "name" ] + }, + "Contact_contactMethod" : { + "title" : "Contact_contactMethod", + "oneOf" : [ { + "$ref" : "#/definitions/EmailContact" + }, { + "$ref" : "#/definitions/PhoneContact" + } ] + }, + "Contact_id" : { + "title" : "Contact_id", + "anyOf" : [ { + "type" : "integer" + }, { + "type" : "string", + "format" : "uuid" + } ] + }, + "Dog" : { + "title" : "Dog", + "type" : "object", + "properties" : { + "species" : { + "type" : "string" + }, + "breed" : { + "type" : "string" + } + }, + "required" : [ "species" ] + }, + "EmailContact" : { + "title" : "EmailContact", + "type" : "object", + "properties" : { + "email" : { + "type" : "string", + "format" : "email" + } + }, + "required" : [ "email" ] + }, + "findItems_code_parameter" : { + "title" : "findItems_code_parameter", + "oneOf" : [ { + "type" : "integer" + }, { + "type" : "string", + "enum" : [ "red", "green", "blue" ] + } ] + }, + "PhoneContact" : { + "title" : "PhoneContact", + "type" : "object", + "properties" : { + "phone" : { + "type" : "string", + "pattern" : "^[0-9]{10}$" + } + }, + "required" : [ "phone" ] + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/composed/modsecurity3/DefaultApi.conf b/src/test/resources/golden/composed/modsecurity3/DefaultApi.conf new file mode 100644 index 0000000..d4e50e9 --- /dev/null +++ b/src/test/resources/golden/composed/modsecurity3/DefaultApi.conf @@ -0,0 +1,109 @@ + +# addContact: POST /contact +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/contact$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addContact" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addContact" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.name|json\.id|json\.contactMethod|json\.contactMethod\.email|json\.contactMethod\.phone)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addContact" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210001,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210002,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^(?:[0-9]{1,19}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" "id:4210014,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.contactMethod.email "!@rx ^([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})$" "id:4210026,phase:2,block,msg:'Invalid value for property json.contactMethod.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.contactMethod.phone "!@rx ^[0-9]{10}$" "id:4210038,phase:2,block,msg:'Invalid value for property json.contactMethod.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addContact" + +SecMarker ENDJSON_addContact + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_addContact + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_addContact + +# addDog: POST /dog +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/dog$" "id:4200042,phase:2,pass,nolog,skipAfter:END_addDog" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200043,phase:2,pass,nolog,skipAfter:END_addDog" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.species|json\.breed)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200056,phase:2,pass,nolog,skipAfter:ENDJSON_addDog" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200057,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.species "@eq 0" "id:4210089,phase:2,block,msg:'Missing required property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.species "!@rx ^.+$" "id:4210090,phase:2,block,msg:'Invalid value for property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.breed "!@rx ^.+$" "id:4210102,phase:2,block,msg:'Invalid value for property json.breed',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200059,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addDog" + +SecMarker ENDJSON_addDog + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_addDog + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_addDog + +# findItems: GET /items +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/items$" "id:4200082,phase:2,pass,nolog,skipAfter:END_findItems" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findItems" + +SecRule ARGS_GET:code "!@rx ^(?:[0-9]{1,19}|(red|green|blue))?$" "id:4210158,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:code "@gt 1" "id:4210159,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:code)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findItems" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findItems + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_findItems diff --git a/src/test/resources/golden/composed/modsecurity3/mainconfig.conf b/src/test/resources/golden/composed/modsecurity3/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/composed/modsecurity3/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/composed/modsecurity3/schema.json b/src/test/resources/golden/composed/modsecurity3/schema.json new file mode 100644 index 0000000..128233b --- /dev/null +++ b/src/test/resources/golden/composed/modsecurity3/schema.json @@ -0,0 +1,95 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "Animal" : { + "title" : "Animal", + "type" : "object", + "properties" : { + "species" : { + "type" : "string" + } + }, + "required" : [ "species" ] + }, + "Contact" : { + "title" : "Contact", + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "id" : { + "$ref" : "#/definitions/Contact_id" + }, + "contactMethod" : { + "$ref" : "#/definitions/Contact_contactMethod" + } + }, + "required" : [ "name" ] + }, + "Contact_contactMethod" : { + "title" : "Contact_contactMethod", + "oneOf" : [ { + "$ref" : "#/definitions/EmailContact" + }, { + "$ref" : "#/definitions/PhoneContact" + } ] + }, + "Contact_id" : { + "title" : "Contact_id", + "anyOf" : [ { + "type" : "integer" + }, { + "type" : "string", + "format" : "uuid" + } ] + }, + "Dog" : { + "title" : "Dog", + "type" : "object", + "properties" : { + "species" : { + "type" : "string" + }, + "breed" : { + "type" : "string" + } + }, + "required" : [ "species" ] + }, + "EmailContact" : { + "title" : "EmailContact", + "type" : "object", + "properties" : { + "email" : { + "type" : "string", + "format" : "email" + } + }, + "required" : [ "email" ] + }, + "findItems_code_parameter" : { + "title" : "findItems_code_parameter", + "oneOf" : [ { + "type" : "integer" + }, { + "type" : "string", + "enum" : [ "red", "green", "blue" ] + } ] + }, + "PhoneContact" : { + "title" : "PhoneContact", + "type" : "object", + "properties" : { + "phone" : { + "type" : "string", + "pattern" : "^[0-9]{10}$" + } + }, + "required" : [ "phone" ] + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/getparam/coraza/PetApi.conf b/src/test/resources/golden/getparam/coraza/PetApi.conf new file mode 100644 index 0000000..46bf4c0 --- /dev/null +++ b/src/test/resources/golden/getparam/coraza/PetApi.conf @@ -0,0 +1,95 @@ + +# findPetsByStatus: GET /pet/findByStatus +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/findByStatus$" "id:4200002,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200003,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" + +SecRule ARGS_GET:status "!@rx ^(available|pending|sold)$" "id:4210006,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:status "@lt 1" "id:4210008,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200025,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findPetsByStatus + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_findPetsByStatus + +# findPetsByTags: GET /pet/findByTags +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/findByTags$" "id:4200042,phase:2,pass,nolog,skipAfter:END_findPetsByTags" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_findPetsByTags" + +SecRule ARGS_GET:tags "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:tags "@lt 1" "id:4210048,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findPetsByTags + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_findPetsByTags + +# listPets: GET /pets +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pets$" "id:4200082,phase:2,pass,nolog,skipAfter:END_listPets" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_listPets" + +SecRule ARGS_GET:limit "!@rx ^([0-9]{1,19})?$" "id:4210086,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:limit "@gt 1" "id:4210087,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:limit "@lt 1" "id:4210096,phase:2,block,msg:'Parameter value below minimum',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:limit "@gt 100" "id:4210097,phase:2,block,msg:'Parameter value above maximum',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:limit)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_listPets" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_listPets + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_listPets diff --git a/src/test/resources/golden/getparam/coraza/mainconfig.conf b/src/test/resources/golden/getparam/coraza/mainconfig.conf new file mode 100644 index 0000000..36167cd --- /dev/null +++ b/src/test/resources/golden/getparam/coraza/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include PetApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/getparam/coraza/schema.json b/src/test/resources/golden/getparam/coraza/schema.json new file mode 100644 index 0000000..05c6a88 --- /dev/null +++ b/src/test/resources/golden/getparam/coraza/schema.json @@ -0,0 +1,154 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "ApiResponse" : { + "title" : "An uploaded response", + "description" : "Describes the result of uploading an image resource", + "type" : "object", + "properties" : { + "code" : { + "type" : "integer", + "format" : "int32" + }, + "type" : { + "type" : "string" + }, + "message" : { + "type" : "string" + } + } + }, + "Category" : { + "title" : "Pet category", + "description" : "A category for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string", + "pattern" : "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$" + } + } + }, + "Order" : { + "title" : "Pet Order", + "description" : "An order for a pets from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "petId" : { + "type" : "integer", + "format" : "int64" + }, + "quantity" : { + "type" : "integer", + "format" : "int32" + }, + "shipDate" : { + "type" : "string", + "format" : "date-time" + }, + "status" : { + "type" : "string", + "description" : "Order Status", + "enum" : [ "placed", "approved", "delivered" ] + }, + "complete" : { + "type" : "boolean" + } + } + }, + "Pet" : { + "title" : "a Pet", + "description" : "A pet for sale in the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "category" : { + "$ref" : "#/definitions/Category" + }, + "name" : { + "type" : "string" + }, + "photoUrls" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "tags" : { + "type" : "array", + "items" : { + "$ref" : "#/definitions/Tag" + } + }, + "status" : { + "type" : "string", + "description" : "pet status in the store", + "enum" : [ "available", "pending", "sold" ] + } + }, + "required" : [ "name", "photoUrls" ] + }, + "Tag" : { + "title" : "Pet Tag", + "description" : "A tag for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + } + } + }, + "User" : { + "title" : "a User", + "description" : "A User who is purchasing from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "username" : { + "type" : "string" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "password" : { + "type" : "string" + }, + "phone" : { + "type" : "string" + }, + "userStatus" : { + "type" : "integer", + "description" : "User Status", + "format" : "int32" + } + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/getparam/modsecurity3/PetApi.conf b/src/test/resources/golden/getparam/modsecurity3/PetApi.conf new file mode 100644 index 0000000..46bf4c0 --- /dev/null +++ b/src/test/resources/golden/getparam/modsecurity3/PetApi.conf @@ -0,0 +1,95 @@ + +# findPetsByStatus: GET /pet/findByStatus +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/findByStatus$" "id:4200002,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200003,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" + +SecRule ARGS_GET:status "!@rx ^(available|pending|sold)$" "id:4210006,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:status "@lt 1" "id:4210008,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200025,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findPetsByStatus + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_findPetsByStatus + +# findPetsByTags: GET /pet/findByTags +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/findByTags$" "id:4200042,phase:2,pass,nolog,skipAfter:END_findPetsByTags" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_findPetsByTags" + +SecRule ARGS_GET:tags "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:tags "@lt 1" "id:4210048,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findPetsByTags + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_findPetsByTags + +# listPets: GET /pets +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pets$" "id:4200082,phase:2,pass,nolog,skipAfter:END_listPets" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_listPets" + +SecRule ARGS_GET:limit "!@rx ^([0-9]{1,19})?$" "id:4210086,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:limit "@gt 1" "id:4210087,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:limit "@lt 1" "id:4210096,phase:2,block,msg:'Parameter value below minimum',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:limit "@gt 100" "id:4210097,phase:2,block,msg:'Parameter value above maximum',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:limit)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_listPets" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_listPets + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_listPets diff --git a/src/test/resources/golden/getparam/modsecurity3/mainconfig.conf b/src/test/resources/golden/getparam/modsecurity3/mainconfig.conf new file mode 100644 index 0000000..36167cd --- /dev/null +++ b/src/test/resources/golden/getparam/modsecurity3/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include PetApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/getparam/modsecurity3/schema.json b/src/test/resources/golden/getparam/modsecurity3/schema.json new file mode 100644 index 0000000..05c6a88 --- /dev/null +++ b/src/test/resources/golden/getparam/modsecurity3/schema.json @@ -0,0 +1,154 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "ApiResponse" : { + "title" : "An uploaded response", + "description" : "Describes the result of uploading an image resource", + "type" : "object", + "properties" : { + "code" : { + "type" : "integer", + "format" : "int32" + }, + "type" : { + "type" : "string" + }, + "message" : { + "type" : "string" + } + } + }, + "Category" : { + "title" : "Pet category", + "description" : "A category for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string", + "pattern" : "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$" + } + } + }, + "Order" : { + "title" : "Pet Order", + "description" : "An order for a pets from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "petId" : { + "type" : "integer", + "format" : "int64" + }, + "quantity" : { + "type" : "integer", + "format" : "int32" + }, + "shipDate" : { + "type" : "string", + "format" : "date-time" + }, + "status" : { + "type" : "string", + "description" : "Order Status", + "enum" : [ "placed", "approved", "delivered" ] + }, + "complete" : { + "type" : "boolean" + } + } + }, + "Pet" : { + "title" : "a Pet", + "description" : "A pet for sale in the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "category" : { + "$ref" : "#/definitions/Category" + }, + "name" : { + "type" : "string" + }, + "photoUrls" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "tags" : { + "type" : "array", + "items" : { + "$ref" : "#/definitions/Tag" + } + }, + "status" : { + "type" : "string", + "description" : "pet status in the store", + "enum" : [ "available", "pending", "sold" ] + } + }, + "required" : [ "name", "photoUrls" ] + }, + "Tag" : { + "title" : "Pet Tag", + "description" : "A tag for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + } + } + }, + "User" : { + "title" : "a User", + "description" : "A User who is purchasing from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "username" : { + "type" : "string" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "password" : { + "type" : "string" + }, + "phone" : { + "type" : "string" + }, + "userStatus" : { + "type" : "integer", + "description" : "User Status", + "format" : "int32" + } + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/petstore/coraza/PetApi.conf b/src/test/resources/golden/petstore/coraza/PetApi.conf new file mode 100644 index 0000000..c53e74f --- /dev/null +++ b/src/test/resources/golden/petstore/coraza/PetApi.conf @@ -0,0 +1,292 @@ + +# addPet: POST /pet +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addPet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addPet" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210002,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210014,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210026,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210037,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210038,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210050,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210062,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210086,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200018,phase:2,block,msg:'JSON schema validation failed for addPet',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDJSON_addPet +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200020,phase:2,pass,nolog,skipAfter:ENDXML_addPet" +SecRule REQBODY_ERROR "!@eq 0" "id:4200023,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# TODO: Implement XML schema validation (issue #14 covers JSON objects) +SecAction "id:4200024,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDXML_addPet + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_addPet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_addPet + +# deletePet: DELETE /pet/{petId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_deletePet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within DELETE" "id:4200043,phase:2,pass,nolog,skipAfter:END_deletePet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deletePet" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_deletePet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_deletePet + +# findPetsByStatus: GET /pet/findByStatus +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/findByStatus$" "id:4200082,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" + +SecRule ARGS_GET:status "!@rx ^(available|pending|sold)$" "id:4210222,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:status "@lt 1" "id:4210224,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findPetsByStatus + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_findPetsByStatus + +# findPetsByTags: GET /pet/findByTags +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/findByTags$" "id:4200122,phase:2,pass,nolog,skipAfter:END_findPetsByTags" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200123,phase:2,pass,nolog,skipAfter:END_findPetsByTags" + +SecRule ARGS_GET:tags "!@rx ^.+$" "id:4210262,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:tags "@lt 1" "id:4210264,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200134,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200145,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200141,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findPetsByTags + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200142" + +## End of checks for this operation +SecMarker END_findPetsByTags + +# getPetById: GET /pet/{petId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200162,phase:2,pass,nolog,skipAfter:END_getPetById" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200163,phase:2,pass,nolog,skipAfter:END_getPetById" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200174,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200185,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200181,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getPetById + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200182" + +## End of checks for this operation +SecMarker END_getPetById + +# updatePet: PUT /pet +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200202,phase:2,pass,nolog,skipAfter:END_updatePet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within PUT" "id:4200203,phase:2,pass,nolog,skipAfter:END_updatePet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200214,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200216,phase:2,pass,nolog,skipAfter:ENDJSON_updatePet" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200217,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210338,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210350,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210362,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210373,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210374,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210386,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210398,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210410,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210422,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200218,phase:2,block,msg:'JSON schema validation failed for updatePet',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200219,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDJSON_updatePet +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200220,phase:2,pass,nolog,skipAfter:ENDXML_updatePet" +SecRule REQBODY_ERROR "!@eq 0" "id:4200223,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# TODO: Implement XML schema validation (issue #14 covers JSON objects) +SecAction "id:4200224,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDXML_updatePet + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200221,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_updatePet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200222" + +## End of checks for this operation +SecMarker END_updatePet + +# updatePetWithForm: POST /pet/{petId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200242,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200243,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" + +SecRule ARGS_POST:name "!@rx ^.*$" "id:4210522,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:name "@gt 1" "id:4210523,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:status "!@rx ^.*$" "id:4210562,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:status "@gt 1" "id:4210563,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:name|status)$" "id:4200254,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200261,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_updatePetWithForm + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200262" + +## End of checks for this operation +SecMarker END_updatePetWithForm + +# uploadFile: POST /pet/{petId}/uploadImage +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})/uploadImage$" "id:4200282,phase:2,pass,nolog,skipAfter:END_uploadFile" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200283,phase:2,pass,nolog,skipAfter:END_uploadFile" + +SecRule ARGS_POST:additionalMetadata "!@rx ^.*$" "id:4210642,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:additionalMetadata "@gt 1" "id:4210643,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:file "!@rx ^.*$" "id:4210682,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:file "@gt 1" "id:4210683,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:additionalMetadata|file)$" "id:4200294,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200301,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_uploadFile + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200302" + +## End of checks for this operation +SecMarker END_uploadFile diff --git a/src/test/resources/golden/petstore/coraza/StoreApi.conf b/src/test/resources/golden/petstore/coraza/StoreApi.conf new file mode 100644 index 0000000..49afc7c --- /dev/null +++ b/src/test/resources/golden/petstore/coraza/StoreApi.conf @@ -0,0 +1,131 @@ + +# deleteOrder: DELETE /store/order/{orderId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:.+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within DELETE" "id:4200324,phase:2,pass,nolog,skipAfter:END_deleteOrder" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200335,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200346,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteOrder" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200342,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_deleteOrder + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200343" + +## End of checks for this operation +SecMarker END_deleteOrder + +# getInventory: GET /store/inventory +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/store/inventory$" "id:4200363,phase:2,pass,nolog,skipAfter:END_getInventory" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200364,phase:2,pass,nolog,skipAfter:END_getInventory" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200375,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200386,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getInventory" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200382,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getInventory + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200383" + +## End of checks for this operation +SecMarker END_getInventory + +# getOrderById: GET /store/order/{orderId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:[0-9]{1,19})$" "id:4200403,phase:2,pass,nolog,skipAfter:END_getOrderById" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200404,phase:2,pass,nolog,skipAfter:END_getOrderById" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200415,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200426,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getOrderById" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200422,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getOrderById + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200423" + +## End of checks for this operation +SecMarker END_getOrderById + +# placeOrder: POST /store/order +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/store/order$" "id:4200443,phase:2,pass,nolog,skipAfter:END_placeOrder" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200444,phase:2,pass,nolog,skipAfter:END_placeOrder" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.petId|json\.quantity|json\.shipDate|json\.status|json\.complete)$" "id:4200455,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200457,phase:2,pass,nolog,skipAfter:ENDJSON_placeOrder" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200458,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210794,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.petId "!@rx ^[0-9]{1,19}$" "id:4210806,phase:2,block,msg:'Invalid value for property json.petId',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.quantity "!@rx ^[0-9]{1,19}$" "id:4210818,phase:2,block,msg:'Invalid value for property json.quantity',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.shipDate "!@rx ^(\d{4}-((01|03|05|07|08|10|12)-(0[1-9]|1\d|2\d|3[0-1])|(04|06|09|11)-(0[1-9]|1\d|2\d|30)|02-(0[1-9]|1\d|2\d))T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(\.\d+)?([Zz]|[+\-](0\d|1[0-4])(:[0-5]\d)?))$" "id:4210830,phase:2,block,msg:'Invalid value for property json.shipDate',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(placed|approved|delivered)$" "id:4210842,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.complete "!@rx ^(true|false)$" "id:4210854,phase:2,block,msg:'Invalid value for property json.complete',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200459,phase:2,block,msg:'JSON schema validation failed for placeOrder',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200460,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_placeOrder" + +SecMarker ENDJSON_placeOrder + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200462,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_placeOrder + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200463" + +## End of checks for this operation +SecMarker END_placeOrder diff --git a/src/test/resources/golden/petstore/coraza/UserApi.conf b/src/test/resources/golden/petstore/coraza/UserApi.conf new file mode 100644 index 0000000..36c78ab --- /dev/null +++ b/src/test/resources/golden/petstore/coraza/UserApi.conf @@ -0,0 +1,288 @@ + +# createUser: POST /user +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user$" "id:4200484,phase:2,pass,nolog,skipAfter:END_createUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200485,phase:2,pass,nolog,skipAfter:END_createUser" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.username|json\.firstName|json\.lastName|json\.email|json\.password|json\.phone|json\.userStatus)$" "id:4200496,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200498,phase:2,pass,nolog,skipAfter:ENDJSON_createUser" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200499,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210906,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4210918,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4210930,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4210942,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4210954,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4210966,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4210978,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4210990,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200500,phase:2,block,msg:'JSON schema validation failed for createUser',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200501,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUser" + +SecMarker ENDJSON_createUser + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200503,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_createUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200504" + +## End of checks for this operation +SecMarker END_createUser + +# createUsersWithArrayInput: POST /user/createWithArray +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/createWithArray$" "id:4200524,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200538,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithArrayInput" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200539,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200540,phase:2,block,msg:'JSON schema validation failed for createUsersWithArrayInput',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200541,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithArrayInput" + +SecMarker ENDJSON_createUsersWithArrayInput + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200543,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_createUsersWithArrayInput + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200544" + +## End of checks for this operation +SecMarker END_createUsersWithArrayInput + +# createUsersWithListInput: POST /user/createWithList +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/createWithList$" "id:4200564,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200578,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithListInput" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200579,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200580,phase:2,block,msg:'JSON schema validation failed for createUsersWithListInput',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200581,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithListInput" + +SecMarker ENDJSON_createUsersWithListInput + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200583,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_createUsersWithListInput + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200584" + +## End of checks for this operation +SecMarker END_createUsersWithListInput + +# deleteUser: DELETE /user/{username} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within DELETE" "id:4200605,phase:2,pass,nolog,skipAfter:END_deleteUser" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200616,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200627,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteUser" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200623,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_deleteUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200624" + +## End of checks for this operation +SecMarker END_deleteUser + +# getUserByName: GET /user/{username} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200645,phase:2,pass,nolog,skipAfter:END_getUserByName" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200656,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200667,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getUserByName" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200663,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getUserByName + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200664" + +## End of checks for this operation +SecMarker END_getUserByName + +# loginUser: GET /user/login +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/login$" "id:4200684,phase:2,pass,nolog,skipAfter:END_loginUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200685,phase:2,pass,nolog,skipAfter:END_loginUser" + +SecRule ARGS_GET:username "!@rx /^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$/" "id:4211206,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:username "@gt 1" "id:4211207,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:password "!@rx ^.+$" "id:4211246,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:password "@gt 1" "id:4211247,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:username|password)$" "id:4200696,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200707,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_loginUser" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200703,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_loginUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200704" + +## End of checks for this operation +SecMarker END_loginUser + +# logoutUser: GET /user/logout +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/logout$" "id:4200724,phase:2,pass,nolog,skipAfter:END_logoutUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200725,phase:2,pass,nolog,skipAfter:END_logoutUser" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200736,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200747,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_logoutUser" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200743,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_logoutUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200744" + +## End of checks for this operation +SecMarker END_logoutUser + +# updateUser: PUT /user/{username} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within PUT" "id:4200765,phase:2,pass,nolog,skipAfter:END_updateUser" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.username|json\.firstName|json\.lastName|json\.email|json\.password|json\.phone|json\.userStatus)$" "id:4200776,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200778,phase:2,pass,nolog,skipAfter:ENDJSON_updateUser" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200779,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211322,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4211334,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211346,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211358,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4211370,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4211382,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4211394,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211406,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200780,phase:2,block,msg:'JSON schema validation failed for updateUser',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200781,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updateUser" + +SecMarker ENDJSON_updateUser + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200783,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_updateUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200784" + +## End of checks for this operation +SecMarker END_updateUser diff --git a/src/test/resources/golden/petstore/coraza/mainconfig.conf b/src/test/resources/golden/petstore/coraza/mainconfig.conf new file mode 100644 index 0000000..b34513f --- /dev/null +++ b/src/test/resources/golden/petstore/coraza/mainconfig.conf @@ -0,0 +1,22 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include PetApi.conf +Include StoreApi.conf +Include UserApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/petstore/coraza/schema.json b/src/test/resources/golden/petstore/coraza/schema.json new file mode 100644 index 0000000..05c6a88 --- /dev/null +++ b/src/test/resources/golden/petstore/coraza/schema.json @@ -0,0 +1,154 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "ApiResponse" : { + "title" : "An uploaded response", + "description" : "Describes the result of uploading an image resource", + "type" : "object", + "properties" : { + "code" : { + "type" : "integer", + "format" : "int32" + }, + "type" : { + "type" : "string" + }, + "message" : { + "type" : "string" + } + } + }, + "Category" : { + "title" : "Pet category", + "description" : "A category for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string", + "pattern" : "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$" + } + } + }, + "Order" : { + "title" : "Pet Order", + "description" : "An order for a pets from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "petId" : { + "type" : "integer", + "format" : "int64" + }, + "quantity" : { + "type" : "integer", + "format" : "int32" + }, + "shipDate" : { + "type" : "string", + "format" : "date-time" + }, + "status" : { + "type" : "string", + "description" : "Order Status", + "enum" : [ "placed", "approved", "delivered" ] + }, + "complete" : { + "type" : "boolean" + } + } + }, + "Pet" : { + "title" : "a Pet", + "description" : "A pet for sale in the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "category" : { + "$ref" : "#/definitions/Category" + }, + "name" : { + "type" : "string" + }, + "photoUrls" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "tags" : { + "type" : "array", + "items" : { + "$ref" : "#/definitions/Tag" + } + }, + "status" : { + "type" : "string", + "description" : "pet status in the store", + "enum" : [ "available", "pending", "sold" ] + } + }, + "required" : [ "name", "photoUrls" ] + }, + "Tag" : { + "title" : "Pet Tag", + "description" : "A tag for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + } + } + }, + "User" : { + "title" : "a User", + "description" : "A User who is purchasing from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "username" : { + "type" : "string" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "password" : { + "type" : "string" + }, + "phone" : { + "type" : "string" + }, + "userStatus" : { + "type" : "integer", + "description" : "User Status", + "format" : "int32" + } + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/petstore/modsecurity3/PetApi.conf b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf new file mode 100644 index 0000000..baf40ee --- /dev/null +++ b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf @@ -0,0 +1,286 @@ + +# addPet: POST /pet +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addPet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addPet" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210002,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210014,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210026,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210037,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210038,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210050,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210062,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210086,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDJSON_addPet +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200020,phase:2,pass,nolog,skipAfter:ENDXML_addPet" +SecRule REQBODY_ERROR "!@eq 0" "id:4200023,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# TODO: Implement XML schema validation (issue #14 covers JSON objects) +SecAction "id:4200024,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDXML_addPet + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_addPet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_addPet + +# deletePet: DELETE /pet/{petId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_deletePet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within DELETE" "id:4200043,phase:2,pass,nolog,skipAfter:END_deletePet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deletePet" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_deletePet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_deletePet + +# findPetsByStatus: GET /pet/findByStatus +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/findByStatus$" "id:4200082,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" + +SecRule ARGS_GET:status "!@rx ^(available|pending|sold)$" "id:4210222,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:status "@lt 1" "id:4210224,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findPetsByStatus + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_findPetsByStatus + +# findPetsByTags: GET /pet/findByTags +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/findByTags$" "id:4200122,phase:2,pass,nolog,skipAfter:END_findPetsByTags" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200123,phase:2,pass,nolog,skipAfter:END_findPetsByTags" + +SecRule ARGS_GET:tags "!@rx ^.+$" "id:4210262,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:tags "@lt 1" "id:4210264,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200134,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200145,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200141,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_findPetsByTags + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200142" + +## End of checks for this operation +SecMarker END_findPetsByTags + +# getPetById: GET /pet/{petId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200162,phase:2,pass,nolog,skipAfter:END_getPetById" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200163,phase:2,pass,nolog,skipAfter:END_getPetById" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200174,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200185,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200181,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getPetById + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200182" + +## End of checks for this operation +SecMarker END_getPetById + +# updatePet: PUT /pet +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200202,phase:2,pass,nolog,skipAfter:END_updatePet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within PUT" "id:4200203,phase:2,pass,nolog,skipAfter:END_updatePet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200214,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200216,phase:2,pass,nolog,skipAfter:ENDJSON_updatePet" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200217,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210338,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210350,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210362,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210373,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210374,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210386,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210398,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210410,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210422,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200219,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDJSON_updatePet +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200220,phase:2,pass,nolog,skipAfter:ENDXML_updatePet" +SecRule REQBODY_ERROR "!@eq 0" "id:4200223,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# TODO: Implement XML schema validation (issue #14 covers JSON objects) +SecAction "id:4200224,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDXML_updatePet + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200221,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_updatePet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200222" + +## End of checks for this operation +SecMarker END_updatePet + +# updatePetWithForm: POST /pet/{petId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200242,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200243,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" + +SecRule ARGS_POST:name "!@rx ^.*$" "id:4210522,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:name "@gt 1" "id:4210523,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:status "!@rx ^.*$" "id:4210562,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:status "@gt 1" "id:4210563,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:name|status)$" "id:4200254,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200261,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_updatePetWithForm + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200262" + +## End of checks for this operation +SecMarker END_updatePetWithForm + +# uploadFile: POST /pet/{petId}/uploadImage +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})/uploadImage$" "id:4200282,phase:2,pass,nolog,skipAfter:END_uploadFile" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200283,phase:2,pass,nolog,skipAfter:END_uploadFile" + +SecRule ARGS_POST:additionalMetadata "!@rx ^.*$" "id:4210642,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:additionalMetadata "@gt 1" "id:4210643,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:file "!@rx ^.*$" "id:4210682,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:file "@gt 1" "id:4210683,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:additionalMetadata|file)$" "id:4200294,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200301,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_uploadFile + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200302" + +## End of checks for this operation +SecMarker END_uploadFile diff --git a/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf b/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf new file mode 100644 index 0000000..5879573 --- /dev/null +++ b/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf @@ -0,0 +1,128 @@ + +# deleteOrder: DELETE /store/order/{orderId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:.+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within DELETE" "id:4200324,phase:2,pass,nolog,skipAfter:END_deleteOrder" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200335,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200346,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteOrder" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200342,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_deleteOrder + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200343" + +## End of checks for this operation +SecMarker END_deleteOrder + +# getInventory: GET /store/inventory +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/store/inventory$" "id:4200363,phase:2,pass,nolog,skipAfter:END_getInventory" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200364,phase:2,pass,nolog,skipAfter:END_getInventory" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200375,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200386,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getInventory" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200382,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getInventory + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200383" + +## End of checks for this operation +SecMarker END_getInventory + +# getOrderById: GET /store/order/{orderId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:[0-9]{1,19})$" "id:4200403,phase:2,pass,nolog,skipAfter:END_getOrderById" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200404,phase:2,pass,nolog,skipAfter:END_getOrderById" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200415,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200426,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getOrderById" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200422,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getOrderById + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200423" + +## End of checks for this operation +SecMarker END_getOrderById + +# placeOrder: POST /store/order +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/store/order$" "id:4200443,phase:2,pass,nolog,skipAfter:END_placeOrder" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200444,phase:2,pass,nolog,skipAfter:END_placeOrder" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.petId|json\.quantity|json\.shipDate|json\.status|json\.complete)$" "id:4200455,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200457,phase:2,pass,nolog,skipAfter:ENDJSON_placeOrder" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200458,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210794,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.petId "!@rx ^[0-9]{1,19}$" "id:4210806,phase:2,block,msg:'Invalid value for property json.petId',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.quantity "!@rx ^[0-9]{1,19}$" "id:4210818,phase:2,block,msg:'Invalid value for property json.quantity',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.shipDate "!@rx ^(\d{4}-((01|03|05|07|08|10|12)-(0[1-9]|1\d|2\d|3[0-1])|(04|06|09|11)-(0[1-9]|1\d|2\d|30)|02-(0[1-9]|1\d|2\d))T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(\.\d+)?([Zz]|[+\-](0\d|1[0-4])(:[0-5]\d)?))$" "id:4210830,phase:2,block,msg:'Invalid value for property json.shipDate',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(placed|approved|delivered)$" "id:4210842,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.complete "!@rx ^(true|false)$" "id:4210854,phase:2,block,msg:'Invalid value for property json.complete',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200460,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_placeOrder" + +SecMarker ENDJSON_placeOrder + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200462,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_placeOrder + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200463" + +## End of checks for this operation +SecMarker END_placeOrder diff --git a/src/test/resources/golden/petstore/modsecurity3/UserApi.conf b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf new file mode 100644 index 0000000..3b597a6 --- /dev/null +++ b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf @@ -0,0 +1,276 @@ + +# createUser: POST /user +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user$" "id:4200484,phase:2,pass,nolog,skipAfter:END_createUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200485,phase:2,pass,nolog,skipAfter:END_createUser" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.username|json\.firstName|json\.lastName|json\.email|json\.password|json\.phone|json\.userStatus)$" "id:4200496,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200498,phase:2,pass,nolog,skipAfter:ENDJSON_createUser" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200499,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210906,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4210918,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4210930,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4210942,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4210954,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4210966,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4210978,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4210990,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200501,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUser" + +SecMarker ENDJSON_createUser + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200503,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_createUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200504" + +## End of checks for this operation +SecMarker END_createUser + +# createUsersWithArrayInput: POST /user/createWithArray +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/createWithArray$" "id:4200524,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200538,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithArrayInput" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200539,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200541,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithArrayInput" + +SecMarker ENDJSON_createUsersWithArrayInput + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200543,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_createUsersWithArrayInput + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200544" + +## End of checks for this operation +SecMarker END_createUsersWithArrayInput + +# createUsersWithListInput: POST /user/createWithList +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/createWithList$" "id:4200564,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200578,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithListInput" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200579,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200581,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithListInput" + +SecMarker ENDJSON_createUsersWithListInput + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200583,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_createUsersWithListInput + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200584" + +## End of checks for this operation +SecMarker END_createUsersWithListInput + +# deleteUser: DELETE /user/{username} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within DELETE" "id:4200605,phase:2,pass,nolog,skipAfter:END_deleteUser" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200616,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200627,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteUser" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200623,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_deleteUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200624" + +## End of checks for this operation +SecMarker END_deleteUser + +# getUserByName: GET /user/{username} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200645,phase:2,pass,nolog,skipAfter:END_getUserByName" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200656,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200667,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getUserByName" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200663,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getUserByName + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200664" + +## End of checks for this operation +SecMarker END_getUserByName + +# loginUser: GET /user/login +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/login$" "id:4200684,phase:2,pass,nolog,skipAfter:END_loginUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200685,phase:2,pass,nolog,skipAfter:END_loginUser" + +SecRule ARGS_GET:username "!@rx /^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$/" "id:4211206,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:username "@gt 1" "id:4211207,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:password "!@rx ^.+$" "id:4211246,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:password "@gt 1" "id:4211247,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:username|password)$" "id:4200696,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200707,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_loginUser" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200703,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_loginUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200704" + +## End of checks for this operation +SecMarker END_loginUser + +# logoutUser: GET /user/logout +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/logout$" "id:4200724,phase:2,pass,nolog,skipAfter:END_logoutUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200725,phase:2,pass,nolog,skipAfter:END_logoutUser" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200736,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200747,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_logoutUser" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200743,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_logoutUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200744" + +## End of checks for this operation +SecMarker END_logoutUser + +# updateUser: PUT /user/{username} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within PUT" "id:4200765,phase:2,pass,nolog,skipAfter:END_updateUser" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.username|json\.firstName|json\.lastName|json\.email|json\.password|json\.phone|json\.userStatus)$" "id:4200776,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200778,phase:2,pass,nolog,skipAfter:ENDJSON_updateUser" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200779,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211322,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4211334,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211346,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211358,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4211370,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4211382,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4211394,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211406,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200781,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updateUser" + +SecMarker ENDJSON_updateUser + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200783,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_updateUser + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200784" + +## End of checks for this operation +SecMarker END_updateUser diff --git a/src/test/resources/golden/petstore/modsecurity3/mainconfig.conf b/src/test/resources/golden/petstore/modsecurity3/mainconfig.conf new file mode 100644 index 0000000..b34513f --- /dev/null +++ b/src/test/resources/golden/petstore/modsecurity3/mainconfig.conf @@ -0,0 +1,22 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include PetApi.conf +Include StoreApi.conf +Include UserApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/petstore/modsecurity3/schema.json b/src/test/resources/golden/petstore/modsecurity3/schema.json new file mode 100644 index 0000000..05c6a88 --- /dev/null +++ b/src/test/resources/golden/petstore/modsecurity3/schema.json @@ -0,0 +1,154 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "ApiResponse" : { + "title" : "An uploaded response", + "description" : "Describes the result of uploading an image resource", + "type" : "object", + "properties" : { + "code" : { + "type" : "integer", + "format" : "int32" + }, + "type" : { + "type" : "string" + }, + "message" : { + "type" : "string" + } + } + }, + "Category" : { + "title" : "Pet category", + "description" : "A category for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string", + "pattern" : "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$" + } + } + }, + "Order" : { + "title" : "Pet Order", + "description" : "An order for a pets from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "petId" : { + "type" : "integer", + "format" : "int64" + }, + "quantity" : { + "type" : "integer", + "format" : "int32" + }, + "shipDate" : { + "type" : "string", + "format" : "date-time" + }, + "status" : { + "type" : "string", + "description" : "Order Status", + "enum" : [ "placed", "approved", "delivered" ] + }, + "complete" : { + "type" : "boolean" + } + } + }, + "Pet" : { + "title" : "a Pet", + "description" : "A pet for sale in the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "category" : { + "$ref" : "#/definitions/Category" + }, + "name" : { + "type" : "string" + }, + "photoUrls" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "tags" : { + "type" : "array", + "items" : { + "$ref" : "#/definitions/Tag" + } + }, + "status" : { + "type" : "string", + "description" : "pet status in the store", + "enum" : [ "available", "pending", "sold" ] + } + }, + "required" : [ "name", "photoUrls" ] + }, + "Tag" : { + "title" : "Pet Tag", + "description" : "A tag for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + } + } + }, + "User" : { + "title" : "a User", + "description" : "A User who is purchasing from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "username" : { + "type" : "string" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "password" : { + "type" : "string" + }, + "phone" : { + "type" : "string" + }, + "userStatus" : { + "type" : "integer", + "description" : "User Status", + "format" : "int32" + } + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/urlintparam/coraza/PetApi.conf b/src/test/resources/golden/urlintparam/coraza/PetApi.conf new file mode 100644 index 0000000..acd7cae --- /dev/null +++ b/src/test/resources/golden/urlintparam/coraza/PetApi.conf @@ -0,0 +1,137 @@ + +# addPet: POST /pet +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addPet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addPet" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210002,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210014,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210026,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210037,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210038,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210050,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210062,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210086,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200018,phase:2,block,msg:'JSON schema validation failed for addPet',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDJSON_addPet +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200020,phase:2,pass,nolog,skipAfter:ENDXML_addPet" +SecRule REQBODY_ERROR "!@eq 0" "id:4200023,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# TODO: Implement XML schema validation (issue #14 covers JSON objects) +SecAction "id:4200024,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDXML_addPet + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_addPet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_addPet + +# getPetById: GET /pet/{petId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_getPetById" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_getPetById" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getPetById + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_getPetById + +# updatePet: PUT /pet +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200082,phase:2,pass,nolog,skipAfter:END_updatePet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within PUT" "id:4200083,phase:2,pass,nolog,skipAfter:END_updatePet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200096,phase:2,pass,nolog,skipAfter:ENDJSON_updatePet" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200097,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210178,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210190,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210202,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210213,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210214,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210226,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210238,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210250,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210262,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200098,phase:2,block,msg:'JSON schema validation failed for updatePet',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200099,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDJSON_updatePet +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200100,phase:2,pass,nolog,skipAfter:ENDXML_updatePet" +SecRule REQBODY_ERROR "!@eq 0" "id:4200103,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# TODO: Implement XML schema validation (issue #14 covers JSON objects) +SecAction "id:4200104,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDXML_updatePet + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_updatePet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_updatePet diff --git a/src/test/resources/golden/urlintparam/coraza/mainconfig.conf b/src/test/resources/golden/urlintparam/coraza/mainconfig.conf new file mode 100644 index 0000000..36167cd --- /dev/null +++ b/src/test/resources/golden/urlintparam/coraza/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include PetApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/urlintparam/coraza/schema.json b/src/test/resources/golden/urlintparam/coraza/schema.json new file mode 100644 index 0000000..05c6a88 --- /dev/null +++ b/src/test/resources/golden/urlintparam/coraza/schema.json @@ -0,0 +1,154 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "ApiResponse" : { + "title" : "An uploaded response", + "description" : "Describes the result of uploading an image resource", + "type" : "object", + "properties" : { + "code" : { + "type" : "integer", + "format" : "int32" + }, + "type" : { + "type" : "string" + }, + "message" : { + "type" : "string" + } + } + }, + "Category" : { + "title" : "Pet category", + "description" : "A category for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string", + "pattern" : "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$" + } + } + }, + "Order" : { + "title" : "Pet Order", + "description" : "An order for a pets from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "petId" : { + "type" : "integer", + "format" : "int64" + }, + "quantity" : { + "type" : "integer", + "format" : "int32" + }, + "shipDate" : { + "type" : "string", + "format" : "date-time" + }, + "status" : { + "type" : "string", + "description" : "Order Status", + "enum" : [ "placed", "approved", "delivered" ] + }, + "complete" : { + "type" : "boolean" + } + } + }, + "Pet" : { + "title" : "a Pet", + "description" : "A pet for sale in the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "category" : { + "$ref" : "#/definitions/Category" + }, + "name" : { + "type" : "string" + }, + "photoUrls" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "tags" : { + "type" : "array", + "items" : { + "$ref" : "#/definitions/Tag" + } + }, + "status" : { + "type" : "string", + "description" : "pet status in the store", + "enum" : [ "available", "pending", "sold" ] + } + }, + "required" : [ "name", "photoUrls" ] + }, + "Tag" : { + "title" : "Pet Tag", + "description" : "A tag for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + } + } + }, + "User" : { + "title" : "a User", + "description" : "A User who is purchasing from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "username" : { + "type" : "string" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "password" : { + "type" : "string" + }, + "phone" : { + "type" : "string" + }, + "userStatus" : { + "type" : "integer", + "description" : "User Status", + "format" : "int32" + } + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf b/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf new file mode 100644 index 0000000..5e7d9b0 --- /dev/null +++ b/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf @@ -0,0 +1,131 @@ + +# addPet: POST /pet +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addPet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addPet" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210002,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210014,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210026,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210037,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210038,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210050,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210062,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210086,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDJSON_addPet +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200020,phase:2,pass,nolog,skipAfter:ENDXML_addPet" +SecRule REQBODY_ERROR "!@eq 0" "id:4200023,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# TODO: Implement XML schema validation (issue #14 covers JSON objects) +SecAction "id:4200024,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDXML_addPet + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_addPet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_addPet + +# getPetById: GET /pet/{petId} +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_getPetById" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_getPetById" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_getPetById + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_getPetById + +# updatePet: PUT /pet +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200082,phase:2,pass,nolog,skipAfter:END_updatePet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within PUT" "id:4200083,phase:2,pass,nolog,skipAfter:END_updatePet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle Models +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200096,phase:2,pass,nolog,skipAfter:ENDJSON_updatePet" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4200097,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210178,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210190,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210202,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210213,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210214,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210226,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210238,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210250,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210262,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4200099,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDJSON_updatePet +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200100,phase:2,pass,nolog,skipAfter:ENDXML_updatePet" +SecRule REQBODY_ERROR "!@eq 0" "id:4200103,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# TODO: Implement XML schema validation (issue #14 covers JSON objects) +SecAction "id:4200104,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDXML_updatePet + + +# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + + +SecMarker AFTER_CONSUMES_updatePet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_updatePet diff --git a/src/test/resources/golden/urlintparam/modsecurity3/mainconfig.conf b/src/test/resources/golden/urlintparam/modsecurity3/mainconfig.conf new file mode 100644 index 0000000..36167cd --- /dev/null +++ b/src/test/resources/golden/urlintparam/modsecurity3/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include PetApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/urlintparam/modsecurity3/schema.json b/src/test/resources/golden/urlintparam/modsecurity3/schema.json new file mode 100644 index 0000000..05c6a88 --- /dev/null +++ b/src/test/resources/golden/urlintparam/modsecurity3/schema.json @@ -0,0 +1,154 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "ApiResponse" : { + "title" : "An uploaded response", + "description" : "Describes the result of uploading an image resource", + "type" : "object", + "properties" : { + "code" : { + "type" : "integer", + "format" : "int32" + }, + "type" : { + "type" : "string" + }, + "message" : { + "type" : "string" + } + } + }, + "Category" : { + "title" : "Pet category", + "description" : "A category for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string", + "pattern" : "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$" + } + } + }, + "Order" : { + "title" : "Pet Order", + "description" : "An order for a pets from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "petId" : { + "type" : "integer", + "format" : "int64" + }, + "quantity" : { + "type" : "integer", + "format" : "int32" + }, + "shipDate" : { + "type" : "string", + "format" : "date-time" + }, + "status" : { + "type" : "string", + "description" : "Order Status", + "enum" : [ "placed", "approved", "delivered" ] + }, + "complete" : { + "type" : "boolean" + } + } + }, + "Pet" : { + "title" : "a Pet", + "description" : "A pet for sale in the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "category" : { + "$ref" : "#/definitions/Category" + }, + "name" : { + "type" : "string" + }, + "photoUrls" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "tags" : { + "type" : "array", + "items" : { + "$ref" : "#/definitions/Tag" + } + }, + "status" : { + "type" : "string", + "description" : "pet status in the store", + "enum" : [ "available", "pending", "sold" ] + } + }, + "required" : [ "name", "photoUrls" ] + }, + "Tag" : { + "title" : "Pet Tag", + "description" : "A tag for a pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + } + } + }, + "User" : { + "title" : "a User", + "description" : "A User who is purchasing from the pet store", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "username" : { + "type" : "string" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "password" : { + "type" : "string" + }, + "phone" : { + "type" : "string" + }, + "userStatus" : { + "type" : "integer", + "description" : "User Status", + "format" : "int32" + } + } + } + } +} \ No newline at end of file From e058d1768c6a79480191f826cae0a39b553d5708 Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 14:46:01 -0500 Subject: [PATCH 2/9] Fix confirmed generator bugs: mangled patterns, root-array bodies, path/regex issues - Spec-provided patterns are now written back sanitized; previously the template and path regex rendered the DefaultCodegen-mangled /.../ form with doubled backslashes, rejecting all valid values (and breaking route matching for path params with patterns). - Root-level JSON array request bodies are flattened (element index 0 generalized to both engines' key forms); previously the ARGS_NAMES allowlist was empty and every element key was blocked as unknown. - Pattern-less string path params now match [^/]+ instead of .+ so values cannot cross segment boundaries. - allowEmptyValue query params accept empty values. - OAS 3.1 numeric exclusiveMinimum/Maximum verified working (test added). - Deleted the petstore-specific photoUrls hack in JsonSchemaGenerator and fixed the array-of-primitive type clobbering it papered over. - Numeric enums no longer crash pattern generation (CCE) and are emitted as typed JSON values in schema.json instead of strings. Co-Authored-By: Claude Fable 5 --- .../modsecurity3/JsonSchemaGenerator.java | 67 ++--- .../modsecurity3/Modsecurity3Generator.java | 37 ++- .../PatternGenerationService.java | 34 ++- .../modsecurity3/tests/Oas31FeaturesTest.java | 51 ++++ .../modsecurity3/tests/Phase1FixesTest.java | 126 +++++++++ .../integration/EngineBehaviorProbeTest.java | 263 ++++++++++++++++++ .../golden/petstore/coraza/StoreApi.conf | 2 +- .../golden/petstore/coraza/UserApi.conf | 50 ++-- .../petstore/modsecurity3/StoreApi.conf | 2 +- .../golden/petstore/modsecurity3/UserApi.conf | 50 ++-- .../resources/specs/oas31-exclusives.yaml | 24 ++ src/test/resources/specs/phase1-features.yaml | 86 ++++++ 12 files changed, 709 insertions(+), 83 deletions(-) create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Oas31FeaturesTest.java create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase1FixesTest.java create mode 100644 src/test/java/com/oashield/openapi/integration/EngineBehaviorProbeTest.java create mode 100644 src/test/resources/specs/oas31-exclusives.yaml create mode 100644 src/test/resources/specs/phase1-features.yaml diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java index 2faf265..880d2bf 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java @@ -151,20 +151,39 @@ private ObjectNode composedMemberSchema(CodegenProperty member) { if (member.dataFormat != null && !member.dataFormat.isEmpty()) { node.put("format", member.dataFormat); } - if (member.allowableValues != null && member.allowableValues.containsKey("values")) { - @SuppressWarnings("unchecked") - List enumValues = (List) member.allowableValues.get("values"); - if (enumValues != null && !enumValues.isEmpty()) { - ArrayNode enumNode = node.putArray("enum"); - for (String value : enumValues) { - enumNode.add(value); - } - } - } + addEnumValues(member, node); processValidationConstraints(member, node); return node; } + /** + * Emit enum values with their JSON types preserved: an integer enum must appear + * as [1, 2] in the schema, not ["1", "2"], or valid numeric values get rejected. + */ + private void addEnumValues(CodegenProperty var, ObjectNode node) { + if (var.allowableValues == null || !(var.allowableValues.get("values") instanceof List)) { + return; + } + List enumValues = (List) var.allowableValues.get("values"); + if (enumValues.isEmpty()) { + return; + } + ArrayNode enumNode = node.putArray("enum"); + for (Object value : enumValues) { + if (value instanceof Integer) { + enumNode.add((Integer) value); + } else if (value instanceof Long) { + enumNode.add((Long) value); + } else if (value instanceof Number) { + enumNode.add(new java.math.BigDecimal(value.toString())); + } else if (value instanceof Boolean) { + enumNode.add((Boolean) value); + } else { + enumNode.add(String.valueOf(value)); + } + } + } + /** * Extract JSON Schema compatible data from a ModelMap. * @@ -200,15 +219,6 @@ private void processProperties(List vars, ObjectNode schemaNode String name = var.name; ObjectNode property = properties.putObject(name); - // Special case for photoUrls in Pet model which is an array of strings - if (name.equals("photoUrls")) { - log.debug("Special handling for photoUrls property"); - property.put("type", "array"); - ObjectNode items = property.putObject("items"); - items.put("type", "string"); - continue; - } - // Set property type setPropertyType(var, property); @@ -230,8 +240,12 @@ private void processProperties(List vars, ObjectNode schemaNode String complexType = var.complexType; // Check if it's a primitive type if (isPrimitiveType(complexType)) { - // Handle primitive types directly - setPrimitiveType(complexType, property); + // For an array of primitives complexType holds the ELEMENT type; + // setPropertyType already emitted type:array with typed items, so + // only scalar properties get the primitive type applied here. + if (!var.isArray) { + setPrimitiveType(complexType, property); + } } else if (var.isArray) { // Array of complex type property.put("type", "array"); @@ -247,16 +261,7 @@ private void processProperties(List vars, ObjectNode schemaNode } // Handle enums - if (var.allowableValues != null && var.allowableValues.containsKey("values")) { - @SuppressWarnings("unchecked") - List enumValues = (List) var.allowableValues.get("values"); - if (enumValues != null && !enumValues.isEmpty()) { - ArrayNode enumNode = property.putArray("enum"); - for (String value : enumValues) { - enumNode.add(value); - } - } - } + addEnumValues(var, property); } } diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index 7dbbf1c..f467323 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -473,6 +473,34 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List flattenedProperties = new ArrayList(); + List itemVars = null; + if (param.items != null) { + if (param.items.vars != null && !param.items.vars.isEmpty()) { + itemVars = param.items.vars; + } else { + itemVars = lookupModelVars(param.items, modelLookup); + } + } + if (itemVars != null) { + for (CodegenProperty prop : itemVars) { + flattenedProperties.addAll(flattenModel(prop, JSON_ARGS_PREFIX + "0.", 2, modelLookup)); + } + } else if (param.items != null) { + // root array of primitives: element keys are json.0 / json.array_0 + CodegenProperty leaf = flattenedLeaf(param.items, JSON_ARGS_PREFIX); + leaf.baseName = JSON_ARGS_PREFIX + "0"; + flattenedProperties.add(leaf); + } + for (CodegenProperty prop : flattenedProperties) { + decorateBodyProperty(prop, argsAllowlist); + } + param.vendorExtensions.put(MODSECURITY_MODEL_PROPERTIES, flattenedProperties); } if (param.isQueryParam || param.isFormParam) { @@ -506,8 +534,15 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List enumValues = null; - try { - enumValues = (List)param.allowableValues.get("values"); - } - catch (ClassCastException e) { - LOGGER.warn("Could not cast allowable values to list of strings for parameter: {}", param.baseName); + // Enum values can be Strings or numbers/booleans (integer enums), so + // treat them as Objects and stringify each one. + List enumValues = null; + Object values = param.allowableValues != null ? param.allowableValues.get("values") : null; + if (values instanceof List) { + enumValues = (List) values; } // For empty/null/invalid enum, return "." as per test - if (enumValues == null || !(enumValues instanceof List) || enumValues.isEmpty()) { + if (enumValues == null || enumValues.isEmpty()) { return "."; } // Only escape if value contains regex metacharacters List escapedValues = new java.util.ArrayList<>(); - for (String v : enumValues) { + for (Object o : enumValues) { + String v = String.valueOf(o); if (v.matches("^[a-zA-Z0-9_]+$")) { escapedValues.add(v); } else { diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Oas31FeaturesTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Oas31FeaturesTest.java new file mode 100644 index 0000000..6df7221 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Oas31FeaturesTest.java @@ -0,0 +1,51 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; + +/** + * OpenAPI 3.1-specific behavior: numeric exclusiveMinimum/exclusiveMaximum must + * produce @le/@ge comparisons (3.0 uses booleans alongside minimum/maximum; 3.1 + * puts the bound value directly in the exclusive keyword). + */ +public class Oas31FeaturesTest { + + private static String generate(String spec, Path outputDir) throws IOException { + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec(spec) + .setOutputDir(outputDir.toString()) + .toClientOptInput()) + .generate(); + try (java.util.stream.Stream files = Files.list(outputDir)) { + StringBuilder sb = new StringBuilder(); + for (Path p : files.filter(f -> f.getFileName().toString().endsWith("Api.conf")) + .collect(java.util.stream.Collectors.toList())) { + sb.append(Files.readString(p)); + } + return sb.toString(); + } + } + + @Test + void numericExclusiveBoundsProduceExclusiveComparisons(@TempDir Path outputDir) throws IOException { + String conf = generate("src/test/resources/specs/oas31-exclusives.yaml", outputDir); + assertTrue(conf.contains("ARGS_GET:count \"@le 0\""), + "exclusiveMinimum: 0 should emit @le 0; conf was:\n" + conf); + assertTrue(conf.contains("ARGS_GET:count \"@ge 100\""), + "exclusiveMaximum: 100 should emit @ge 100; conf was:\n" + conf); + assertTrue(conf.contains("ARGS_GET:size \"@lt 1\""), + "inclusive minimum: 1 should emit @lt 1; conf was:\n" + conf); + assertTrue(conf.contains("ARGS_GET:size \"@gt 50\""), + "inclusive maximum: 50 should emit @gt 50; conf was:\n" + conf); + } +} diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase1FixesTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase1FixesTest.java new file mode 100644 index 0000000..a5255f4 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase1FixesTest.java @@ -0,0 +1,126 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openapitools.codegen.CodegenParameter; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oashield.openapi.generators.modsecurity3.PatternGenerationService; + +/** + * Regression tests for the confirmed generator bugs: mangled spec patterns, + * blocked root-array bodies, path patterns crossing segment boundaries, + * allowEmptyValue, and numeric enums. + */ +public class Phase1FixesTest { + + @TempDir + static Path outputDir; + private static String conf; + private static JsonNode schema; + + @BeforeAll + static void generate() throws IOException { + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("src/test/resources/specs/phase1-features.yaml") + .setOutputDir(outputDir.toString()) + .toClientOptInput()) + .generate(); + try (Stream files = Files.list(outputDir)) { + conf = files.filter(p -> p.getFileName().toString().endsWith("Api.conf")) + .map(p -> { + try { + return Files.readString(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.joining("\n")); + } + schema = new ObjectMapper().readTree(outputDir.resolve("schema.json").toFile()); + } + + @Test + void specPatternsAreEmittedUnmangled() { + // Spec pattern for the query param appears undelimited in the @rx operand + assertTrue(conf.contains("ARGS_GET:q \"!@rx ^[a-zA-Z0-9.]{1,20}$\""), + "spec pattern should be emitted without /.../ delimiters:\n" + conf); + // No rule anywhere carries the DefaultCodegen-mangled /^...$/ form + assertFalse(conf.contains("@rx /^"), "mangled delimited pattern leaked into rules:\n" + conf); + } + + @Test + void specPatternEmbedsIntoPathRegex() { + assertTrue(conf.contains("^/users/(?:[a-z]{3,10})$"), + "path regex should embed the sanitized spec pattern:\n" + conf); + } + + @Test + void genericStringPathParamsDoNotCrossSegments() { + assertTrue(conf.contains("^/files/(?:[^/]+)$"), + "pattern-less string path params should match a single segment:\n" + conf); + } + + @Test + void rootArrayBodyOfModelsIsFlattened() { + assertTrue(conf.contains("json\\.(?:array_)?\\d{1,9}\\.username"), + "root-array element fields should be validated and allowlisted:\n" + conf); + // the array-body operation's ARGS_NAMES allowlist covers element keys and fields + assertTrue(conf.contains( + "^(?:json\\.(?:array_)?\\d{1,9}|json\\.(?:array_)?\\d{1,9}\\.username|json\\.(?:array_)?\\d{1,9}\\.level)$"), + "root-array body allowlist should cover index keys and element fields:\n" + conf); + } + + @Test + void rootArrayBodyOfPrimitivesIsFlattened() { + assertTrue(conf.contains("/^json\\.(?:array_)?\\d{1,9}$/"), + "root-array primitive elements should get an indexed selector:\n" + conf); + } + + @Test + void allowEmptyValueQueryParamAcceptsEmpty() { + assertTrue(conf.contains("ARGS_GET:flag \"!@rx ^(?:[a-z]+)?$\""), + "allowEmptyValue param should accept an empty value:\n" + conf); + } + + @Test + void numericEnumsEmitTypedSchemaValues() { + JsonNode enumNode = schema.path("definitions").path("BulkUser") + .path("properties").path("level").path("enum"); + assertTrue(enumNode.isArray() && enumNode.size() == 3, + "BulkUser.level should have a 3-value enum: " + schema); + assertTrue(enumNode.get(0).isIntegralNumber(), + "integer enum values must be emitted as JSON numbers, got: " + enumNode); + } + + @Test + void numericEnumPatternGenerationDoesNotCrash() { + PatternGenerationService service = new PatternGenerationService(); + CodegenParameter param = new CodegenParameter(); + param.baseName = "level"; + param.isEnum = true; + Map allowable = new HashMap<>(); + allowable.put("values", Arrays.asList(1, 2, 3)); + param.allowableValues = allowable; + assertEquals("(1|2|3)", service.getAllowedInputPattern(param)); + } +} diff --git a/src/test/java/com/oashield/openapi/integration/EngineBehaviorProbeTest.java b/src/test/java/com/oashield/openapi/integration/EngineBehaviorProbeTest.java new file mode 100644 index 0000000..dc7e8fe --- /dev/null +++ b/src/test/java/com/oashield/openapi/integration/EngineBehaviorProbeTest.java @@ -0,0 +1,263 @@ +package com.oashield.openapi.integration; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.Test; + +import com.oashield.openapi.integration.util.CorazaContainerManager; +import com.oashield.openapi.integration.util.ModSecurityContainerManager; +import com.oashield.openapi.integration.util.WafContainerManager; + +/** + * Exploratory probes of undocumented engine behavior. NOT part of the regular + * suite: run manually with + * + * DOCKER_JAVA_PROPERTIES="api.version=1.44" mvn test -Dtest=EngineBehaviorProbeTest -DrunEngineProbes=true + * + * Findings are printed with an ENGINE-PROBE| prefix and recorded in + * docs/engine-behavior.md. Probes drive design decisions for nullable handling, + * optional request bodies, multipart support, Coraza JSON Schema draft support, + * and XML validation. + */ +@EnabledIfSystemProperty(named = "runEngineProbes", matches = "true") +public class EngineBehaviorProbeTest { + + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + // --------------------------------------------------------------- + // Probe 1: how does a JSON null value flatten into ARGS? + // Statuses: 456=key absent, 457=value empty string, 458=value literal "null", + // 459=present with some other value + // --------------------------------------------------------------- + @ParameterizedTest + @ValueSource(strings = { "coraza", "modsecurity3" }) + void probeJsonNullFlattening(String engine) throws Exception { + String conf = "SecRuleEngine On\n" + + "SecRequestBodyAccess On\n" + // health-check bypass: the container wait strategy expects GET / to answer 200/403 + + "SecRule REQUEST_FILENAME \"@streq /\" \"id:2,phase:1,pass,nolog,ctl:ruleEngine=Off\"\n" + + "SecRule REQUEST_HEADERS:Content-Type \"@rx (?i)json\" \"id:1,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON\"\n" + + "SecRule REQBODY_ERROR \"!@eq 0\" \"id:5,phase:2,deny,status:422\"\n" + + "SecRule &ARGS:json.a \"@eq 0\" \"id:10,phase:2,deny,status:456\"\n" + + "SecRule ARGS:json.a \"@rx ^$\" \"id:11,phase:2,deny,status:457\"\n" + + "SecRule ARGS:json.a \"@streq null\" \"id:12,phase:2,deny,status:458\"\n" + + "SecAction \"id:13,phase:2,deny,status:459\"\n"; + withWaf(engine, Map.of("main.conf", conf), waf -> { + report(engine, "null-flatten", "a=null", post(waf, "/x", "application/json", "{\"a\":null}")); + report(engine, "null-flatten", "a=empty-string", post(waf, "/x", "application/json", "{\"a\":\"\"}")); + report(engine, "null-flatten", "a=x(control)", post(waf, "/x", "application/json", "{\"a\":\"x\"}")); + report(engine, "null-flatten", "a-absent(control)", post(waf, "/x", "application/json", "{}")); + }); + } + + // --------------------------------------------------------------- + // Probe 2: empty / absent body signals for optional-requestBody gating. + // Statuses: 456=no Content-Type header, 457=REQBODY_ERROR set, 459=fallthrough + // --------------------------------------------------------------- + @ParameterizedTest + @ValueSource(strings = { "coraza", "modsecurity3" }) + void probeEmptyBody(String engine) throws Exception { + String conf = "SecRuleEngine On\n" + + "SecRequestBodyAccess On\n" + // health-check bypass: the container wait strategy expects GET / to answer 200/403 + + "SecRule REQUEST_FILENAME \"@streq /\" \"id:2,phase:1,pass,nolog,ctl:ruleEngine=Off\"\n" + + "SecRule REQUEST_HEADERS:Content-Type \"@rx (?i)json\" \"id:1,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON\"\n" + + "SecRule &REQUEST_HEADERS:Content-Type \"@eq 0\" \"id:20,phase:2,deny,status:456\"\n" + + "SecRule REQBODY_ERROR \"!@eq 0\" \"id:21,phase:2,deny,status:457\"\n" + + "SecAction \"id:23,phase:2,deny,status:459\"\n"; + withWaf(engine, Map.of("main.conf", conf), waf -> { + report(engine, "empty-body", "no-CT-no-body", post(waf, "/x", null, null)); + report(engine, "empty-body", "json-CT-empty-body", post(waf, "/x", "application/json", "")); + report(engine, "empty-body", "json-CT-valid-body(control)", post(waf, "/x", "application/json", "{\"a\":1}")); + }); + } + + // --------------------------------------------------------------- + // Probe 3: multipart/form-data — text parts in ARGS_POST? file parts in FILES? + // Statuses: 456=text part visible in ARGS_POST, 457=file part visible in FILES_NAMES, + // 458=REQBODY_ERROR, 459=nothing matched + // --------------------------------------------------------------- + @ParameterizedTest + @ValueSource(strings = { "coraza", "modsecurity3" }) + void probeMultipart(String engine) throws Exception { + String conf = "SecRuleEngine On\n" + + "SecRequestBodyAccess On\n" + // health-check bypass: the container wait strategy expects GET / to answer 200/403 + + "SecRule REQUEST_FILENAME \"@streq /\" \"id:2,phase:1,pass,nolog,ctl:ruleEngine=Off\"\n" + + "SecRule REQBODY_ERROR \"!@eq 0\" \"id:31,phase:2,deny,status:458\"\n" + + "SecRule &ARGS_POST:field1 \"@eq 1\" \"id:30,phase:2,deny,status:456\"\n" + + "SecRule FILES_NAMES \"@rx .\" \"id:32,phase:2,deny,status:457\"\n" + + "SecAction \"id:33,phase:2,deny,status:459\"\n"; + String boundary = "oashieldprobe"; + String textPart = "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"field1\"\r\n\r\n" + + "value1\r\n" + + "--" + boundary + "--\r\n"; + String filePart = "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file1\"; filename=\"a.txt\"\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "contents\r\n" + + "--" + boundary + "--\r\n"; + withWaf(engine, Map.of("main.conf", conf), waf -> { + report(engine, "multipart", "text-part", + post(waf, "/x", "multipart/form-data; boundary=" + boundary, textPart)); + report(engine, "multipart", "file-part", + post(waf, "/x", "multipart/form-data; boundary=" + boundary, filePart)); + report(engine, "multipart", "urlencoded(control)", + post(waf, "/x", "application/x-www-form-urlencoded", "field1=value1")); + }); + } + + // --------------------------------------------------------------- + // Probe 4: which JSON Schema keywords does Coraza's @validateSchema enforce, + // under which $schema draft? 456=schema violation detected, 459=passed through. + // A startup failure (exception) means the schema file failed to load. + // --------------------------------------------------------------- + @Test + void probeCorazaSchemaDrafts() throws Exception { + Map cases = new LinkedHashMap<>(); + // name -> {schema, violating body} + cases.put("d7-const", new String[] { + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"a\":{\"const\":5}}}", + "{\"a\":6}" }); + cases.put("d7-dependentRequired", new String[] { + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"dependentRequired\":{\"a\":[\"b\"]}}", + "{\"a\":1}" }); + cases.put("d7-dependencies", new String[] { + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"dependencies\":{\"a\":[\"b\"]}}", + "{\"a\":1}" }); + cases.put("d7-ifthenelse", new String[] { + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"if\":{\"properties\":{\"a\":{\"const\":1}}},\"then\":{\"required\":[\"b\"]}}", + "{\"a\":1}" }); + cases.put("2020-prefixItems", new String[] { + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"type\":\"object\",\"properties\":{\"arr\":{\"type\":\"array\",\"prefixItems\":[{\"type\":\"integer\"}]}}}", + "{\"arr\":[\"x\"]}" }); + cases.put("2020-const", new String[] { + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"type\":\"object\",\"properties\":{\"a\":{\"const\":5}}}", + "{\"a\":6}" }); + cases.put("noschema-const", new String[] { + "{\"type\":\"object\",\"properties\":{\"a\":{\"const\":5}}}", + "{\"a\":6}" }); + + String conf = "SecRuleEngine On\n" + + "SecRequestBodyAccess On\n" + // health-check bypass: the container wait strategy expects GET / to answer 200/403 + + "SecRule REQUEST_FILENAME \"@streq /\" \"id:2,phase:1,pass,nolog,ctl:ruleEngine=Off\"\n" + + "SecRule REQUEST_HEADERS:Content-Type \"@rx (?i)json\" \"id:1,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON\"\n" + + "SecRule REQUEST_BODY \"@validateSchema rules/probe-schema.json\" \"id:50,phase:2,deny,status:456\"\n" + + "SecAction \"id:51,phase:2,deny,status:459\"\n"; + for (Map.Entry e : cases.entrySet()) { + try { + withWaf("coraza", Map.of("main.conf", conf, "probe-schema.json", e.getValue()[0]), waf -> { + report("coraza", "schema-draft", e.getKey() + "/violating", + post(waf, "/x", "application/json", e.getValue()[1])); + report("coraza", "schema-draft", e.getKey() + "/conforming", + post(waf, "/x", "application/json", "{\"a\":5,\"b\":2,\"arr\":[1]}")); + }); + } catch (Exception ex) { + System.out.println("ENGINE-PROBE|coraza|schema-draft|" + e.getKey() + "|LOAD-ERROR: " + + ex.getMessage()); + } + } + } + + // --------------------------------------------------------------- + // Probe 5: XML support. Does the XML body processor flatten into ARGS / set + // REQBODY_ERROR, and does @validateSchema accept an XSD? + // Statuses: 456=xsd violation detected, 457=REQBODY_ERROR, 459=fallthrough + // --------------------------------------------------------------- + @ParameterizedTest + @ValueSource(strings = { "coraza", "modsecurity3" }) + void probeXmlValidation(String engine) throws Exception { + String xsdPath = engine.equals("coraza") + ? "rules/probe.xsd" + : "/etc/modsecurity.d/oashield/probe.xsd"; + String conf = "SecRuleEngine On\n" + + "SecRequestBodyAccess On\n" + // health-check bypass: the container wait strategy expects GET / to answer 200/403 + + "SecRule REQUEST_FILENAME \"@streq /\" \"id:2,phase:1,pass,nolog,ctl:ruleEngine=Off\"\n" + + "SecRule REQUEST_HEADERS:Content-Type \"@rx (?i)xml\" \"id:1,phase:1,pass,nolog,ctl:requestBodyProcessor=XML\"\n" + + "SecRule REQBODY_ERROR \"!@eq 0\" \"id:61,phase:2,deny,status:457\"\n" + + "SecRule XML \"@validateSchema " + xsdPath + "\" \"id:60,phase:2,deny,status:456\"\n" + + "SecAction \"id:62,phase:2,deny,status:459\"\n"; + String xsd = "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n"; + try { + withWaf(engine, Map.of("main.conf", conf, "probe.xsd", xsd), waf -> { + report(engine, "xml", "valid-xml", + post(waf, "/x", "application/xml", "1")); + report(engine, "xml", "xsd-violating-xml", + post(waf, "/x", "application/xml", "notanumber")); + report(engine, "xml", "malformed-xml", + post(waf, "/x", "application/xml", "1")); + }); + } catch (Exception ex) { + System.out.println("ENGINE-PROBE|" + engine + "|xml|LOAD-ERROR: " + ex.getMessage()); + } + } + + // ------------------------------------------------------------------ + // harness + // ------------------------------------------------------------------ + + private interface ProbeBody { + void run(String baseUrl) throws Exception; + } + + private void withWaf(String engine, Map files, ProbeBody body) throws Exception { + Path rulesDir = Files.createTempDirectory("oashield-probe"); + for (Map.Entry f : files.entrySet()) { + Files.writeString(rulesDir.resolve(f.getKey()), f.getValue()); + } + WafContainerManager mgr = "coraza".equals(engine) + ? new CorazaContainerManager(rulesDir.toAbsolutePath().toString()) + : new ModSecurityContainerManager(rulesDir.toAbsolutePath().toString()); + try { + String baseUrl = mgr.start(); + body.run(baseUrl); + } finally { + mgr.stop(); + } + } + + private static int post(String baseUrl, String path, String contentType, String body) + throws IOException, InterruptedException { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .timeout(Duration.ofSeconds(15)) + .POST(body == null + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)); + if (contentType != null) { + builder.header("Content-Type", contentType); + } + return HTTP.send(builder.build(), HttpResponse.BodyHandlers.ofString()).statusCode(); + } + + private static void report(String engine, String probe, String testCase, int status) { + System.out.println("ENGINE-PROBE|" + engine + "|" + probe + "|" + testCase + "|status=" + status); + } +} diff --git a/src/test/resources/golden/petstore/coraza/StoreApi.conf b/src/test/resources/golden/petstore/coraza/StoreApi.conf index 49afc7c..c35a840 100644 --- a/src/test/resources/golden/petstore/coraza/StoreApi.conf +++ b/src/test/resources/golden/petstore/coraza/StoreApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:.+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" +SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:[^/]+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200324,phase:2,pass,nolog,skipAfter:END_deleteOrder" diff --git a/src/test/resources/golden/petstore/coraza/UserApi.conf b/src/test/resources/golden/petstore/coraza/UserApi.conf index 36c78ab..c16696b 100644 --- a/src/test/resources/golden/petstore/coraza/UserApi.conf +++ b/src/test/resources/golden/petstore/coraza/UserApi.conf @@ -56,13 +56,21 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter: # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. -SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Handle Models SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200538,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithArrayInput" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4200539,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211042,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211054,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211066,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200540,phase:2,block,msg:'JSON schema validation failed for createUsersWithArrayInput',log,auditlog,skipAfter:FAILED_API_CHECKS" @@ -94,13 +102,21 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter: # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. -SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Handle Models SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200578,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithListInput" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4200579,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211178,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211190,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211202,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211214,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211226,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211238,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211250,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211262,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200580,phase:2,block,msg:'JSON schema validation failed for createUsersWithListInput',log,auditlog,skipAfter:FAILED_API_CHECKS" @@ -125,7 +141,7 @@ SecMarker END_createUsersWithListInput # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" +SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200605,phase:2,pass,nolog,skipAfter:END_deleteUser" @@ -154,7 +170,7 @@ SecMarker END_deleteUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" +SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200645,phase:2,pass,nolog,skipAfter:END_getUserByName" @@ -187,10 +203,10 @@ SecRule REQUEST_FILENAME "!@rx ^/user/login$" "id:4200684,phase:2,pass,nolog,ski # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200685,phase:2,pass,nolog,skipAfter:END_loginUser" -SecRule ARGS_GET:username "!@rx /^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$/" "id:4211206,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:username "@gt 1" "id:4211207,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS_GET:password "!@rx ^.+$" "id:4211246,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:password "@gt 1" "id:4211247,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:username "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4211398,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:username "@gt 1" "id:4211399,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:password "!@rx ^.+$" "id:4211438,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:password "@gt 1" "id:4211439,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. @@ -245,7 +261,7 @@ SecMarker END_logoutUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" +SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within PUT" "id:4200765,phase:2,pass,nolog,skipAfter:END_updateUser" @@ -259,14 +275,14 @@ SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200778,phase: # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4200779,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211322,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.username "!@rx ^.+$" "id:4211334,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211346,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211358,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.email "!@rx ^.+$" "id:4211370,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.password "!@rx ^.+$" "id:4211382,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.phone "!@rx ^.+$" "id:4211394,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211406,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211514,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4211526,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211538,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211550,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4211562,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4211574,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4211586,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211598,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200780,phase:2,block,msg:'JSON schema validation failed for updateUser',log,auditlog,skipAfter:FAILED_API_CHECKS" diff --git a/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf b/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf index 5879573..9885b15 100644 --- a/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:.+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" +SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:[^/]+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200324,phase:2,pass,nolog,skipAfter:END_deleteOrder" diff --git a/src/test/resources/golden/petstore/modsecurity3/UserApi.conf b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf index 3b597a6..9a38ed0 100644 --- a/src/test/resources/golden/petstore/modsecurity3/UserApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf @@ -53,13 +53,21 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter: # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. -SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Handle Models SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200538,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithArrayInput" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4200539,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211042,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211054,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211066,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4200541,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithArrayInput" SecMarker ENDJSON_createUsersWithArrayInput @@ -88,13 +96,21 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter: # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. -SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Handle Models SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200578,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithListInput" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4200579,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211178,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211190,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211202,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211214,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211226,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211238,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211250,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211262,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4200581,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithListInput" SecMarker ENDJSON_createUsersWithListInput @@ -116,7 +132,7 @@ SecMarker END_createUsersWithListInput # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" +SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200605,phase:2,pass,nolog,skipAfter:END_deleteUser" @@ -145,7 +161,7 @@ SecMarker END_deleteUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" +SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200645,phase:2,pass,nolog,skipAfter:END_getUserByName" @@ -178,10 +194,10 @@ SecRule REQUEST_FILENAME "!@rx ^/user/login$" "id:4200684,phase:2,pass,nolog,ski # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200685,phase:2,pass,nolog,skipAfter:END_loginUser" -SecRule ARGS_GET:username "!@rx /^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$/" "id:4211206,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:username "@gt 1" "id:4211207,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS_GET:password "!@rx ^.+$" "id:4211246,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:password "@gt 1" "id:4211247,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:username "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4211398,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:username "@gt 1" "id:4211399,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:password "!@rx ^.+$" "id:4211438,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:password "@gt 1" "id:4211439,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. @@ -236,7 +252,7 @@ SecMarker END_logoutUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:.+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" +SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within PUT" "id:4200765,phase:2,pass,nolog,skipAfter:END_updateUser" @@ -250,14 +266,14 @@ SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200778,phase: # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4200779,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211322,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.username "!@rx ^.+$" "id:4211334,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211346,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211358,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.email "!@rx ^.+$" "id:4211370,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.password "!@rx ^.+$" "id:4211382,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.phone "!@rx ^.+$" "id:4211394,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211406,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211514,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4211526,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211538,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211550,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4211562,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4211574,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4211586,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211598,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4200781,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updateUser" SecMarker ENDJSON_updateUser diff --git a/src/test/resources/specs/oas31-exclusives.yaml b/src/test/resources/specs/oas31-exclusives.yaml new file mode 100644 index 0000000..fe47c4c --- /dev/null +++ b/src/test/resources/specs/oas31-exclusives.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: OAS 3.1 numeric exclusive bounds + version: "1.0" +paths: + /items: + get: + operationId: listItems + parameters: + - name: count + in: query + schema: + type: integer + exclusiveMinimum: 0 + exclusiveMaximum: 100 + - name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + responses: + "200": + description: ok diff --git a/src/test/resources/specs/phase1-features.yaml b/src/test/resources/specs/phase1-features.yaml new file mode 100644 index 0000000..72f503d --- /dev/null +++ b/src/test/resources/specs/phase1-features.yaml @@ -0,0 +1,86 @@ +openapi: 3.0.3 +info: + title: Phase 1 fixes + version: "1.0" +paths: + /users/{name}: + get: + operationId: getUser + parameters: + - name: name + in: path + required: true + schema: + type: string + pattern: '^[a-z]{3,10}$' + responses: + "200": + description: ok + /files/{fileId}: + get: + operationId: getFile + parameters: + - name: fileId + in: path + required: true + schema: + type: string + responses: + "200": + description: ok + /search: + get: + operationId: search + parameters: + - name: q + in: query + schema: + type: string + pattern: '^[a-zA-Z0-9.]{1,20}$' + - name: flag + in: query + allowEmptyValue: true + schema: + type: string + pattern: '^[a-z]+$' + responses: + "200": + description: ok + /bulk-users: + post: + operationId: bulkCreateUsers + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BulkUser' + responses: + "200": + description: ok + /bulk-tags: + post: + operationId: bulkCreateTags + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: string + responses: + "200": + description: ok +components: + schemas: + BulkUser: + type: object + properties: + username: + type: string + level: + type: integer + enum: [1, 2, 3] From 3a309cd475664f30fb91127e726b587f5167320a Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 14:49:07 -0500 Subject: [PATCH 3/9] Add engine behavior probes and documented findings EngineBehaviorProbeTest (gated behind -DrunEngineProbes=true) probes JSON null flattening, empty-body signals, multipart ARGS_POST behavior, Coraza JSON Schema keyword support, and XML validation on both engines. Findings recorded in docs/engine-behavior.md. Co-Authored-By: Claude Fable 5 --- docs/engine-behavior.md | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/engine-behavior.md diff --git a/docs/engine-behavior.md b/docs/engine-behavior.md new file mode 100644 index 0000000..9078ad5 --- /dev/null +++ b/docs/engine-behavior.md @@ -0,0 +1,66 @@ +# Verified engine behavior (ModSecurity3 vs Coraza) + +Facts established by `EngineBehaviorProbeTest` (run manually: +`DOCKER_JAVA_PROPERTIES="api.version=1.44" mvn test -Dtest=EngineBehaviorProbeTest -DrunEngineProbes=true`). +Generator design decisions reference this file. Last run: 2026-07-04 against +`ghcr.io/cognitivegears/coraza-validate-server:latest` and `owasp/modsecurity-crs:nginx`. + +## JSON body flattening + +- Both engines flatten JSON bodies into `ARGS` with a `json.` prefix. +- Array elements: ModSecurity3 keys are `json.items.array_0`, Coraza `json.items.0` + (generated selectors use `(?:array_)?\d{1,9}` to match both). +- Coraza also lists container nodes (`json.category`, `json.photoUrls`) in + `ARGS_NAMES`, not just leaves — allowlists must include intermediate prefixes. +- **JSON `null` flattens to a present key with an EMPTY value on both engines** + (indistinguishable from `""`). Nullable properties are therefore validated with + an optional-wrapped value pattern; a required-presence rule still sees the key. + +## Request bodies + +- `REQBODY_ERROR` does **not** fire for an empty body with `Content-Type: + application/json` on either engine (the JSON processor accepts empty input). +- A request with no `Content-Type` header is reliably detectable with + `&REQUEST_HEADERS:Content-Type "@eq 0"` on both engines — this is the + optional-requestBody gate. +- ModSecurity3 sets `REQBODY_ERROR` for malformed JSON; Coraza does not (its + `@validateSchema` rejects malformed JSON instead). + +## multipart/form-data and form-urlencoded + +- Text parts of multipart bodies land in `ARGS_POST` on **both** engines, so the + same param rules and `ARGS_NAMES` allowlist cover urlencoded and multipart. +- File parts appear in `FILES_NAMES` (both engines), not in `ARGS_POST`. +- `REQBODY_ERROR` did not fire for well-formed multipart on either engine. + +## Coraza `@validateSchema` (JSON Schema) + +Coraza's validator honors modern keywords regardless of the declared `$schema` +draft. Verified enforced: `const`, `dependentRequired`, `if`/`then`/`else` +(under draft-07 *and* 2020-12), `prefixItems` (2020-12). Verified **ignored**: +legacy draft-07 `dependencies` — always emit `dependentRequired`, never +`dependencies`. + +ModSecurity3 has no JSON `@validateSchema` (XSD only), which is why the +modsecurity3 flavor relies on per-field rules. + +## XML + +- **Coraza fails config load** on `ctl:requestBodyProcessor=XML` / + `SecRule XML "@validateSchema ..."` — the coraza flavor cannot get XML + validation; it relies on content-type gating only. +- ModSecurity3 accepts `SecRule XML "@validateSchema "` at load, sets + `REQBODY_ERROR` on malformed XML, **but the operator matched valid and + XSD-violating documents alike in the probe** — suspected runtime XSD load/parse + failure matching everything. Needs investigation before the XSD subsystem + ships (see Phase 6); do not assume `@validateSchema` works until a + valid-document probe returns pass-through. + +## Container test harness + +- The Testcontainers wait strategy expects `GET /` to answer 200 or 403; probe + rule sets must include a health-check bypass + (`SecRule REQUEST_FILENAME "@streq /" "phase:1,pass,nolog,ctl:ruleEngine=Off"`). +- `owasp/modsecurity-crs:nginx` publishes linux/386 only — on other + architectures `docker pull --platform linux/386 owasp/modsecurity-crs:nginx`. +- Docker Engine 29+ needs `DOCKER_JAVA_PROPERTIES="api.version=1.44"`. From d70a466621e3143d0e970f3d7bb54177051b4002 Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 15:13:44 -0500 Subject: [PATCH 4/9] Rework media-type handling, fix false positives (Phases 2-3) Media types (items 2,4): each consume entry is classified (json/xml/ form-like/wildcard/other) with its own gate, rule ids, and pass path; form-urlencoded and multipart operations are no longer unconditionally blocked, */* skips the content-type gate, and declared-but-uninspectable types are governed by the new unknownMediaTypePolicy option. Optional request bodies (the OAS3 default) skip body checks when no Content-Type is present instead of being blocked. False positives (items 5-10): map/free-form object properties allowlist their subtrees (map values validated against the additionalProperties schema); nullable properties accept the empty value JSON null flattens to; explode=false arrays validate the joined CSV/space/pipe form; deepObject bracket keys are allowlisted; matrix/label path styles wrap their path segments; apiKey-in-query security parameters are allowlisted; readOnly required properties lose their presence rule; and the servers.url base path is auto-prefixed onto path regexes (overridable via the new basePath option). Array parameters now derive per-item patterns from the item schema instead of the container's flags. Also adds power-of-10 multipleOf groundwork, raw-body HTTP steps for the integration harness, and a media-type Cucumber scenario. Existing petstore scenarios move to /v2 paths per that spec's servers declaration. Co-Authored-By: Claude Fable 5 --- docs/configuration.md | 2 + samples/multipart.yaml | 82 +++++ .../modsecurity3/Modsecurity3Generator.java | 335 ++++++++++++++++-- .../resources/modsecurity3/config.mustache | 53 ++- .../modsecurity3/tests/EngineFlavorTest.java | 2 +- .../modsecurity3/tests/GoldenFileTest.java | 2 +- .../tests/MediaTypeHandlingTest.java | 127 +++++++ .../modsecurity3/tests/Phase3FixesTest.java | 112 ++++++ .../actions/HttpRequestAction.java | 32 ++ .../actions/TestActionService.java | 30 ++ .../integration/data/TestDataServiceTest.java | 18 +- .../steps/ModSecurityStepDefinitions.java | 28 ++ .../modsecurity_rule_generation.feature | 64 ++-- .../golden/composed/coraza/DefaultApi.conf | 58 +-- .../composed/modsecurity3/DefaultApi.conf | 54 +-- .../golden/getparam/coraza/PetApi.conf | 30 +- .../golden/getparam/modsecurity3/PetApi.conf | 30 +- .../golden/multipart/coraza/DefaultApi.conf | 182 ++++++++++ .../golden/multipart/coraza/mainconfig.conf | 20 ++ .../golden/multipart/coraza/schema.json | 18 + .../multipart/modsecurity3/DefaultApi.conf | 179 ++++++++++ .../multipart/modsecurity3/mainconfig.conf | 20 ++ .../golden/multipart/modsecurity3/schema.json | 18 + .../golden/petstore/coraza/PetApi.conf | 176 ++++----- .../golden/petstore/coraza/StoreApi.conf | 54 +-- .../golden/petstore/coraza/UserApi.conf | 176 ++++----- .../golden/petstore/modsecurity3/PetApi.conf | 176 ++++----- .../petstore/modsecurity3/StoreApi.conf | 52 +-- .../golden/petstore/modsecurity3/UserApi.conf | 174 ++++----- .../golden/urlintparam/coraza/PetApi.conf | 96 ++--- .../urlintparam/modsecurity3/PetApi.conf | 96 ++--- src/test/resources/specs/phase3-features.yaml | 90 +++++ .../{ => v2}/pet/invalid/extra_property.json | 0 .../{ => v2}/pet/invalid/invalid_type.json | 0 .../pet/invalid/missing_required.json | 0 .../petstore/{ => v2}/pet/valid.json | 0 36 files changed, 1946 insertions(+), 640 deletions(-) create mode 100644 samples/multipart.yaml create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/MediaTypeHandlingTest.java create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase3FixesTest.java create mode 100644 src/test/resources/golden/multipart/coraza/DefaultApi.conf create mode 100644 src/test/resources/golden/multipart/coraza/mainconfig.conf create mode 100644 src/test/resources/golden/multipart/coraza/schema.json create mode 100644 src/test/resources/golden/multipart/modsecurity3/DefaultApi.conf create mode 100644 src/test/resources/golden/multipart/modsecurity3/mainconfig.conf create mode 100644 src/test/resources/golden/multipart/modsecurity3/schema.json create mode 100644 src/test/resources/specs/phase3-features.yaml rename src/test/resources/test-data/petstore/{ => v2}/pet/invalid/extra_property.json (100%) rename src/test/resources/test-data/petstore/{ => v2}/pet/invalid/invalid_type.json (100%) rename src/test/resources/test-data/petstore/{ => v2}/pet/invalid/missing_required.json (100%) rename src/test/resources/test-data/petstore/{ => v2}/pet/valid.json (100%) diff --git a/docs/configuration.md b/docs/configuration.md index a551cf1..034bfc6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,6 +18,8 @@ the known limitations of request-body validation. | `denyRedirectUrl` | — | Absolute http(s) URL to redirect blocked requests to; required when `denyAction=redirect` | | `enableLogging` | `true` | Emit `log,auditlog` on generated rules; `false` emits `nolog` instead | | `includeEngineConfig` | `true` | Emit `SecRuleEngine On`, `SecRequestBodyAccess On`, and the `SecDefaultAction` in `mainconfig.conf`. Set `false` when your existing WAF configuration already defines these | +| `unknownMediaTypePolicy` | `pass` | Handling of declared request media types the WAF cannot inspect (e.g. `application/octet-stream`, `text/plain`): `pass` lets them through after the content-type gate, `block` rejects them | +| `basePath` | auto | Path prefix for all generated path-match rules. Defaults to the path component of the spec's first `servers.url` (server variables match one path segment). Pass an empty string to disable prefixing | Pass them comma-separated: diff --git a/samples/multipart.yaml b/samples/multipart.yaml new file mode 100644 index 0000000..89ccfd7 --- /dev/null +++ b/samples/multipart.yaml @@ -0,0 +1,82 @@ +openapi: 3.0.3 +info: + title: Media type handling + description: form-urlencoded, multipart, uninspectable and wildcard request bodies + version: "1.0" +paths: + /profile: + post: + operationId: updateProfile + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + displayName: + type: string + pattern: '^[a-zA-Z ]{1,30}$' + age: + type: integer + responses: + "200": + description: ok + /avatar: + post: + operationId: uploadAvatar + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + caption: + type: string + file: + type: string + format: binary + responses: + "200": + description: ok + /blob: + post: + operationId: uploadBlob + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: ok + /anything: + post: + operationId: postAnything + requestBody: + content: + '*/*': + schema: + type: string + responses: + "200": + description: ok + /note: + post: + operationId: postNote + requestBody: + required: false + content: + application/json: + schema: + type: object + required: [text] + properties: + text: + type: string + responses: + "200": + description: ok diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index f467323..f89022b 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -62,6 +62,12 @@ public void setOutputDir(String dir) { private int denyStatus = 403; private String denyRedirectUrl = null; private boolean enableLogging = true; + // Policy for declared media types the WAF cannot inspect (e.g. + // application/octet-stream): pass them through or block them. + private String unknownMediaTypePolicy = "pass"; + // Deployed base path prefix for path-match regexes; null = auto-extract from + // the first servers.url, "" = no prefix. + private String basePathOverride = null; // false = emit no SecRuleEngine/SecRequestBodyAccess/SecDefaultAction, for // deployments whose existing ModSecurity config already sets them private boolean includeEngineConfig = true; @@ -155,6 +161,21 @@ public void processOpts() { LOGGER.info("includeEngineConfig set to: {}", includeEngineConfig); } + if (additionalProperties.containsKey("unknownMediaTypePolicy")) { + unknownMediaTypePolicy = additionalProperties.get("unknownMediaTypePolicy").toString(); + if (!Arrays.asList("pass", "block").contains(unknownMediaTypePolicy)) { + throw new IllegalArgumentException( + "Unknown unknownMediaTypePolicy '" + unknownMediaTypePolicy + "'; expected 'pass' or 'block'"); + } + LOGGER.info("unknownMediaTypePolicy set to: {}", unknownMediaTypePolicy); + } + additionalProperties.put("blockOtherMedia", "block".equals(unknownMediaTypePolicy)); + + if (additionalProperties.containsKey("basePath")) { + basePathOverride = additionalProperties.get("basePath").toString(); + LOGGER.info("basePath set to: '{}'", basePathOverride); + } + // Real boolean for the mustache section; derived strings so templates stay flat additionalProperties.put("includeEngineConfig", includeEngineConfig); additionalProperties.put("logAction", enableLogging ? "log,auditlog" : "nolog"); @@ -193,6 +214,13 @@ private String buildDenyActionDirective() { private static final String FLAVOR_MODSECURITY3 = "modsecurity3"; private static final String FLAVOR_CORAZA = "coraza"; + // Media-type classification keys set on each consume entry (exactly one is "true") + static final String CONSUME_JSON = "isJson"; + static final String CONSUME_XML = "isXml"; + static final String CONSUME_FORM_LIKE = "isFormLike"; + static final String CONSUME_WILDCARD = "isWildcardAll"; + static final String CONSUME_OTHER = "isOtherMedia"; + // Prefix both engines use when flattening JSON bodies into ARGS private static final String JSON_ARGS_PREFIX = "json."; // ModSecurity3 keys array elements "json.items.array_0", Coraza "json.items.0"; @@ -377,6 +405,10 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List opList = ops.getOperation(); + // Deployed base path (servers.url path component or basePath override), + // prepended to every operation's path-match regex. + String basePathRegex = buildBasePathRegex(); + // $ref properties carry no vars of their own; resolve them via the model list Map modelLookup = new HashMap(); if (allModels != null) { @@ -396,27 +428,46 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List consume : co.consumes) { - if (consume.containsKey("isJson")) { - String isJsonString = consume.get("isJson"); - includeRequestJSON = isJsonString != null && isJsonString.equals("true"); - } - if (consume.containsKey("isXml")) { - String isXmlString = consume.get("isXml"); - includeRequestXML = isXmlString != null && isXmlString.equals("true"); - } - // mediaType can contain regex metacharacters (e.g. application/vnd.api+json) String mediaType = consume.get("mediaType"); + // Canonical single classification key per consume entry. DefaultCodegen's + // isJson/isXml string flags are replaced: the string "false" is truthy in + // mustache sections, and unclassified media types previously fell through + // to an unconditional block (form/multipart operations could never succeed). + String classification = classifyMediaType(mediaType); + consume.remove("isJson"); + consume.remove("isXml"); + consume.put(classification, "true"); + includeRequestJSON |= CONSUME_JSON.equals(classification); + includeRequestXML |= CONSUME_XML.equals(classification); + + // Unique marker suffix and rule ids per consume entry: two consumes of the + // same class would otherwise emit duplicate SecMarker names and rule ids. + consume.put("consumeIndex", String.valueOf(consumeIndex++)); + consume.put("oasGateId", String.valueOf(globalParamIndex++)); + consume.put("oasBodyErrId", String.valueOf(globalParamIndex++)); + consume.put("oasSchemaId", String.valueOf(globalParamIndex++)); + consume.put("oasPassId", String.valueOf(globalParamIndex++)); + + // mediaType can contain regex metacharacters (e.g. application/vnd.api+json); + // '*' wildcards (application/*) match any token in that position if (mediaType != null) { - consume.put("mediaTypeRegex", escapeRegexLiteral(mediaType)); + consume.put("mediaTypeRegex", escapeRegexLiteral(mediaType).replace("\\*", "[^/\\s]+")); } } + + // OAS3 requestBody.required defaults to FALSE: a bodiless request to an + // operation with an optional body must skip the body checks instead of + // being blocked by the content-type fallthrough. + if (!isRequestBodyRequired(co)) { + co.vendorExtensions.put("x-codegen-optionalBody", true); + } } // Add vendor extension for JSON and XML @@ -530,11 +581,40 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List paramUnion = unionMembers(param.getComposedSchemas()); if (paramUnion != null) { patternString = patternGenerationService.getComposedPattern(paramUnion, param.required); + } else if (param.isArray && param.items != null) { + // array parameters validate each value against the ITEM schema (the + // param's own flags describe the container, not the elements) + String itemPattern = sanitizeSpecPattern(param.items.pattern); + if (itemPattern == null || itemPattern.isEmpty() || isInvalidPattern(itemPattern)) { + itemPattern = patternGenerationService.getPropertyPattern(param.items); + } + patternString = itemPattern; } else { patternString = getParamPattern(param); } LOGGER.debug("Calculated pattern string {}", patternString); } + // multipleOf: power-of-10 multiples of integers are expressible as a + // trailing-zeros pattern; anything else is enforced via schema.json only. + int paramZeros = powerOfTenZeros(param.getMultipleOf()); + if (paramZeros > 0 && !param.isArray && (param.isInteger || param.isLong)) { + patternString = "^(?:0|[0-9]{1," + (19 - paramZeros) + "}0{" + paramZeros + "})$"; + } + // explode=false arrays arrive as ONE delimited value (CSV / space / pipe + // per style), so validate the joined form and suppress the per-value + // count rules, whose &ARGS count would always be 1. + if (param.isArray && !param.isExplode && (param.isQueryParam || param.isFormParam) + && patternString != null && !patternString.isEmpty()) { + patternString = buildJoinedArrayPattern(param, patternString); + param.vendorExtensions.put("x-codegen-joinedArray", true); + param.vendorExtensions.put(MODSECURITY_HAS_ARRAY_MIN, false); + param.vendorExtensions.put(MODSECURITY_HAS_ARRAY_MAX, false); + } + // deepObject query params serialize as name[prop]=value: allow those keys + // (the scalar ARGS_GET:name rules no-op on an empty collection). + if (param.isDeepObject && param.isQueryParam) { + argsAllowlist.add(escapeRegexLiteral(param.baseName) + "\\[[^\\]]{1,64}\\]"); + } // allowEmptyValue: an empty value is explicitly valid for this parameter if (param.isAllowEmptyValue && patternString != null && !patternString.isEmpty()) { patternString = "^(?:" + stripAnchors(patternString) + ")?$"; @@ -549,11 +629,23 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List flattenModel(CodegenProperty currentProperty, Strin return properties; } + // Map / free-form object: arbitrary keys are legal beneath this path, so it + // becomes a wildcard leaf instead of being dropped (free-form objects have no + // resolvable vars and previously produced nothing, blocking every key). + if (currentProperty.isMap || currentProperty.isFreeFormObject) { + properties.add(flattenedLeaf(currentProperty, baseNamePrefix)); + return properties; + } + if (currentProperty.isArray) { List itemVars = null; if (currentProperty.vars != null && !currentProperty.vars.isEmpty()) { @@ -655,6 +755,61 @@ public List flattenModel(CodegenProperty currentProperty, Strin return properties; } + /** + * Classify a request media type into the template section that handles it. + * JSON and XML get body validation; form-urlencoded/multipart rely on the + * ARGS_POST parameter rules; "*/*" accepts anything; everything else is an + * uninspectable declared type governed by unknownMediaTypePolicy. + */ + public static String classifyMediaType(String mediaType) { + if (mediaType == null) { + return CONSUME_OTHER; + } + String mt = mediaType.trim().toLowerCase(java.util.Locale.ROOT); + if (mt.startsWith("*/*")) { + return CONSUME_WILDCARD; + } + if (mt.matches("^application/(?:[a-z0-9.+-]+\\+)?json\\b.*")) { + return CONSUME_JSON; + } + if (mt.matches("^(?:application|text)/(?:[a-z0-9.+-]+\\+)?xml\\b.*")) { + return CONSUME_XML; + } + if (mt.startsWith("application/x-www-form-urlencoded") || mt.startsWith("multipart/")) { + return CONSUME_FORM_LIKE; + } + return CONSUME_OTHER; + } + + /** + * Resolve the raw spec requestBody.required for an operation. CodegenOperation + * does not expose it for form-param operations (their body param is dissolved + * into formParams), so read it from the parsed OpenAPI document. + */ + private boolean isRequestBodyRequired(CodegenOperation co) { + if (this.openAPI == null || this.openAPI.getPaths() == null) { + return false; + } + io.swagger.v3.oas.models.PathItem pathItem = this.openAPI.getPaths().get(co.path); + if (pathItem == null) { + return false; + } + io.swagger.v3.oas.models.Operation rawOp; + try { + rawOp = pathItem.readOperationsMap() + .get(io.swagger.v3.oas.models.PathItem.HttpMethod + .valueOf(co.httpMethod.toUpperCase(java.util.Locale.ROOT))); + } catch (IllegalArgumentException e) { + return false; + } + if (rawOp == null || rawOp.getRequestBody() == null) { + return false; + } + io.swagger.v3.oas.models.parameters.RequestBody body = + org.openapitools.codegen.utils.ModelUtils.getReferencedRequestBody(this.openAPI, rawOp.getRequestBody()); + return body != null && Boolean.TRUE.equals(body.getRequired()); + } + /** * Collect the oneOf/anyOf members of a composed schema. Both keywords get the * same WAF treatment (a value passing any member is allowed), so they are merged. @@ -687,6 +842,28 @@ private static List lookupModelVars(CodegenProperty prop, Map 1, + * 100 -> 2, ...); -1 when the value is not a power of ten >= 10. + */ + static int powerOfTenZeros(Number multipleOf) { + if (multipleOf == null) { + return -1; + } + java.math.BigDecimal value; + try { + value = new java.math.BigDecimal(multipleOf.toString()).stripTrailingZeros(); + } catch (NumberFormatException e) { + return -1; + } + // a power of ten >= 10 strips to unscaled value 1 with negative scale + if (java.math.BigDecimal.ONE.compareTo(new java.math.BigDecimal(value.unscaledValue())) != 0 + || value.scale() >= 0) { + return -1; + } + return -value.scale(); + } + /** * Copy a leaf property with the flattened name instead of mutating it: models are * shared between operations, so in-place mutation would double-prefix the second @@ -728,12 +905,13 @@ public static String sanitizeSpecPattern(String pattern) { * Build the operation's path-match regex: literal segments are regex-escaped and * each {param} placeholder is replaced with that path parameter's validation * pattern (anchors stripped). Unknown parameters fall back to a single segment. + * matrix/label style path params include their style prefix in the match. */ String buildPathMatchRegex(CodegenOperation co) { - Map paramPatterns = new HashMap(); + Map pathParams = new HashMap(); for (CodegenParameter param : co.allParams) { - if (param.isPathParam && param.pattern != null && !param.pattern.isEmpty()) { - paramPatterns.put(param.baseName, param.pattern); + if (param.isPathParam) { + pathParams.put(param.baseName, param); } } @@ -742,18 +920,93 @@ String buildPathMatchRegex(CodegenOperation co) { int last = 0; while (m.find()) { regex.append(escapeRegexLiteral(co.path.substring(last, m.start()))); - String pattern = paramPatterns.get(m.group(1)); - if (pattern != null) { - regex.append("(?:").append(stripAnchors(pattern)).append(")"); + CodegenParameter param = pathParams.get(m.group(1)); + String core; + if (param != null && param.pattern != null && !param.pattern.isEmpty()) { + core = "(?:" + stripAnchors(param.pattern) + ")"; } else { - regex.append("[^/]+"); + core = "[^/]+"; + } + if (param != null && "matrix".equals(param.style)) { + core = ";" + escapeRegexLiteral(m.group(1)) + "=" + core; + } else if (param != null && "label".equals(param.style)) { + core = "\\." + core; } + regex.append(core); last = m.end(); } regex.append(escapeRegexLiteral(co.path.substring(last))); return regex.toString(); } + /** + * Joined (explode=false) array parameter pattern: the whole delimited list in a + * single value, item repetitions bounded by minItems/maxItems. + */ + String buildJoinedArrayPattern(CodegenParameter param, String itemPattern) { + String sep = ","; + if ("spaceDelimited".equals(param.style)) { + sep = " "; + } else if ("pipeDelimited".equals(param.style)) { + sep = "\\|"; + } + String item = "(?:" + stripAnchors(itemPattern) + ")"; + int lo = param.getMinItems() != null && param.getMinItems() > 0 ? param.getMinItems() - 1 : 0; + // ponytail: 999-item ceiling keeps the quantifier bounded (ReDoS hygiene) + String hi = param.getMaxItems() != null && param.getMaxItems() > 0 + ? String.valueOf(param.getMaxItems() - 1) + : "999"; + return "^" + item + "(?:" + sep + item + "){" + lo + "," + hi + "}$"; + } + + /** + * Regex prefix for the deployed base path: the basePath CLI option when set + * (empty string disables prefixing), otherwise the path component of the first + * servers.url. Server-URL template variables match one path segment. + */ + String buildBasePathRegex() { + String path = basePathOverride; + if (path == null && this.openAPI != null && this.openAPI.getServers() != null + && !this.openAPI.getServers().isEmpty()) { + String url = this.openAPI.getServers().get(0).getUrl(); + if (url != null) { + if (url.startsWith("/")) { + path = url; + } else { + try { + // {variables} are not URI-legal; neutralize for parsing, then restore + String parsed = java.net.URI.create(url.replace("{", "%7B").replace("}", "%7D")).getPath(); + path = parsed != null ? parsed.replace("%7B", "{").replace("%7D", "}") : null; + } catch (IllegalArgumentException e) { + LOGGER.warn("Cannot parse server URL '{}'; no base path prefix applied", url); + } + } + } + } + if (path == null) { + return ""; + } + path = path.trim(); + while (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + if (path.isEmpty()) { + return ""; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + StringBuilder regex = new StringBuilder(); + java.util.regex.Matcher m = java.util.regex.Pattern.compile("\\{[^/{}]+\\}").matcher(path); + int last = 0; + while (m.find()) { + regex.append(escapeRegexLiteral(path.substring(last, m.start()))).append("[^/]+"); + last = m.end(); + } + regex.append(escapeRegexLiteral(path.substring(last))); + return regex.toString(); + } + static String stripAnchors(String pattern) { String result = pattern; if (result.startsWith("^")) { @@ -798,6 +1051,30 @@ private void decorateBodyProperty(CodegenProperty prop, java.util.Collection SAMPLES = List.of("petstore", "composed", "getparam", "urlintparam"); + private static final List SAMPLES = List.of("petstore", "composed", "getparam", "urlintparam", "multipart"); private static final List FLAVORS = List.of("modsecurity3", "coraza"); static Stream cases() { diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/MediaTypeHandlingTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/MediaTypeHandlingTest.java new file mode 100644 index 0000000..53873eb --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/MediaTypeHandlingTest.java @@ -0,0 +1,127 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; + +import com.oashield.openapi.generators.modsecurity3.Modsecurity3Generator; + +/** + * Media-type handling (Phase 2): form/multipart/other/wildcard consumes must not + * be unconditionally blocked, and optional request bodies must allow bodiless + * requests. + */ +public class MediaTypeHandlingTest { + + @TempDir + static Path outputDir; + private static String conf; + + @BeforeAll + static void generate() throws IOException { + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("samples/multipart.yaml") + .setOutputDir(outputDir.toString()) + .toClientOptInput()) + .generate(); + try (Stream files = Files.list(outputDir)) { + conf = files.filter(p -> p.getFileName().toString().endsWith("Api.conf")) + .map(p -> { + try { + return Files.readString(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.joining("\n")); + } + } + + private static String section(String operationId) { + int start = conf.indexOf("# " + operationId + ":"); + int end = conf.indexOf("SecMarker END_" + operationId); + assertTrue(start >= 0 && end > start, "operation section not found: " + operationId + "\n" + conf); + return conf.substring(start, end); + } + + @Test + void classifyMediaTypeCoversAllClasses() { + assertEquals("isJson", Modsecurity3Generator.classifyMediaType("application/json")); + assertEquals("isJson", Modsecurity3Generator.classifyMediaType("application/vnd.api+json")); + assertEquals("isXml", Modsecurity3Generator.classifyMediaType("text/xml")); + assertEquals("isXml", Modsecurity3Generator.classifyMediaType("application/soap+xml")); + assertEquals("isFormLike", Modsecurity3Generator.classifyMediaType("application/x-www-form-urlencoded")); + assertEquals("isFormLike", Modsecurity3Generator.classifyMediaType("multipart/form-data")); + assertEquals("isWildcardAll", Modsecurity3Generator.classifyMediaType("*/*")); + assertEquals("isOtherMedia", Modsecurity3Generator.classifyMediaType("application/octet-stream")); + assertEquals("isOtherMedia", Modsecurity3Generator.classifyMediaType("text/plain")); + } + + @Test + void formOperationPassesToAfterConsumes() { + String op = section("updateProfile"); + assertTrue(op.contains("skipAfter:AFTER_CONSUMES_updateProfile"), + "form operation must be able to reach AFTER_CONSUMES:\n" + op); + assertTrue(op.contains("^application/x-www-form-urlencoded"), + "form operation should gate on its media type:\n" + op); + } + + @Test + void multipartOperationPassesToAfterConsumes() { + String op = section("uploadAvatar"); + assertTrue(op.contains("^multipart/form-data"), "multipart gate missing:\n" + op); + assertTrue(op.contains("skipAfter:AFTER_CONSUMES_uploadAvatar"), + "multipart operation must be able to reach AFTER_CONSUMES:\n" + op); + } + + @Test + void octetStreamOperationPassesByDefaultPolicy() { + String op = section("uploadBlob"); + assertTrue(op.contains("^application/octet-stream"), "octet-stream gate missing:\n" + op); + assertTrue(op.contains("skipAfter:AFTER_CONSUMES_uploadBlob"), + "octet-stream should pass under the default policy:\n" + op); + assertFalse(op.contains("Uninspectable media type blocked"), + "default policy must not emit the block action:\n" + op); + } + + @Test + void wildcardConsumesSkipsContentTypeGate() { + String op = section("postAnything"); + assertTrue(op.contains("skipAfter:AFTER_CONSUMES_postAnything"), + "wildcard operation must pass:\n" + op); + assertFalse(op.contains("[^/\\s]+/[^/\\s]+"), + "*/* must not emit a content-type gate at all:\n" + op); + assertFalse(op.contains("@rx ^\\*"), "escaped literal * gate must be gone:\n" + op); + } + + @Test + void optionalBodySkipsBodyChecksWhenNoContentType() { + String op = section("postNote"); + assertTrue(op.contains("SecRule &REQUEST_HEADERS:Content-Type \"@eq 0\""), + "optional body should short-circuit on missing Content-Type:\n" + op); + // required-property rule still present for requests that DO send a body + assertTrue(op.contains("Missing required property json.text"), + "required property rule should remain for present bodies:\n" + op); + } + + @Test + void requiredBodyDoesNotGetTheBodilessSkip() { + String op = section("updateProfile"); + assertFalse(op.contains("SecRule &REQUEST_HEADERS:Content-Type \"@eq 0\""), + "required body must not skip body checks:\n" + op); + } +} diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase3FixesTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase3FixesTest.java new file mode 100644 index 0000000..d4ffc52 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase3FixesTest.java @@ -0,0 +1,112 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; + +/** + * False-positive fixes: additionalProperties/maps, nullable, serialization + * styles, security-scheme parameters, readOnly required properties, and server + * base paths. + */ +public class Phase3FixesTest { + + @TempDir + static Path outputDir; + private static String conf; + + @BeforeAll + static void generate() throws IOException { + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("src/test/resources/specs/phase3-features.yaml") + .setOutputDir(outputDir.toString()) + .toClientOptInput()) + .generate(); + try (Stream files = Files.list(outputDir)) { + conf = files.filter(p -> p.getFileName().toString().endsWith("Api.conf")) + .map(p -> { + try { + return Files.readString(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.joining("\n")); + } + } + + @Test + void serverBasePathIsPrefixedWithVariableAsSegment() { + assertTrue(conf.contains("\"!@rx ^/base/[^/]+/things"), + "servers.url path (with {version} variable) should prefix path regexes:\n" + conf); + } + + @Test + void apiKeyQueryParameterIsAllowlisted() { + assertTrue(conf.matches("(?s).*ARGS_NAMES \"!@rx \\^\\(\\?:[^\"]*api_key[^\"]*\\)\\$\".*"), + "api_key security parameter should be in the ARGS_NAMES allowlist:\n" + conf); + } + + @Test + void csvArrayValidatesJoinedForm() { + assertTrue(conf.contains("ARGS_GET:tags \"!@rx ^(?:[a-z]{1,10})(?:,(?:[a-z]{1,10})){1,4}$\""), + "explode=false array should validate the joined CSV form:\n" + conf); + assertFalse(conf.contains("&ARGS_GET:tags \"@lt"), + "joined arrays must not emit per-value count rules:\n" + conf); + } + + @Test + void pipeDelimitedArrayUsesPipeSeparator() { + assertTrue(conf.contains("ARGS_GET:ids \"!@rx ^(?:[0-9]{1,19})(?:\\|(?:[0-9]{1,19})){0,999}$\""), + "pipeDelimited array should join with | :\n" + conf); + } + + @Test + void deepObjectKeysAreAllowlisted() { + assertTrue(conf.contains("filter\\[[^\\]]{1,64}\\]"), + "deepObject bracket keys should be allowlisted:\n" + conf); + } + + @Test + void readOnlyRequiredPropertyHasNoPresenceRule() { + assertFalse(conf.contains("Missing required property json.id"), + "readOnly required properties may be omitted from requests:\n" + conf); + assertTrue(conf.contains("Missing required property json.name"), + "regular required properties keep their presence rule:\n" + conf); + } + + @Test + void nullablePropertyAcceptsEmptyValue() { + assertTrue(conf.contains("ARGS:json.nickname \"!@rx ^(?:.+)?$\""), + "nullable property pattern should accept the empty (null) form:\n" + conf); + } + + @Test + void mapPropertyAllowsArbitraryKeysAndValidatesValues() { + assertTrue(conf.contains("json\\.attrs\\..{1,256}"), + "map property should allowlist arbitrary sub-keys:\n" + conf); + assertTrue(conf.contains("ARGS:/^json\\.attrs\\.[^.]{1,64}$/ \"!@rx ^[a-z]{1,20}$\""), + "map values should be validated against the additionalProperties schema:\n" + conf); + } + + @Test + void freeFormObjectAllowsArbitraryKeysWithoutValueRule() { + assertTrue(conf.contains("json\\.misc\\..{1,256}"), + "free-form object should allowlist arbitrary sub-keys:\n" + conf); + assertFalse(conf.contains("ARGS:json.misc \"!@rx"), + "free-form object must not get a scalar value rule:\n" + conf); + } +} diff --git a/src/test/java/com/oashield/openapi/integration/actions/HttpRequestAction.java b/src/test/java/com/oashield/openapi/integration/actions/HttpRequestAction.java index 0618996..934096f 100644 --- a/src/test/java/com/oashield/openapi/integration/actions/HttpRequestAction.java +++ b/src/test/java/com/oashield/openapi/integration/actions/HttpRequestAction.java @@ -37,6 +37,38 @@ public static Response executePostRequest(String url, String requestBody) { .post(url); } + /** + * Execute a POST request with an explicit content type (form, multipart, + * binary, ...) and return the status code. Uses java.net.http directly: + * RestAssured's encoder registry re-encodes or rejects raw form/multipart + * string bodies, but the WAF must see the body verbatim. + * + * @param url the URL to send the POST request to + * @param contentType the Content-Type header value, or null to omit the header + * @param requestBody the raw body as String, or null for no body + * @return the HTTP status code + */ + public static int executeRawPostRequest(String url, String contentType, String requestBody) { + logger.info("Executing raw POST request to URL: {} with content type: {}", url, contentType); + try { + java.net.http.HttpRequest.Builder builder = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(url)) + .timeout(java.time.Duration.ofSeconds(30)) + .POST(requestBody == null + ? java.net.http.HttpRequest.BodyPublishers.noBody() + : java.net.http.HttpRequest.BodyPublishers.ofString(requestBody, + java.nio.charset.StandardCharsets.UTF_8)); + if (contentType != null) { + builder.header("Content-Type", contentType); + } + return java.net.http.HttpClient.newHttpClient() + .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString()) + .statusCode(); + } catch (java.io.IOException | InterruptedException e) { + throw new RuntimeException("Raw POST request to " + url + " failed", e); + } + } + /** * Execute an HTTP request with specified method. * diff --git a/src/test/java/com/oashield/openapi/integration/actions/TestActionService.java b/src/test/java/com/oashield/openapi/integration/actions/TestActionService.java index c74446e..ef3b91f 100644 --- a/src/test/java/com/oashield/openapi/integration/actions/TestActionService.java +++ b/src/test/java/com/oashield/openapi/integration/actions/TestActionService.java @@ -87,6 +87,36 @@ public static Response executePostRequest(String path, String requestBody) { return HttpRequestAction.executePostRequest(buildUrl(path), requestBody); } + /** + * Execute POST request with an explicit content type (raw body, no client-side + * re-encoding) to given path and return the status code. + * + * @param path endpoint path or full URL + * @param contentType Content-Type header value, or null to omit + * @param requestBody raw body as String, or null for no body + * @return status code, or null if skipped via skip.http.calls + */ + public static Integer executeRawPostStatus(String path, String contentType, String requestBody) { + if (configService.isHttpCallsSkipped()) { + logger.info("Skipping raw POST request to {} due to skip.http.calls", path); + return null; + } + return HttpRequestAction.executeRawPostRequest(buildUrl(path), contentType, requestBody); + } + + /** + * Assert a raw-request status code; a null actual status means the request was + * skipped (skip.http.calls) and the assertion is a no-op. + */ + public static void assertRawStatus(Integer actual, int expected, String context) { + if (actual == null) { + return; + } + if (actual != expected) { + throw new AssertionError(context + ": expected status " + expected + " but got " + actual); + } + } + /** * Execute HTTP request with specified method to given path. * diff --git a/src/test/java/com/oashield/openapi/integration/data/TestDataServiceTest.java b/src/test/java/com/oashield/openapi/integration/data/TestDataServiceTest.java index 635b8a6..c832578 100644 --- a/src/test/java/com/oashield/openapi/integration/data/TestDataServiceTest.java +++ b/src/test/java/com/oashield/openapi/integration/data/TestDataServiceTest.java @@ -94,7 +94,7 @@ void testSingletonThreadSafety() throws InterruptedException { void testParseSpecNameValid() throws Exception { Method method = TestDataService.class.getDeclaredMethod("parseSpecName", String.class); method.setAccessible(true); - assertEquals("petstore", method.invoke(service, "petstore:/pet")); + assertEquals("petstore", method.invoke(service, "petstore:/v2/pet")); assertEquals("getparam", method.invoke(service, "getparam:/pet/findByStatus")); } @@ -114,7 +114,7 @@ void testParseSpecNameInvalidFormats() throws Exception { void testParseEndpointPathValid() throws Exception { Method method = TestDataService.class.getDeclaredMethod("parseEndpointPath", String.class); method.setAccessible(true); - assertEquals("pet", method.invoke(service, "petstore:/pet")); + assertEquals("v2/pet", method.invoke(service, "petstore:/v2/pet")); assertEquals("pet/findByStatus", method.invoke(service, "getparam:/pet/findByStatus")); } @@ -133,18 +133,18 @@ void testParseEndpointPathInvalidFormats() throws Exception { @Test void testGetValidRequestBody() throws Exception { - String result = service.getValidRequestBody("petstore:/pet"); + String result = service.getValidRequestBody("petstore:/v2/pet"); String testDataDir = TestConfigurationService.getInstance().getTestDataDirectory(); - Path path = Paths.get(testDataDir, "test-data", "petstore", "pet", "valid.json"); + Path path = Paths.get(testDataDir, "test-data", "petstore", "v2", "pet", "valid.json"); String expected = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); assertEquals(expected, result); } @Test void testGetInvalidRequestBody() { - String result = service.getInvalidRequestBody("petstore:/pet", "missing_required"); + String result = service.getInvalidRequestBody("petstore:/v2/pet", "missing_required"); String testDataDir = TestConfigurationService.getInstance().getTestDataDirectory(); - Path path = Paths.get(testDataDir, "test-data", "petstore", "pet", "invalid", + Path path = Paths.get(testDataDir, "test-data", "petstore", "v2", "pet", "invalid", "missing_required.json"); try { String expected = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); @@ -159,7 +159,7 @@ void testTemplateNotFound() { assertThrows(TemplateNotFoundException.class, () -> service.getValidRequestBody("petstore:/nonexistent")); assertThrows(TemplateNotFoundException.class, - () -> service.getInvalidRequestBody("petstore:/pet", "nonexistent")); + () -> service.getInvalidRequestBody("petstore:/v2/pet", "nonexistent")); } @Test @@ -187,9 +187,9 @@ void testCachingBehavior() throws Exception { Map cache = (Map) cacheField.get(service); cache.clear(); assertTrue(cache.isEmpty()); - service.getValidRequestBody("petstore:/pet"); + service.getValidRequestBody("petstore:/v2/pet"); assertEquals(1, cache.size()); - service.getValidRequestBody("petstore:/pet"); + service.getValidRequestBody("petstore:/v2/pet"); assertEquals(1, cache.size()); } diff --git a/src/test/java/com/oashield/openapi/integration/steps/ModSecurityStepDefinitions.java b/src/test/java/com/oashield/openapi/integration/steps/ModSecurityStepDefinitions.java index efd58b0..269ee21 100644 --- a/src/test/java/com/oashield/openapi/integration/steps/ModSecurityStepDefinitions.java +++ b/src/test/java/com/oashield/openapi/integration/steps/ModSecurityStepDefinitions.java @@ -156,4 +156,32 @@ public void postRequestWithNamedInvalidBodyShouldBeBlocked(String path, String v TestActionService.assertStatusCodeWithRelaxedValidation(response, expectedStatus, "POST request with invalid body '" + variant + "' to " + path); } + + @Then("a POST request to {string} with content type {string} and body {string} should return a {int} status code") + public void postRequestWithContentTypeShouldReturnStatusCode(String path, String contentType, String body, + int expectedStatus) { + Integer status = TestActionService.executeRawPostStatus(path, contentType, unescapeBody(body)); + TestActionService.assertRawStatus(status, expectedStatus, "POST " + contentType + " request to " + path); + } + + @Then("a POST request to {string} with content type {string} and body {string} should be blocked with a {int} status code") + public void postRequestWithContentTypeShouldBeBlocked(String path, String contentType, String body, + int expectedStatus) { + Integer status = TestActionService.executeRawPostStatus(path, contentType, unescapeBody(body)); + TestActionService.assertRawStatus(status, expectedStatus, "POST " + contentType + " request to " + path); + } + + @Then("a POST request to {string} with no body should return a {int} status code") + public void postRequestWithoutBodyShouldReturnStatusCode(String path, int expectedStatus) { + Integer status = TestActionService.executeRawPostStatus(path, null, null); + TestActionService.assertRawStatus(status, expectedStatus, "bodiless POST request to " + path); + } + + /** + * Gherkin string arguments cannot carry real CRLFs; multipart bodies in the + * feature file write them as literal \r\n sequences. + */ + private static String unescapeBody(String body) { + return body.replace("\\r\\n", "\r\n"); + } } diff --git a/src/test/resources/features/modsecurity_rule_generation.feature b/src/test/resources/features/modsecurity_rule_generation.feature index fbda397..7d54628 100644 --- a/src/test/resources/features/modsecurity_rule_generation.feature +++ b/src/test/resources/features/modsecurity_rule_generation.feature @@ -10,12 +10,12 @@ Feature: ModSecurity Rule Generation and Testing Given an OpenAPI specification file at "samples/petstore.yaml" When I generate rules with body validation for "" And I start the WAF server with the generated rules - Then a valid GET request to "/pet/1" should return a 200 status code - And an invalid GET request to "/pet/abc" should be blocked with a 403 status code - And a POST request to "/pet" with a valid body should return a 200 status code - And a POST request to "/pet" with an invalid body should be blocked with a 403 status code + Then a valid GET request to "/v2/pet/1" should return a 200 status code + And an invalid GET request to "/v2/pet/abc" should be blocked with a 403 status code + And a POST request to "/v2/pet" with a valid body should return a 200 status code + And a POST request to "/v2/pet" with an invalid body should be blocked with a 403 status code And a request to an undefined path "/undefined/path" should be blocked with a 403 status code - And a request using an undefined HTTP method "PATCH" to "/pet/1" should be blocked with a 403 status code + And a request using an undefined HTTP method "PATCH" to "/v2/pet/1" should be blocked with a 403 status code Examples: | engine | @@ -26,12 +26,12 @@ Feature: ModSecurity Rule Generation and Testing Given an OpenAPI specification file at "samples/petstore.yaml" When I generate rules without body validation for "" And I start the WAF server with the generated rules - Then a valid GET request to "/pet/1" should return a 200 status code - And an invalid GET request to "/pet/abc" should be blocked with a 403 status code - And a POST request to "/pet" with a valid body should return a 200 status code - And a POST request to "/pet" with an invalid body should return a 200 status code + Then a valid GET request to "/v2/pet/1" should return a 200 status code + And an invalid GET request to "/v2/pet/abc" should be blocked with a 403 status code + And a POST request to "/v2/pet" with a valid body should return a 200 status code + And a POST request to "/v2/pet" with an invalid body should return a 200 status code And a request to an undefined path "/undefined/path" should be blocked with a 403 status code - And a request using an undefined HTTP method "PATCH" to "/pet/1" should be blocked with a 403 status code + And a request using an undefined HTTP method "PATCH" to "/v2/pet/1" should be blocked with a 403 status code Examples: | engine | @@ -42,10 +42,10 @@ Feature: ModSecurity Rule Generation and Testing Given an OpenAPI specification file at "samples/urlintparam.yaml" When I generate rules with body validation for "" And I start the WAF server with the generated rules - Then a valid GET request to "/pet/1" should return a 200 status code - And an invalid GET request to "/pet/abc" should be blocked with a 403 status code - And a request to "/pet/1.5" should be blocked with a 403 status code - And a request to "/pet/-1" should be blocked with a 403 status code + Then a valid GET request to "/v2/pet/1" should return a 200 status code + And an invalid GET request to "/v2/pet/abc" should be blocked with a 403 status code + And a request to "/v2/pet/1.5" should be blocked with a 403 status code + And a request to "/v2/pet/-1" should be blocked with a 403 status code Examples: | engine | @@ -56,11 +56,11 @@ Feature: ModSecurity Rule Generation and Testing Given an OpenAPI specification file at "samples/getparam.yaml" When I generate rules with body validation for "" And I start the WAF server with the generated rules - Then a GET request to "/pets?limit=10" should return a 200 status code - And a GET request to "/pets?limit=abc" should be blocked with a 403 status code - And a GET request to "/pets?limit=-1" should be blocked with a 403 status code - And a GET request to "/pets?limit=1000" should be blocked with a 403 status code - And a GET request to "/pets" without parameters should return a 200 status code + Then a GET request to "/v2/pets?limit=10" should return a 200 status code + And a GET request to "/v2/pets?limit=abc" should be blocked with a 403 status code + And a GET request to "/v2/pets?limit=-1" should be blocked with a 403 status code + And a GET request to "/v2/pets?limit=1000" should be blocked with a 403 status code + And a GET request to "/v2/pets" without parameters should return a 200 status code Examples: | engine | @@ -89,10 +89,28 @@ Feature: ModSecurity Rule Generation and Testing Given an OpenAPI specification file at "samples/petstore.yaml" When I generate rules with body validation for "" And I start the WAF server with the generated rules - Then a POST request to "/pet" with a valid body should return a 200 status code - And a POST request to "/pet" with an invalid body "missing_required" should be blocked with a 403 status code - And a POST request to "/pet" with an invalid body "invalid_type" should be blocked with a 403 status code - And a POST request to "/pet" with an invalid body "extra_property" should be blocked with a 403 status code + Then a POST request to "/v2/pet" with a valid body should return a 200 status code + And a POST request to "/v2/pet" with an invalid body "missing_required" should be blocked with a 403 status code + And a POST request to "/v2/pet" with an invalid body "invalid_type" should be blocked with a 403 status code + And a POST request to "/v2/pet" with an invalid body "extra_property" should be blocked with a 403 status code + + Examples: + | engine | + | coraza | + | modsecurity3 | + + Scenario Outline: Media type handling for form, multipart, binary, wildcard and optional bodies + Given an OpenAPI specification file at "samples/multipart.yaml" + When I generate rules with body validation for "" + And I start the WAF server with the generated rules + Then a POST request to "/profile" with content type "application/x-www-form-urlencoded" and body "displayName=John+Doe&age=30" should return a 200 status code + And a POST request to "/profile" with content type "application/x-www-form-urlencoded" and body "displayName=bad!name&age=30" should be blocked with a 403 status code + And a POST request to '/avatar' with content type 'multipart/form-data; boundary=oas' and body '--oas\r\nContent-Disposition: form-data; name="caption"\r\n\r\nhello\r\n--oas--\r\n' should return a 200 status code + And a POST request to "/blob" with content type "application/octet-stream" and body "0102aabb" should return a 200 status code + And a POST request to "/anything" with content type "text/plain" and body "whatever" should return a 200 status code + And a POST request to "/note" with no body should return a 200 status code + And a POST request to "/note" with content type "application/json" and body "{\"text\":\"hi\"}" should return a 200 status code + And a POST request to "/note" with content type "application/json" and body "{\"other\":1}" should be blocked with a 403 status code Examples: | engine | diff --git a/src/test/resources/golden/composed/coraza/DefaultApi.conf b/src/test/resources/golden/composed/coraza/DefaultApi.conf index 463b212..8741e09 100644 --- a/src/test/resources/golden/composed/coraza/DefaultApi.conf +++ b/src/test/resources/golden/composed/coraza/DefaultApi.conf @@ -12,26 +12,26 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.name|json\.id|json\.contactMethod|json\.contactMethod\.email|json\.contactMethod\.phone)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addContact" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_addContact_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210001,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210002,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^(?:[0-9]{1,19}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" "id:4210014,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.contactMethod.email "!@rx ^([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})$" "id:4210026,phase:2,block,msg:'Invalid value for property json.contactMethod.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.contactMethod.phone "!@rx ^[0-9]{10}$" "id:4210038,phase:2,block,msg:'Invalid value for property json.contactMethod.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210005,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210006,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^(?:[0-9]{1,19}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" "id:4210018,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.contactMethod.email "!@rx ^([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})$" "id:4210030,phase:2,block,msg:'Invalid value for property json.contactMethod.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.contactMethod.phone "!@rx ^[0-9]{10}$" "id:4210042,phase:2,block,msg:'Invalid value for property json.contactMethod.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200018,phase:2,block,msg:'JSON schema validation failed for addContact',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addContact" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210003,phase:2,block,msg:'JSON schema validation failed for addContact',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addContact" -SecMarker ENDJSON_addContact +SecMarker ENDMEDIA_addContact_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_addContact @@ -55,24 +55,24 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200043,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.species|json\.breed)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200056,phase:2,pass,nolog,skipAfter:ENDJSON_addDog" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210093,phase:2,pass,nolog,skipAfter:ENDMEDIA_addDog_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200057,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.species "@eq 0" "id:4210089,phase:2,block,msg:'Missing required property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.species "!@rx ^.+$" "id:4210090,phase:2,block,msg:'Invalid value for property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.breed "!@rx ^.+$" "id:4210102,phase:2,block,msg:'Invalid value for property json.breed',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210094,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.species "@eq 0" "id:4210097,phase:2,block,msg:'Missing required property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.species "!@rx ^.+$" "id:4210098,phase:2,block,msg:'Invalid value for property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.breed "!@rx ^.+$" "id:4210110,phase:2,block,msg:'Invalid value for property json.breed',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200058,phase:2,block,msg:'JSON schema validation failed for addDog',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200059,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addDog" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210095,phase:2,block,msg:'JSON schema validation failed for addDog',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210096,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addDog" -SecMarker ENDJSON_addDog +SecMarker ENDMEDIA_addDog_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_addDog @@ -91,19 +91,19 @@ SecRule REQUEST_FILENAME "!@rx ^/items$" "id:4200082,phase:2,pass,nolog,skipAfte # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findItems" -SecRule ARGS_GET:code "!@rx ^(?:[0-9]{1,19}|(red|green|blue))?$" "id:4210158,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:code "@gt 1" "id:4210159,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:code "!@rx ^(?:[0-9]{1,19}|(red|green|blue))?$" "id:4210166,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:code "@gt 1" "id:4210167,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:code)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findItems" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findItems diff --git a/src/test/resources/golden/composed/modsecurity3/DefaultApi.conf b/src/test/resources/golden/composed/modsecurity3/DefaultApi.conf index d4e50e9..0e0f11c 100644 --- a/src/test/resources/golden/composed/modsecurity3/DefaultApi.conf +++ b/src/test/resources/golden/composed/modsecurity3/DefaultApi.conf @@ -12,23 +12,23 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.name|json\.id|json\.contactMethod|json\.contactMethod\.email|json\.contactMethod\.phone)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addContact" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_addContact_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210001,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210002,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^(?:[0-9]{1,19}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" "id:4210014,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.contactMethod.email "!@rx ^([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})$" "id:4210026,phase:2,block,msg:'Invalid value for property json.contactMethod.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.contactMethod.phone "!@rx ^[0-9]{10}$" "id:4210038,phase:2,block,msg:'Invalid value for property json.contactMethod.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addContact" +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210005,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210006,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^(?:[0-9]{1,19}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" "id:4210018,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.contactMethod.email "!@rx ^([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})$" "id:4210030,phase:2,block,msg:'Invalid value for property json.contactMethod.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.contactMethod.phone "!@rx ^[0-9]{10}$" "id:4210042,phase:2,block,msg:'Invalid value for property json.contactMethod.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addContact" -SecMarker ENDJSON_addContact +SecMarker ENDMEDIA_addContact_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_addContact @@ -52,21 +52,21 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200043,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.species|json\.breed)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200056,phase:2,pass,nolog,skipAfter:ENDJSON_addDog" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210093,phase:2,pass,nolog,skipAfter:ENDMEDIA_addDog_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200057,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.species "@eq 0" "id:4210089,phase:2,block,msg:'Missing required property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.species "!@rx ^.+$" "id:4210090,phase:2,block,msg:'Invalid value for property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.breed "!@rx ^.+$" "id:4210102,phase:2,block,msg:'Invalid value for property json.breed',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200059,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addDog" +SecRule REQBODY_ERROR "!@eq 0" "id:4210094,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.species "@eq 0" "id:4210097,phase:2,block,msg:'Missing required property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.species "!@rx ^.+$" "id:4210098,phase:2,block,msg:'Invalid value for property json.species',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.breed "!@rx ^.+$" "id:4210110,phase:2,block,msg:'Invalid value for property json.breed',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210096,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addDog" -SecMarker ENDJSON_addDog +SecMarker ENDMEDIA_addDog_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_addDog @@ -85,19 +85,19 @@ SecRule REQUEST_FILENAME "!@rx ^/items$" "id:4200082,phase:2,pass,nolog,skipAfte # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findItems" -SecRule ARGS_GET:code "!@rx ^(?:[0-9]{1,19}|(red|green|blue))?$" "id:4210158,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:code "@gt 1" "id:4210159,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:code "!@rx ^(?:[0-9]{1,19}|(red|green|blue))?$" "id:4210166,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:code "@gt 1" "id:4210167,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:code)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findItems" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findItems diff --git a/src/test/resources/golden/getparam/coraza/PetApi.conf b/src/test/resources/golden/getparam/coraza/PetApi.conf index 46bf4c0..e3566ed 100644 --- a/src/test/resources/golden/getparam/coraza/PetApi.conf +++ b/src/test/resources/golden/getparam/coraza/PetApi.conf @@ -3,23 +3,22 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/findByStatus$" "id:4200002,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByStatus$" "id:4200002,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200003,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" -SecRule ARGS_GET:status "!@rx ^(available|pending|sold)$" "id:4210006,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:status "@lt 1" "id:4210008,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:status "!@rx ^(?:(available|pending|sold))(?:,(?:(available|pending|sold))){0,999}$" "id:4210006,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200025,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findPetsByStatus @@ -34,23 +33,22 @@ SecMarker END_findPetsByStatus # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/findByTags$" "id:4200042,phase:2,pass,nolog,skipAfter:END_findPetsByTags" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByTags$" "id:4200042,phase:2,pass,nolog,skipAfter:END_findPetsByTags" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_findPetsByTags" -SecRule ARGS_GET:tags "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:tags "@lt 1" "id:4210048,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:tags "!@rx ^(?:.+)(?:,(?:.+)){0,999}$" "id:4210046,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findPetsByTags @@ -65,7 +63,7 @@ SecMarker END_findPetsByTags # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pets$" "id:4200082,phase:2,pass,nolog,skipAfter:END_listPets" +SecRule REQUEST_FILENAME "!@rx ^/v2/pets$" "id:4200082,phase:2,pass,nolog,skipAfter:END_listPets" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_listPets" @@ -78,12 +76,12 @@ SecRule ARGS_GET:limit "@gt 100" "id:4210097,phase:2,block,msg:'Parameter value # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:limit)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_listPets" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_listPets diff --git a/src/test/resources/golden/getparam/modsecurity3/PetApi.conf b/src/test/resources/golden/getparam/modsecurity3/PetApi.conf index 46bf4c0..e3566ed 100644 --- a/src/test/resources/golden/getparam/modsecurity3/PetApi.conf +++ b/src/test/resources/golden/getparam/modsecurity3/PetApi.conf @@ -3,23 +3,22 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/findByStatus$" "id:4200002,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByStatus$" "id:4200002,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200003,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" -SecRule ARGS_GET:status "!@rx ^(available|pending|sold)$" "id:4210006,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:status "@lt 1" "id:4210008,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:status "!@rx ^(?:(available|pending|sold))(?:,(?:(available|pending|sold))){0,999}$" "id:4210006,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200025,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findPetsByStatus @@ -34,23 +33,22 @@ SecMarker END_findPetsByStatus # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/findByTags$" "id:4200042,phase:2,pass,nolog,skipAfter:END_findPetsByTags" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByTags$" "id:4200042,phase:2,pass,nolog,skipAfter:END_findPetsByTags" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_findPetsByTags" -SecRule ARGS_GET:tags "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:tags "@lt 1" "id:4210048,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:tags "!@rx ^(?:.+)(?:,(?:.+)){0,999}$" "id:4210046,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findPetsByTags @@ -65,7 +63,7 @@ SecMarker END_findPetsByTags # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pets$" "id:4200082,phase:2,pass,nolog,skipAfter:END_listPets" +SecRule REQUEST_FILENAME "!@rx ^/v2/pets$" "id:4200082,phase:2,pass,nolog,skipAfter:END_listPets" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_listPets" @@ -78,12 +76,12 @@ SecRule ARGS_GET:limit "@gt 100" "id:4210097,phase:2,block,msg:'Parameter value # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:limit)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_listPets" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_listPets diff --git a/src/test/resources/golden/multipart/coraza/DefaultApi.conf b/src/test/resources/golden/multipart/coraza/DefaultApi.conf new file mode 100644 index 0000000..56dca95 --- /dev/null +++ b/src/test/resources/golden/multipart/coraza/DefaultApi.conf @@ -0,0 +1,182 @@ + +# postAnything: POST /anything +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/anything$" "id:4200002,phase:2,pass,nolog,skipAfter:END_postAnything" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_postAnything" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type + +SecAction "id:4200025,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_postAnything" + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_postAnything + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_postAnything + +# postNote: POST /note +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/note$" "id:4200042,phase:2,pass,nolog,skipAfter:END_postNote" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200043,phase:2,pass,nolog,skipAfter:END_postNote" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.text)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +# requestBody is optional (the OAS3 default): a request without a body skips body checks +SecRule &REQUEST_HEADERS:Content-Type "@eq 0" "id:4200066,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_postNote" +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210041,phase:2,pass,nolog,skipAfter:ENDMEDIA_postNote_0" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4210042,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.text "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.text',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.text "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.text',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210043,phase:2,block,msg:'JSON schema validation failed for postNote',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210044,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_postNote" + +SecMarker ENDMEDIA_postNote_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_postNote + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_postNote + +# updateProfile: POST /profile +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/profile$" "id:4200082,phase:2,pass,nolog,skipAfter:END_updateProfile" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200083,phase:2,pass,nolog,skipAfter:END_updateProfile" + +SecRule ARGS_POST:displayName "!@rx ^[a-zA-Z ]{1,30}$" "id:4210110,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:displayName "@gt 1" "id:4210111,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:age "!@rx ^([0-9]{1,19})?$" "id:4210150,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:age "@gt 1" "id:4210151,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:displayName|age)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +# form-urlencoded/multipart: text fields land in ARGS_POST on both engines and are +# validated by the parameter rules and ARGS_NAMES allowlist above +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/x-www-form-urlencoded" "id:4210097,phase:2,pass,nolog,skipAfter:ENDMEDIA_updateProfile_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210098,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210100,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updateProfile" + +SecMarker ENDMEDIA_updateProfile_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_updateProfile + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_updateProfile + +# uploadAvatar: POST /avatar +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/avatar$" "id:4200122,phase:2,pass,nolog,skipAfter:END_uploadAvatar" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200123,phase:2,pass,nolog,skipAfter:END_uploadAvatar" + +SecRule ARGS_POST:caption "!@rx ^.*$" "id:4210194,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:caption "@gt 1" "id:4210195,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:file "!@rx ^.*$" "id:4210234,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:file "@gt 1" "id:4210235,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:caption|file)$" "id:4200134,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +# form-urlencoded/multipart: text fields land in ARGS_POST on both engines and are +# validated by the parameter rules and ARGS_NAMES allowlist above +SecRule REQUEST_HEADERS:Content-Type "!@rx ^multipart/form-data" "id:4210181,phase:2,pass,nolog,skipAfter:ENDMEDIA_uploadAvatar_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210182,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210184,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_uploadAvatar" + +SecMarker ENDMEDIA_uploadAvatar_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200141,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_uploadAvatar + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200142" + +## End of checks for this operation +SecMarker END_uploadAvatar + +# uploadBlob: POST /blob +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/blob$" "id:4200162,phase:2,pass,nolog,skipAfter:END_uploadBlob" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200163,phase:2,pass,nolog,skipAfter:END_uploadBlob" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200174,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +# Declared media type the WAF cannot inspect; handling set by unknownMediaTypePolicy +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/octet-stream" "id:4210265,phase:2,pass,nolog,skipAfter:ENDMEDIA_uploadBlob_0" +SecAction "id:4210268,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_uploadBlob" + +SecMarker ENDMEDIA_uploadBlob_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200181,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_uploadBlob + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200182" + +## End of checks for this operation +SecMarker END_uploadBlob diff --git a/src/test/resources/golden/multipart/coraza/mainconfig.conf b/src/test/resources/golden/multipart/coraza/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/multipart/coraza/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/multipart/coraza/schema.json b/src/test/resources/golden/multipart/coraza/schema.json new file mode 100644 index 0000000..f86cc48 --- /dev/null +++ b/src/test/resources/golden/multipart/coraza/schema.json @@ -0,0 +1,18 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "postNote_request" : { + "title" : "postNote_request", + "type" : "object", + "properties" : { + "text" : { + "type" : "string" + } + }, + "required" : [ "text" ] + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/multipart/modsecurity3/DefaultApi.conf b/src/test/resources/golden/multipart/modsecurity3/DefaultApi.conf new file mode 100644 index 0000000..8745464 --- /dev/null +++ b/src/test/resources/golden/multipart/modsecurity3/DefaultApi.conf @@ -0,0 +1,179 @@ + +# postAnything: POST /anything +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/anything$" "id:4200002,phase:2,pass,nolog,skipAfter:END_postAnything" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_postAnything" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type + +SecAction "id:4200025,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_postAnything" + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_postAnything + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_postAnything + +# postNote: POST /note +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/note$" "id:4200042,phase:2,pass,nolog,skipAfter:END_postNote" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200043,phase:2,pass,nolog,skipAfter:END_postNote" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.text)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +# requestBody is optional (the OAS3 default): a request without a body skips body checks +SecRule &REQUEST_HEADERS:Content-Type "@eq 0" "id:4200066,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_postNote" +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210041,phase:2,pass,nolog,skipAfter:ENDMEDIA_postNote_0" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4210042,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.text "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.text',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.text "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.text',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210044,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_postNote" + +SecMarker ENDMEDIA_postNote_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_postNote + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_postNote + +# updateProfile: POST /profile +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/profile$" "id:4200082,phase:2,pass,nolog,skipAfter:END_updateProfile" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200083,phase:2,pass,nolog,skipAfter:END_updateProfile" + +SecRule ARGS_POST:displayName "!@rx ^[a-zA-Z ]{1,30}$" "id:4210110,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:displayName "@gt 1" "id:4210111,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:age "!@rx ^([0-9]{1,19})?$" "id:4210150,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:age "@gt 1" "id:4210151,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:displayName|age)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +# form-urlencoded/multipart: text fields land in ARGS_POST on both engines and are +# validated by the parameter rules and ARGS_NAMES allowlist above +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/x-www-form-urlencoded" "id:4210097,phase:2,pass,nolog,skipAfter:ENDMEDIA_updateProfile_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210098,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210100,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updateProfile" + +SecMarker ENDMEDIA_updateProfile_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_updateProfile + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200102" + +## End of checks for this operation +SecMarker END_updateProfile + +# uploadAvatar: POST /avatar +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/avatar$" "id:4200122,phase:2,pass,nolog,skipAfter:END_uploadAvatar" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200123,phase:2,pass,nolog,skipAfter:END_uploadAvatar" + +SecRule ARGS_POST:caption "!@rx ^.*$" "id:4210194,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:caption "@gt 1" "id:4210195,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:file "!@rx ^.*$" "id:4210234,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:file "@gt 1" "id:4210235,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:caption|file)$" "id:4200134,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +# form-urlencoded/multipart: text fields land in ARGS_POST on both engines and are +# validated by the parameter rules and ARGS_NAMES allowlist above +SecRule REQUEST_HEADERS:Content-Type "!@rx ^multipart/form-data" "id:4210181,phase:2,pass,nolog,skipAfter:ENDMEDIA_uploadAvatar_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210182,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210184,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_uploadAvatar" + +SecMarker ENDMEDIA_uploadAvatar_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200141,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_uploadAvatar + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200142" + +## End of checks for this operation +SecMarker END_uploadAvatar + +# uploadBlob: POST /blob +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/blob$" "id:4200162,phase:2,pass,nolog,skipAfter:END_uploadBlob" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200163,phase:2,pass,nolog,skipAfter:END_uploadBlob" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200174,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +# Declared media type the WAF cannot inspect; handling set by unknownMediaTypePolicy +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/octet-stream" "id:4210265,phase:2,pass,nolog,skipAfter:ENDMEDIA_uploadBlob_0" +SecAction "id:4210268,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_uploadBlob" + +SecMarker ENDMEDIA_uploadBlob_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200181,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_uploadBlob + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200182" + +## End of checks for this operation +SecMarker END_uploadBlob diff --git a/src/test/resources/golden/multipart/modsecurity3/mainconfig.conf b/src/test/resources/golden/multipart/modsecurity3/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/multipart/modsecurity3/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/multipart/modsecurity3/schema.json b/src/test/resources/golden/multipart/modsecurity3/schema.json new file mode 100644 index 0000000..f86cc48 --- /dev/null +++ b/src/test/resources/golden/multipart/modsecurity3/schema.json @@ -0,0 +1,18 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "postNote_request" : { + "title" : "postNote_request", + "type" : "object", + "properties" : { + "text" : { + "type" : "string" + } + }, + "required" : [ "text" ] + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/petstore/coraza/PetApi.conf b/src/test/resources/golden/petstore/coraza/PetApi.conf index c53e74f..93d5198 100644 --- a/src/test/resources/golden/petstore/coraza/PetApi.conf +++ b/src/test/resources/golden/petstore/coraza/PetApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addPet" @@ -12,37 +12,37 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addPet" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210002,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210014,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210026,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210037,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210038,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210050,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210062,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210086,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210010,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210022,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210034,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210094,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200018,phase:2,block,msg:'JSON schema validation failed for addPet',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210003,phase:2,block,msg:'JSON schema validation failed for addPet',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" -SecMarker ENDJSON_addPet -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200020,phase:2,pass,nolog,skipAfter:ENDXML_addPet" -SecRule REQBODY_ERROR "!@eq 0" "id:4200023,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecMarker ENDMEDIA_addPet_0 +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210005,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_1" +SecRule REQBODY_ERROR "!@eq 0" "id:4210006,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" # TODO: Implement XML schema validation (issue #14 covers JSON objects) -SecAction "id:4200024,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" +SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" -SecMarker ENDXML_addPet +SecMarker ENDMEDIA_addPet_1 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_addPet @@ -57,7 +57,7 @@ SecMarker END_addPet # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_deletePet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_deletePet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200043,phase:2,pass,nolog,skipAfter:END_deletePet" @@ -66,12 +66,12 @@ SecRule REQUEST_METHOD "!@within DELETE" "id:4200043,phase:2,pass,nolog,skipAfte # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deletePet" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_deletePet @@ -86,23 +86,22 @@ SecMarker END_deletePet # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/findByStatus$" "id:4200082,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByStatus$" "id:4200082,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" -SecRule ARGS_GET:status "!@rx ^(available|pending|sold)$" "id:4210222,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:status "@lt 1" "id:4210224,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:status "!@rx ^(?:(available|pending|sold))(?:,(?:(available|pending|sold))){0,999}$" "id:4210230,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findPetsByStatus @@ -117,23 +116,22 @@ SecMarker END_findPetsByStatus # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/findByTags$" "id:4200122,phase:2,pass,nolog,skipAfter:END_findPetsByTags" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByTags$" "id:4200122,phase:2,pass,nolog,skipAfter:END_findPetsByTags" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200123,phase:2,pass,nolog,skipAfter:END_findPetsByTags" -SecRule ARGS_GET:tags "!@rx ^.+$" "id:4210262,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:tags "@lt 1" "id:4210264,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:tags "!@rx ^(?:.+)(?:,(?:.+)){0,999}$" "id:4210270,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200134,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200145,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200141,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200141,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findPetsByTags @@ -148,7 +146,7 @@ SecMarker END_findPetsByTags # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200162,phase:2,pass,nolog,skipAfter:END_getPetById" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200162,phase:2,pass,nolog,skipAfter:END_getPetById" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200163,phase:2,pass,nolog,skipAfter:END_getPetById" @@ -157,12 +155,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200163,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200174,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200185,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200181,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200181,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getPetById @@ -177,7 +175,7 @@ SecMarker END_getPetById # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200202,phase:2,pass,nolog,skipAfter:END_updatePet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet$" "id:4200202,phase:2,pass,nolog,skipAfter:END_updatePet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within PUT" "id:4200203,phase:2,pass,nolog,skipAfter:END_updatePet" @@ -186,37 +184,37 @@ SecRule REQUEST_METHOD "!@within PUT" "id:4200203,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200214,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200216,phase:2,pass,nolog,skipAfter:ENDJSON_updatePet" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210345,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200217,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210338,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210350,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210362,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210373,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210374,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210386,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210398,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210410,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210422,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210346,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210354,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210366,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210378,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210389,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210390,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210402,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210414,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210426,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210438,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200218,phase:2,block,msg:'JSON schema validation failed for updatePet',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200219,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210347,phase:2,block,msg:'JSON schema validation failed for updatePet',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210348,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" -SecMarker ENDJSON_updatePet -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200220,phase:2,pass,nolog,skipAfter:ENDXML_updatePet" -SecRule REQBODY_ERROR "!@eq 0" "id:4200223,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecMarker ENDMEDIA_updatePet_0 +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210349,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_1" +SecRule REQBODY_ERROR "!@eq 0" "id:4210350,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" # TODO: Implement XML schema validation (issue #14 covers JSON objects) -SecAction "id:4200224,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" +SecAction "id:4210352,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" -SecMarker ENDXML_updatePet +SecMarker ENDMEDIA_updatePet_1 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200221,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200221,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_updatePet @@ -231,24 +229,33 @@ SecMarker END_updatePet # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200242,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200242,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200243,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" -SecRule ARGS_POST:name "!@rx ^.*$" "id:4210522,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_POST:name "@gt 1" "id:4210523,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS_POST:status "!@rx ^.*$" "id:4210562,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_POST:status "@gt 1" "id:4210563,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:name "!@rx ^.*$" "id:4210542,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:name "@gt 1" "id:4210543,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:status "!@rx ^.*$" "id:4210582,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:status "@gt 1" "id:4210583,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:name|status)$" "id:4200254,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type +# requestBody is optional (the OAS3 default): a request without a body skips body checks +SecRule &REQUEST_HEADERS:Content-Type "@eq 0" "id:4200266,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePetWithForm" +# form-urlencoded/multipart: text fields land in ARGS_POST on both engines and are +# validated by the parameter rules and ARGS_NAMES allowlist above +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/x-www-form-urlencoded" "id:4210489,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePetWithForm_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210490,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210492,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePetWithForm" +SecMarker ENDMEDIA_updatePetWithForm_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200261,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200261,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_updatePetWithForm @@ -263,24 +270,33 @@ SecMarker END_updatePetWithForm # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})/uploadImage$" "id:4200282,phase:2,pass,nolog,skipAfter:END_uploadFile" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})/uploadImage$" "id:4200282,phase:2,pass,nolog,skipAfter:END_uploadFile" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200283,phase:2,pass,nolog,skipAfter:END_uploadFile" -SecRule ARGS_POST:additionalMetadata "!@rx ^.*$" "id:4210642,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_POST:additionalMetadata "@gt 1" "id:4210643,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS_POST:file "!@rx ^.*$" "id:4210682,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_POST:file "@gt 1" "id:4210683,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:additionalMetadata "!@rx ^.*$" "id:4210666,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:additionalMetadata "@gt 1" "id:4210667,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:file "!@rx ^.*$" "id:4210706,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:file "@gt 1" "id:4210707,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:additionalMetadata|file)$" "id:4200294,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type +# requestBody is optional (the OAS3 default): a request without a body skips body checks +SecRule &REQUEST_HEADERS:Content-Type "@eq 0" "id:4200306,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_uploadFile" +# form-urlencoded/multipart: text fields land in ARGS_POST on both engines and are +# validated by the parameter rules and ARGS_NAMES allowlist above +SecRule REQUEST_HEADERS:Content-Type "!@rx ^multipart/form-data" "id:4210613,phase:2,pass,nolog,skipAfter:ENDMEDIA_uploadFile_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210614,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210616,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_uploadFile" + +SecMarker ENDMEDIA_uploadFile_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200301,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200301,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_uploadFile diff --git a/src/test/resources/golden/petstore/coraza/StoreApi.conf b/src/test/resources/golden/petstore/coraza/StoreApi.conf index c35a840..58857ea 100644 --- a/src/test/resources/golden/petstore/coraza/StoreApi.conf +++ b/src/test/resources/golden/petstore/coraza/StoreApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:[^/]+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" +SecRule REQUEST_FILENAME "!@rx ^/v2/store/order/(?:[^/]+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200324,phase:2,pass,nolog,skipAfter:END_deleteOrder" @@ -12,12 +12,12 @@ SecRule REQUEST_METHOD "!@within DELETE" "id:4200324,phase:2,pass,nolog,skipAfte # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200335,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200346,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteOrder" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200342,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200342,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_deleteOrder @@ -32,7 +32,7 @@ SecMarker END_deleteOrder # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/inventory$" "id:4200363,phase:2,pass,nolog,skipAfter:END_getInventory" +SecRule REQUEST_FILENAME "!@rx ^/v2/store/inventory$" "id:4200363,phase:2,pass,nolog,skipAfter:END_getInventory" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200364,phase:2,pass,nolog,skipAfter:END_getInventory" @@ -41,12 +41,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200364,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200375,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200386,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getInventory" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200382,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200382,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getInventory @@ -61,7 +61,7 @@ SecMarker END_getInventory # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:[0-9]{1,19})$" "id:4200403,phase:2,pass,nolog,skipAfter:END_getOrderById" +SecRule REQUEST_FILENAME "!@rx ^/v2/store/order/(?:[0-9]{1,19})$" "id:4200403,phase:2,pass,nolog,skipAfter:END_getOrderById" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200404,phase:2,pass,nolog,skipAfter:END_getOrderById" @@ -70,12 +70,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200404,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200415,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200426,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getOrderById" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200422,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200422,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getOrderById @@ -90,7 +90,7 @@ SecMarker END_getOrderById # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/order$" "id:4200443,phase:2,pass,nolog,skipAfter:END_placeOrder" +SecRule REQUEST_FILENAME "!@rx ^/v2/store/order$" "id:4200443,phase:2,pass,nolog,skipAfter:END_placeOrder" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200444,phase:2,pass,nolog,skipAfter:END_placeOrder" @@ -99,27 +99,27 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200444,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.petId|json\.quantity|json\.shipDate|json\.status|json\.complete)$" "id:4200455,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200457,phase:2,pass,nolog,skipAfter:ENDJSON_placeOrder" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210817,phase:2,pass,nolog,skipAfter:ENDMEDIA_placeOrder_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200458,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210794,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.petId "!@rx ^[0-9]{1,19}$" "id:4210806,phase:2,block,msg:'Invalid value for property json.petId',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.quantity "!@rx ^[0-9]{1,19}$" "id:4210818,phase:2,block,msg:'Invalid value for property json.quantity',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.shipDate "!@rx ^(\d{4}-((01|03|05|07|08|10|12)-(0[1-9]|1\d|2\d|3[0-1])|(04|06|09|11)-(0[1-9]|1\d|2\d|30)|02-(0[1-9]|1\d|2\d))T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(\.\d+)?([Zz]|[+\-](0\d|1[0-4])(:[0-5]\d)?))$" "id:4210830,phase:2,block,msg:'Invalid value for property json.shipDate',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(placed|approved|delivered)$" "id:4210842,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.complete "!@rx ^(true|false)$" "id:4210854,phase:2,block,msg:'Invalid value for property json.complete',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210818,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210822,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.petId "!@rx ^[0-9]{1,19}$" "id:4210834,phase:2,block,msg:'Invalid value for property json.petId',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.quantity "!@rx ^[0-9]{1,19}$" "id:4210846,phase:2,block,msg:'Invalid value for property json.quantity',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.shipDate "!@rx ^(\d{4}-((01|03|05|07|08|10|12)-(0[1-9]|1\d|2\d|3[0-1])|(04|06|09|11)-(0[1-9]|1\d|2\d|30)|02-(0[1-9]|1\d|2\d))T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(\.\d+)?([Zz]|[+\-](0\d|1[0-4])(:[0-5]\d)?))$" "id:4210858,phase:2,block,msg:'Invalid value for property json.shipDate',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(placed|approved|delivered)$" "id:4210870,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.complete "!@rx ^(true|false)$" "id:4210882,phase:2,block,msg:'Invalid value for property json.complete',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200459,phase:2,block,msg:'JSON schema validation failed for placeOrder',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200460,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_placeOrder" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210819,phase:2,block,msg:'JSON schema validation failed for placeOrder',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210820,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_placeOrder" -SecMarker ENDJSON_placeOrder +SecMarker ENDMEDIA_placeOrder_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200462,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200462,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_placeOrder diff --git a/src/test/resources/golden/petstore/coraza/UserApi.conf b/src/test/resources/golden/petstore/coraza/UserApi.conf index c16696b..460751e 100644 --- a/src/test/resources/golden/petstore/coraza/UserApi.conf +++ b/src/test/resources/golden/petstore/coraza/UserApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user$" "id:4200484,phase:2,pass,nolog,skipAfter:END_createUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user$" "id:4200484,phase:2,pass,nolog,skipAfter:END_createUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200485,phase:2,pass,nolog,skipAfter:END_createUser" @@ -12,29 +12,29 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200485,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.username|json\.firstName|json\.lastName|json\.email|json\.password|json\.phone|json\.userStatus)$" "id:4200496,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200498,phase:2,pass,nolog,skipAfter:ENDJSON_createUser" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210933,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUser_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200499,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210906,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.username "!@rx ^.+$" "id:4210918,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.firstName "!@rx ^.+$" "id:4210930,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.lastName "!@rx ^.+$" "id:4210942,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.email "!@rx ^.+$" "id:4210954,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.password "!@rx ^.+$" "id:4210966,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.phone "!@rx ^.+$" "id:4210978,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4210990,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210934,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210938,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4210950,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4210962,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4210974,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4210986,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4210998,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4211010,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211022,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200500,phase:2,block,msg:'JSON schema validation failed for createUser',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200501,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUser" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210935,phase:2,block,msg:'JSON schema validation failed for createUser',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210936,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUser" -SecMarker ENDJSON_createUser +SecMarker ENDMEDIA_createUser_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200503,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200503,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_createUser @@ -49,7 +49,7 @@ SecMarker END_createUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/createWithArray$" "id:4200524,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/createWithArray$" "id:4200524,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" @@ -58,29 +58,29 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200538,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithArrayInput" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211073,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUsersWithArrayInput_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200539,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211042,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211054,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211066,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4211074,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211138,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211150,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211162,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200540,phase:2,block,msg:'JSON schema validation failed for createUsersWithArrayInput',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200541,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithArrayInput" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4211075,phase:2,block,msg:'JSON schema validation failed for createUsersWithArrayInput',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4211076,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithArrayInput" -SecMarker ENDJSON_createUsersWithArrayInput +SecMarker ENDMEDIA_createUsersWithArrayInput_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200543,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200543,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_createUsersWithArrayInput @@ -95,7 +95,7 @@ SecMarker END_createUsersWithArrayInput # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/createWithList$" "id:4200564,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/createWithList$" "id:4200564,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" @@ -104,29 +104,29 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200578,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithListInput" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211213,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUsersWithListInput_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200579,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211178,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211190,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211202,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211214,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211226,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211238,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211250,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211262,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4211214,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211218,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211230,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211242,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211254,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211266,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211278,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211290,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211302,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200580,phase:2,block,msg:'JSON schema validation failed for createUsersWithListInput',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200581,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithListInput" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4211215,phase:2,block,msg:'JSON schema validation failed for createUsersWithListInput',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4211216,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithListInput" -SecMarker ENDJSON_createUsersWithListInput +SecMarker ENDMEDIA_createUsersWithListInput_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200583,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200583,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_createUsersWithListInput @@ -141,7 +141,7 @@ SecMarker END_createUsersWithListInput # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/(?:[^/]+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200605,phase:2,pass,nolog,skipAfter:END_deleteUser" @@ -150,12 +150,12 @@ SecRule REQUEST_METHOD "!@within DELETE" "id:4200605,phase:2,pass,nolog,skipAfte # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200616,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200627,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteUser" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200623,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200623,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_deleteUser @@ -170,7 +170,7 @@ SecMarker END_deleteUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/(?:[^/]+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200645,phase:2,pass,nolog,skipAfter:END_getUserByName" @@ -179,12 +179,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200645,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200656,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200667,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getUserByName" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200663,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200663,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getUserByName @@ -199,25 +199,25 @@ SecMarker END_getUserByName # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/login$" "id:4200684,phase:2,pass,nolog,skipAfter:END_loginUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/login$" "id:4200684,phase:2,pass,nolog,skipAfter:END_loginUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200685,phase:2,pass,nolog,skipAfter:END_loginUser" -SecRule ARGS_GET:username "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4211398,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:username "@gt 1" "id:4211399,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS_GET:password "!@rx ^.+$" "id:4211438,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:password "@gt 1" "id:4211439,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:username "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4211438,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:username "@gt 1" "id:4211439,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:password "!@rx ^.+$" "id:4211478,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:password "@gt 1" "id:4211479,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:username|password)$" "id:4200696,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200707,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_loginUser" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200703,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200703,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_loginUser @@ -232,7 +232,7 @@ SecMarker END_loginUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/logout$" "id:4200724,phase:2,pass,nolog,skipAfter:END_logoutUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/logout$" "id:4200724,phase:2,pass,nolog,skipAfter:END_logoutUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200725,phase:2,pass,nolog,skipAfter:END_logoutUser" @@ -241,12 +241,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200725,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200736,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200747,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_logoutUser" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200743,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200743,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_logoutUser @@ -261,7 +261,7 @@ SecMarker END_logoutUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/(?:[^/]+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within PUT" "id:4200765,phase:2,pass,nolog,skipAfter:END_updateUser" @@ -270,29 +270,29 @@ SecRule REQUEST_METHOD "!@within PUT" "id:4200765,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.username|json\.firstName|json\.lastName|json\.email|json\.password|json\.phone|json\.userStatus)$" "id:4200776,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200778,phase:2,pass,nolog,skipAfter:ENDJSON_updateUser" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211513,phase:2,pass,nolog,skipAfter:ENDMEDIA_updateUser_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200779,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211514,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.username "!@rx ^.+$" "id:4211526,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211538,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211550,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.email "!@rx ^.+$" "id:4211562,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.password "!@rx ^.+$" "id:4211574,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.phone "!@rx ^.+$" "id:4211586,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211598,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4211514,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211558,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4211570,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211582,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211594,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4211606,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4211618,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4211630,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211642,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200780,phase:2,block,msg:'JSON schema validation failed for updateUser',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200781,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updateUser" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4211515,phase:2,block,msg:'JSON schema validation failed for updateUser',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4211516,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updateUser" -SecMarker ENDJSON_updateUser +SecMarker ENDMEDIA_updateUser_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200783,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200783,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_updateUser diff --git a/src/test/resources/golden/petstore/modsecurity3/PetApi.conf b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf index baf40ee..55d99c5 100644 --- a/src/test/resources/golden/petstore/modsecurity3/PetApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addPet" @@ -12,34 +12,34 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addPet" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210002,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210014,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210026,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210037,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210038,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210050,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210062,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210086,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" - -SecMarker ENDJSON_addPet -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200020,phase:2,pass,nolog,skipAfter:ENDXML_addPet" -SecRule REQBODY_ERROR "!@eq 0" "id:4200023,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210010,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210022,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210034,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210094,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDMEDIA_addPet_0 +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210005,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_1" +SecRule REQBODY_ERROR "!@eq 0" "id:4210006,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" # TODO: Implement XML schema validation (issue #14 covers JSON objects) -SecAction "id:4200024,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" +SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" -SecMarker ENDXML_addPet +SecMarker ENDMEDIA_addPet_1 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_addPet @@ -54,7 +54,7 @@ SecMarker END_addPet # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_deletePet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_deletePet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200043,phase:2,pass,nolog,skipAfter:END_deletePet" @@ -63,12 +63,12 @@ SecRule REQUEST_METHOD "!@within DELETE" "id:4200043,phase:2,pass,nolog,skipAfte # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deletePet" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_deletePet @@ -83,23 +83,22 @@ SecMarker END_deletePet # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/findByStatus$" "id:4200082,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByStatus$" "id:4200082,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" -SecRule ARGS_GET:status "!@rx ^(available|pending|sold)$" "id:4210222,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:status "@lt 1" "id:4210224,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:status "!@rx ^(?:(available|pending|sold))(?:,(?:(available|pending|sold))){0,999}$" "id:4210230,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findPetsByStatus @@ -114,23 +113,22 @@ SecMarker END_findPetsByStatus # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/findByTags$" "id:4200122,phase:2,pass,nolog,skipAfter:END_findPetsByTags" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByTags$" "id:4200122,phase:2,pass,nolog,skipAfter:END_findPetsByTags" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200123,phase:2,pass,nolog,skipAfter:END_findPetsByTags" -SecRule ARGS_GET:tags "!@rx ^.+$" "id:4210262,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:tags "@lt 1" "id:4210264,phase:2,block,msg:'Too few values for array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:tags "!@rx ^(?:.+)(?:,(?:.+)){0,999}$" "id:4210270,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200134,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200145,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200141,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200141,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_findPetsByTags @@ -145,7 +143,7 @@ SecMarker END_findPetsByTags # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200162,phase:2,pass,nolog,skipAfter:END_getPetById" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200162,phase:2,pass,nolog,skipAfter:END_getPetById" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200163,phase:2,pass,nolog,skipAfter:END_getPetById" @@ -154,12 +152,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200163,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200174,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200185,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200181,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200181,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getPetById @@ -174,7 +172,7 @@ SecMarker END_getPetById # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200202,phase:2,pass,nolog,skipAfter:END_updatePet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet$" "id:4200202,phase:2,pass,nolog,skipAfter:END_updatePet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within PUT" "id:4200203,phase:2,pass,nolog,skipAfter:END_updatePet" @@ -183,34 +181,34 @@ SecRule REQUEST_METHOD "!@within PUT" "id:4200203,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200214,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200216,phase:2,pass,nolog,skipAfter:ENDJSON_updatePet" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210345,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200217,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210338,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210350,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210362,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210373,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210374,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210386,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210398,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210410,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210422,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200219,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" - -SecMarker ENDJSON_updatePet -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200220,phase:2,pass,nolog,skipAfter:ENDXML_updatePet" -SecRule REQBODY_ERROR "!@eq 0" "id:4200223,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210346,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210354,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210366,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210378,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210389,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210390,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210402,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210414,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210426,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210438,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210348,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDMEDIA_updatePet_0 +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210349,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_1" +SecRule REQBODY_ERROR "!@eq 0" "id:4210350,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" # TODO: Implement XML schema validation (issue #14 covers JSON objects) -SecAction "id:4200224,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" +SecAction "id:4210352,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" -SecMarker ENDXML_updatePet +SecMarker ENDMEDIA_updatePet_1 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200221,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200221,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_updatePet @@ -225,24 +223,33 @@ SecMarker END_updatePet # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200242,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200242,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200243,phase:2,pass,nolog,skipAfter:END_updatePetWithForm" -SecRule ARGS_POST:name "!@rx ^.*$" "id:4210522,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_POST:name "@gt 1" "id:4210523,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS_POST:status "!@rx ^.*$" "id:4210562,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_POST:status "@gt 1" "id:4210563,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:name "!@rx ^.*$" "id:4210542,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:name "@gt 1" "id:4210543,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:status "!@rx ^.*$" "id:4210582,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:status "@gt 1" "id:4210583,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:name|status)$" "id:4200254,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type +# requestBody is optional (the OAS3 default): a request without a body skips body checks +SecRule &REQUEST_HEADERS:Content-Type "@eq 0" "id:4200266,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePetWithForm" +# form-urlencoded/multipart: text fields land in ARGS_POST on both engines and are +# validated by the parameter rules and ARGS_NAMES allowlist above +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/x-www-form-urlencoded" "id:4210489,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePetWithForm_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210490,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210492,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePetWithForm" +SecMarker ENDMEDIA_updatePetWithForm_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200261,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200261,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_updatePetWithForm @@ -257,24 +264,33 @@ SecMarker END_updatePetWithForm # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})/uploadImage$" "id:4200282,phase:2,pass,nolog,skipAfter:END_uploadFile" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})/uploadImage$" "id:4200282,phase:2,pass,nolog,skipAfter:END_uploadFile" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200283,phase:2,pass,nolog,skipAfter:END_uploadFile" -SecRule ARGS_POST:additionalMetadata "!@rx ^.*$" "id:4210642,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_POST:additionalMetadata "@gt 1" "id:4210643,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS_POST:file "!@rx ^.*$" "id:4210682,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_POST:file "@gt 1" "id:4210683,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:additionalMetadata "!@rx ^.*$" "id:4210666,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:additionalMetadata "@gt 1" "id:4210667,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:file "!@rx ^.*$" "id:4210706,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_POST:file "@gt 1" "id:4210707,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:additionalMetadata|file)$" "id:4200294,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type +# requestBody is optional (the OAS3 default): a request without a body skips body checks +SecRule &REQUEST_HEADERS:Content-Type "@eq 0" "id:4200306,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_uploadFile" +# form-urlencoded/multipart: text fields land in ARGS_POST on both engines and are +# validated by the parameter rules and ARGS_NAMES allowlist above +SecRule REQUEST_HEADERS:Content-Type "!@rx ^multipart/form-data" "id:4210613,phase:2,pass,nolog,skipAfter:ENDMEDIA_uploadFile_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210614,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210616,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_uploadFile" + +SecMarker ENDMEDIA_uploadFile_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200301,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200301,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_uploadFile diff --git a/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf b/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf index 9885b15..c83db6a 100644 --- a/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/StoreApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:[^/]+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" +SecRule REQUEST_FILENAME "!@rx ^/v2/store/order/(?:[^/]+)$" "id:4200323,phase:2,pass,nolog,skipAfter:END_deleteOrder" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200324,phase:2,pass,nolog,skipAfter:END_deleteOrder" @@ -12,12 +12,12 @@ SecRule REQUEST_METHOD "!@within DELETE" "id:4200324,phase:2,pass,nolog,skipAfte # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200335,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200346,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteOrder" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200342,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200342,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_deleteOrder @@ -32,7 +32,7 @@ SecMarker END_deleteOrder # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/inventory$" "id:4200363,phase:2,pass,nolog,skipAfter:END_getInventory" +SecRule REQUEST_FILENAME "!@rx ^/v2/store/inventory$" "id:4200363,phase:2,pass,nolog,skipAfter:END_getInventory" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200364,phase:2,pass,nolog,skipAfter:END_getInventory" @@ -41,12 +41,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200364,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200375,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200386,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getInventory" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200382,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200382,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getInventory @@ -61,7 +61,7 @@ SecMarker END_getInventory # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/order/(?:[0-9]{1,19})$" "id:4200403,phase:2,pass,nolog,skipAfter:END_getOrderById" +SecRule REQUEST_FILENAME "!@rx ^/v2/store/order/(?:[0-9]{1,19})$" "id:4200403,phase:2,pass,nolog,skipAfter:END_getOrderById" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200404,phase:2,pass,nolog,skipAfter:END_getOrderById" @@ -70,12 +70,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200404,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200415,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200426,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getOrderById" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200422,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200422,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getOrderById @@ -90,7 +90,7 @@ SecMarker END_getOrderById # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/store/order$" "id:4200443,phase:2,pass,nolog,skipAfter:END_placeOrder" +SecRule REQUEST_FILENAME "!@rx ^/v2/store/order$" "id:4200443,phase:2,pass,nolog,skipAfter:END_placeOrder" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200444,phase:2,pass,nolog,skipAfter:END_placeOrder" @@ -99,24 +99,24 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200444,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.petId|json\.quantity|json\.shipDate|json\.status|json\.complete)$" "id:4200455,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200457,phase:2,pass,nolog,skipAfter:ENDJSON_placeOrder" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210817,phase:2,pass,nolog,skipAfter:ENDMEDIA_placeOrder_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200458,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210794,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.petId "!@rx ^[0-9]{1,19}$" "id:4210806,phase:2,block,msg:'Invalid value for property json.petId',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.quantity "!@rx ^[0-9]{1,19}$" "id:4210818,phase:2,block,msg:'Invalid value for property json.quantity',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.shipDate "!@rx ^(\d{4}-((01|03|05|07|08|10|12)-(0[1-9]|1\d|2\d|3[0-1])|(04|06|09|11)-(0[1-9]|1\d|2\d|30)|02-(0[1-9]|1\d|2\d))T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(\.\d+)?([Zz]|[+\-](0\d|1[0-4])(:[0-5]\d)?))$" "id:4210830,phase:2,block,msg:'Invalid value for property json.shipDate',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(placed|approved|delivered)$" "id:4210842,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.complete "!@rx ^(true|false)$" "id:4210854,phase:2,block,msg:'Invalid value for property json.complete',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200460,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_placeOrder" +SecRule REQBODY_ERROR "!@eq 0" "id:4210818,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210822,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.petId "!@rx ^[0-9]{1,19}$" "id:4210834,phase:2,block,msg:'Invalid value for property json.petId',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.quantity "!@rx ^[0-9]{1,19}$" "id:4210846,phase:2,block,msg:'Invalid value for property json.quantity',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.shipDate "!@rx ^(\d{4}-((01|03|05|07|08|10|12)-(0[1-9]|1\d|2\d|3[0-1])|(04|06|09|11)-(0[1-9]|1\d|2\d|30)|02-(0[1-9]|1\d|2\d))T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(\.\d+)?([Zz]|[+\-](0\d|1[0-4])(:[0-5]\d)?))$" "id:4210858,phase:2,block,msg:'Invalid value for property json.shipDate',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(placed|approved|delivered)$" "id:4210870,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.complete "!@rx ^(true|false)$" "id:4210882,phase:2,block,msg:'Invalid value for property json.complete',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210820,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_placeOrder" -SecMarker ENDJSON_placeOrder +SecMarker ENDMEDIA_placeOrder_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200462,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200462,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_placeOrder diff --git a/src/test/resources/golden/petstore/modsecurity3/UserApi.conf b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf index 9a38ed0..95db1f8 100644 --- a/src/test/resources/golden/petstore/modsecurity3/UserApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user$" "id:4200484,phase:2,pass,nolog,skipAfter:END_createUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user$" "id:4200484,phase:2,pass,nolog,skipAfter:END_createUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200485,phase:2,pass,nolog,skipAfter:END_createUser" @@ -12,26 +12,26 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200485,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.username|json\.firstName|json\.lastName|json\.email|json\.password|json\.phone|json\.userStatus)$" "id:4200496,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200498,phase:2,pass,nolog,skipAfter:ENDJSON_createUser" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210933,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUser_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200499,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210906,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.username "!@rx ^.+$" "id:4210918,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.firstName "!@rx ^.+$" "id:4210930,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.lastName "!@rx ^.+$" "id:4210942,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.email "!@rx ^.+$" "id:4210954,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.password "!@rx ^.+$" "id:4210966,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.phone "!@rx ^.+$" "id:4210978,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4210990,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200501,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUser" +SecRule REQBODY_ERROR "!@eq 0" "id:4210934,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210938,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4210950,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4210962,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4210974,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4210986,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4210998,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4211010,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211022,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210936,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUser" -SecMarker ENDJSON_createUser +SecMarker ENDMEDIA_createUser_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200503,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200503,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_createUser @@ -46,7 +46,7 @@ SecMarker END_createUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/createWithArray$" "id:4200524,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/createWithArray$" "id:4200524,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter:END_createUsersWithArrayInput" @@ -55,26 +55,26 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200538,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithArrayInput" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211073,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUsersWithArrayInput_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200539,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211042,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211054,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211066,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200541,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithArrayInput" +SecRule REQBODY_ERROR "!@eq 0" "id:4211074,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211138,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211150,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211162,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4211076,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithArrayInput" -SecMarker ENDJSON_createUsersWithArrayInput +SecMarker ENDMEDIA_createUsersWithArrayInput_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200543,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200543,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_createUsersWithArrayInput @@ -89,7 +89,7 @@ SecMarker END_createUsersWithArrayInput # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/createWithList$" "id:4200564,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/createWithList$" "id:4200564,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter:END_createUsersWithListInput" @@ -98,26 +98,26 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200578,phase:2,pass,nolog,skipAfter:ENDJSON_createUsersWithListInput" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211213,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUsersWithListInput_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200579,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211178,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211190,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211202,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211214,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211226,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211238,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211250,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211262,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200581,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithListInput" +SecRule REQBODY_ERROR "!@eq 0" "id:4211214,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211218,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211230,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211242,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211254,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211266,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211278,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211290,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211302,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4211216,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithListInput" -SecMarker ENDJSON_createUsersWithListInput +SecMarker ENDMEDIA_createUsersWithListInput_0 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200583,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200583,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_createUsersWithListInput @@ -132,7 +132,7 @@ SecMarker END_createUsersWithListInput # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/(?:[^/]+)$" "id:4200604,phase:2,pass,nolog,skipAfter:END_deleteUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200605,phase:2,pass,nolog,skipAfter:END_deleteUser" @@ -141,12 +141,12 @@ SecRule REQUEST_METHOD "!@within DELETE" "id:4200605,phase:2,pass,nolog,skipAfte # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200616,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200627,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteUser" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200623,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200623,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_deleteUser @@ -161,7 +161,7 @@ SecMarker END_deleteUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/(?:[^/]+)$" "id:4200644,phase:2,pass,nolog,skipAfter:END_getUserByName" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200645,phase:2,pass,nolog,skipAfter:END_getUserByName" @@ -170,12 +170,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200645,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200656,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200667,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getUserByName" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200663,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200663,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getUserByName @@ -190,25 +190,25 @@ SecMarker END_getUserByName # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/login$" "id:4200684,phase:2,pass,nolog,skipAfter:END_loginUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/login$" "id:4200684,phase:2,pass,nolog,skipAfter:END_loginUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200685,phase:2,pass,nolog,skipAfter:END_loginUser" -SecRule ARGS_GET:username "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4211398,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:username "@gt 1" "id:4211399,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS_GET:password "!@rx ^.+$" "id:4211438,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS_GET:password "@gt 1" "id:4211439,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:username "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4211438,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:username "@gt 1" "id:4211439,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:password "!@rx ^.+$" "id:4211478,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:password "@gt 1" "id:4211479,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:username|password)$" "id:4200696,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200707,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_loginUser" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200703,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200703,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_loginUser @@ -223,7 +223,7 @@ SecMarker END_loginUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/logout$" "id:4200724,phase:2,pass,nolog,skipAfter:END_logoutUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/logout$" "id:4200724,phase:2,pass,nolog,skipAfter:END_logoutUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200725,phase:2,pass,nolog,skipAfter:END_logoutUser" @@ -232,12 +232,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200725,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200736,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200747,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_logoutUser" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200743,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200743,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_logoutUser @@ -252,7 +252,7 @@ SecMarker END_logoutUser # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/user/(?:[^/]+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" +SecRule REQUEST_FILENAME "!@rx ^/v2/user/(?:[^/]+)$" "id:4200764,phase:2,pass,nolog,skipAfter:END_updateUser" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within PUT" "id:4200765,phase:2,pass,nolog,skipAfter:END_updateUser" @@ -261,26 +261,26 @@ SecRule REQUEST_METHOD "!@within PUT" "id:4200765,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.username|json\.firstName|json\.lastName|json\.email|json\.password|json\.phone|json\.userStatus)$" "id:4200776,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200778,phase:2,pass,nolog,skipAfter:ENDJSON_updateUser" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211513,phase:2,pass,nolog,skipAfter:ENDMEDIA_updateUser_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200779,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211514,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.username "!@rx ^.+$" "id:4211526,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211538,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211550,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.email "!@rx ^.+$" "id:4211562,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.password "!@rx ^.+$" "id:4211574,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.phone "!@rx ^.+$" "id:4211586,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211598,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200781,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updateUser" - -SecMarker ENDJSON_updateUser - - -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200783,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +SecRule REQBODY_ERROR "!@eq 0" "id:4211514,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4211558,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.username "!@rx ^.+$" "id:4211570,phase:2,block,msg:'Invalid value for property json.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.firstName "!@rx ^.+$" "id:4211582,phase:2,block,msg:'Invalid value for property json.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.lastName "!@rx ^.+$" "id:4211594,phase:2,block,msg:'Invalid value for property json.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.email "!@rx ^.+$" "id:4211606,phase:2,block,msg:'Invalid value for property json.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.password "!@rx ^.+$" "id:4211618,phase:2,block,msg:'Invalid value for property json.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.phone "!@rx ^.+$" "id:4211630,phase:2,block,msg:'Invalid value for property json.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.userStatus "!@rx ^[0-9]{1,19}$" "id:4211642,phase:2,block,msg:'Invalid value for property json.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4211516,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updateUser" + +SecMarker ENDMEDIA_updateUser_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200783,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_updateUser diff --git a/src/test/resources/golden/urlintparam/coraza/PetApi.conf b/src/test/resources/golden/urlintparam/coraza/PetApi.conf index acd7cae..b972a6a 100644 --- a/src/test/resources/golden/urlintparam/coraza/PetApi.conf +++ b/src/test/resources/golden/urlintparam/coraza/PetApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addPet" @@ -12,37 +12,37 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addPet" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210002,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210014,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210026,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210037,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210038,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210050,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210062,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210086,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210010,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210022,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210034,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210094,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200018,phase:2,block,msg:'JSON schema validation failed for addPet',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210003,phase:2,block,msg:'JSON schema validation failed for addPet',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" -SecMarker ENDJSON_addPet -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200020,phase:2,pass,nolog,skipAfter:ENDXML_addPet" -SecRule REQBODY_ERROR "!@eq 0" "id:4200023,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecMarker ENDMEDIA_addPet_0 +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210005,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_1" +SecRule REQBODY_ERROR "!@eq 0" "id:4210006,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" # TODO: Implement XML schema validation (issue #14 covers JSON objects) -SecAction "id:4200024,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" +SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" -SecMarker ENDXML_addPet +SecMarker ENDMEDIA_addPet_1 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_addPet @@ -57,7 +57,7 @@ SecMarker END_addPet # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_getPetById" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_getPetById" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_getPetById" @@ -66,12 +66,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getPetById @@ -86,7 +86,7 @@ SecMarker END_getPetById # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200082,phase:2,pass,nolog,skipAfter:END_updatePet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet$" "id:4200082,phase:2,pass,nolog,skipAfter:END_updatePet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within PUT" "id:4200083,phase:2,pass,nolog,skipAfter:END_updatePet" @@ -95,37 +95,37 @@ SecRule REQUEST_METHOD "!@within PUT" "id:4200083,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200096,phase:2,pass,nolog,skipAfter:ENDJSON_updatePet" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210185,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200097,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210178,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210190,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210202,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210213,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210214,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210226,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210238,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210250,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210262,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210186,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210194,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210206,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210218,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210229,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210230,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210242,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210254,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210266,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210278,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. -SecRule REQUEST_BODY "@validateSchema schema.json" "id:4200098,phase:2,block,msg:'JSON schema validation failed for updatePet',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200099,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210187,phase:2,block,msg:'JSON schema validation failed for updatePet',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210188,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" -SecMarker ENDJSON_updatePet -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200100,phase:2,pass,nolog,skipAfter:ENDXML_updatePet" -SecRule REQBODY_ERROR "!@eq 0" "id:4200103,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecMarker ENDMEDIA_updatePet_0 +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210189,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_1" +SecRule REQBODY_ERROR "!@eq 0" "id:4210190,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" # TODO: Implement XML schema validation (issue #14 covers JSON objects) -SecAction "id:4200104,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" +SecAction "id:4210192,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" -SecMarker ENDXML_updatePet +SecMarker ENDMEDIA_updatePet_1 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_updatePet diff --git a/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf b/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf index 5e7d9b0..71af0b5 100644 --- a/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf +++ b/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf @@ -3,7 +3,7 @@ # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet$" "id:4200002,phase:2,pass,nolog,skipAfter:END_addPet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_addPet" @@ -12,34 +12,34 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter: # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200016,phase:2,pass,nolog,skipAfter:ENDJSON_addPet" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200017,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210002,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210014,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210026,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210037,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210038,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210050,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210062,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210086,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200019,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" - -SecMarker ENDJSON_addPet -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200020,phase:2,pass,nolog,skipAfter:ENDXML_addPet" -SecRule REQBODY_ERROR "!@eq 0" "id:4200023,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210010,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210022,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210034,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210094,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDMEDIA_addPet_0 +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210005,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_1" +SecRule REQBODY_ERROR "!@eq 0" "id:4210006,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" # TODO: Implement XML schema validation (issue #14 covers JSON objects) -SecAction "id:4200024,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" +SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" -SecMarker ENDXML_addPet +SecMarker ENDMEDIA_addPet_1 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_addPet @@ -54,7 +54,7 @@ SecMarker END_addPet # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_getPetById" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pass,nolog,skipAfter:END_getPetById" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_getPetById" @@ -63,12 +63,12 @@ SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_getPetById @@ -83,7 +83,7 @@ SecMarker END_getPetById # Skip this operation if the request does not match the operation path. # Path parameter validation patterns are embedded in the regex, so this rule # both routes and validates path parameters (works on ModSecurity3 and Coraza). -SecRule REQUEST_FILENAME "!@rx ^/pet$" "id:4200082,phase:2,pass,nolog,skipAfter:END_updatePet" +SecRule REQUEST_FILENAME "!@rx ^/v2/pet$" "id:4200082,phase:2,pass,nolog,skipAfter:END_updatePet" # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within PUT" "id:4200083,phase:2,pass,nolog,skipAfter:END_updatePet" @@ -92,34 +92,34 @@ SecRule REQUEST_METHOD "!@within PUT" "id:4200083,phase:2,pass,nolog,skipAfter:E # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.category|json\.category\.id|json\.category\.name|json\.name|json\.photoUrls|json\.photoUrls\.(?:array_)?\d{1,9}|json\.tags|json\.tags\.(?:array_)?\d{1,9}|json\.tags\.(?:array_)?\d{1,9}\.id|json\.tags\.(?:array_)?\d{1,9}\.name|json\.status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" -# Handle Models -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4200096,phase:2,pass,nolog,skipAfter:ENDJSON_updatePet" +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210185,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. -SecRule REQBODY_ERROR "!@eq 0" "id:4200097,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210178,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210190,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210202,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:json.name "@eq 0" "id:4210213,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.name "!@rx ^.+$" "id:4210214,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210226,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210238,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210250,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210262,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecAction "id:4200099,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" - -SecMarker ENDJSON_updatePet -SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4200100,phase:2,pass,nolog,skipAfter:ENDXML_updatePet" -SecRule REQBODY_ERROR "!@eq 0" "id:4200103,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:4210186,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.id "!@rx ^[0-9]{1,19}$" "id:4210194,phase:2,block,msg:'Invalid value for property json.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210206,phase:2,block,msg:'Invalid value for property json.category.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210218,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.name "@eq 0" "id:4210229,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.name "!@rx ^.+$" "id:4210230,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210242,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210254,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210266,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210278,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210188,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDMEDIA_updatePet_0 +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210189,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_1" +SecRule REQBODY_ERROR "!@eq 0" "id:4210190,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" # TODO: Implement XML schema validation (issue #14 covers JSON objects) -SecAction "id:4200104,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" +SecAction "id:4210192,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" -SecMarker ENDXML_updatePet +SecMarker ENDMEDIA_updatePet_1 -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200101,log,auditlog,block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_updatePet diff --git a/src/test/resources/specs/phase3-features.yaml b/src/test/resources/specs/phase3-features.yaml new file mode 100644 index 0000000..342b064 --- /dev/null +++ b/src/test/resources/specs/phase3-features.yaml @@ -0,0 +1,90 @@ +openapi: 3.0.3 +info: + title: Phase 3 features + version: "1.0" +servers: + - url: https://api.example.com/base/{version} + variables: + version: + default: v1 +paths: + /things/{thingId}: + get: + operationId: getThing + security: + - apiKeyQuery: [] + parameters: + - name: thingId + in: path + required: true + schema: + type: string + - name: tags + in: query + style: form + explode: false + schema: + type: array + minItems: 2 + maxItems: 5 + items: + type: string + pattern: '^[a-z]{1,10}$' + - name: ids + in: query + style: pipeDelimited + explode: false + schema: + type: array + items: + type: integer + - name: filter + in: query + style: deepObject + explode: true + schema: + type: object + properties: + name: + type: string + responses: + "200": + description: ok + /things: + post: + operationId: createThing + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' + responses: + "200": + description: ok +components: + securitySchemes: + apiKeyQuery: + type: apiKey + in: query + name: api_key + schemas: + Thing: + type: object + required: [id, name, nickname] + properties: + id: + type: integer + readOnly: true + name: + type: string + nickname: + type: string + nullable: true + attrs: + type: object + additionalProperties: + type: string + pattern: '^[a-z]{1,20}$' + misc: + type: object diff --git a/src/test/resources/test-data/petstore/pet/invalid/extra_property.json b/src/test/resources/test-data/petstore/v2/pet/invalid/extra_property.json similarity index 100% rename from src/test/resources/test-data/petstore/pet/invalid/extra_property.json rename to src/test/resources/test-data/petstore/v2/pet/invalid/extra_property.json diff --git a/src/test/resources/test-data/petstore/pet/invalid/invalid_type.json b/src/test/resources/test-data/petstore/v2/pet/invalid/invalid_type.json similarity index 100% rename from src/test/resources/test-data/petstore/pet/invalid/invalid_type.json rename to src/test/resources/test-data/petstore/v2/pet/invalid/invalid_type.json diff --git a/src/test/resources/test-data/petstore/pet/invalid/missing_required.json b/src/test/resources/test-data/petstore/v2/pet/invalid/missing_required.json similarity index 100% rename from src/test/resources/test-data/petstore/pet/invalid/missing_required.json rename to src/test/resources/test-data/petstore/v2/pet/invalid/missing_required.json diff --git a/src/test/resources/test-data/petstore/pet/valid.json b/src/test/resources/test-data/petstore/v2/pet/valid.json similarity index 100% rename from src/test/resources/test-data/petstore/pet/valid.json rename to src/test/resources/test-data/petstore/v2/pet/valid.json From 11e6573344f6ec8294e0a5d19a0d7951da5c2fca Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 15:24:13 -0500 Subject: [PATCH 5/9] Add header/cookie validation, required presence, multipleOf, array counts (Phase 4) - Header parameters get REQUEST_HEADERS value rules plus a presence rule when required; cookie parameters likewise via REQUEST_COOKIES. Undeclared headers and cookies are never blocked. - Required query and form parameters gain &ARGS @eq 0 presence rules. - Power-of-10 integer multipleOf becomes a trailing-zeros value pattern on both parameters and body properties; other multipleOf values are schema.json-only (Coraza). - Body arrays of primitives enforce maxItems unconditionally and minItems when the array is required; absent-vs-empty is indistinguishable per-field on ModSecurity3, so optional-array minItems and min/maxProperties are schema-only (documented in docs/engine-behavior.md). - New samples/paramfeatures.yaml with a both-engines Cucumber scenario and raw GET header/cookie steps in the integration harness. Co-Authored-By: Claude Fable 5 --- docs/engine-behavior.md | 10 ++ samples/paramfeatures.yaml | 57 +++++++++ .../modsecurity3/Modsecurity3Generator.java | 25 +++- .../resources/modsecurity3/config.mustache | 24 ++++ .../modsecurity3/tests/GoldenFileTest.java | 3 +- .../tests/Phase4EnforcementTest.java | 111 ++++++++++++++++++ .../actions/HttpRequestAction.java | 27 +++++ .../actions/TestActionService.java | 16 +++ .../steps/ModSecurityStepDefinitions.java | 21 ++++ .../modsecurity_rule_generation.feature | 32 +++++ .../golden/getparam/coraza/PetApi.conf | 2 + .../golden/getparam/modsecurity3/PetApi.conf | 2 + .../paramfeatures/coraza/DefaultApi.conf | 79 +++++++++++++ .../paramfeatures/coraza/mainconfig.conf | 20 ++++ .../golden/paramfeatures/coraza/schema.json | 26 ++++ .../modsecurity3/DefaultApi.conf | 76 ++++++++++++ .../modsecurity3/mainconfig.conf | 20 ++++ .../paramfeatures/modsecurity3/schema.json | 26 ++++ .../golden/petstore/coraza/PetApi.conf | 3 + .../golden/petstore/coraza/UserApi.conf | 2 + .../golden/petstore/modsecurity3/PetApi.conf | 3 + .../golden/petstore/modsecurity3/UserApi.conf | 2 + src/test/resources/specs/phase3-features.yaml | 8 ++ 23 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 samples/paramfeatures.yaml create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase4EnforcementTest.java create mode 100644 src/test/resources/golden/paramfeatures/coraza/DefaultApi.conf create mode 100644 src/test/resources/golden/paramfeatures/coraza/mainconfig.conf create mode 100644 src/test/resources/golden/paramfeatures/coraza/schema.json create mode 100644 src/test/resources/golden/paramfeatures/modsecurity3/DefaultApi.conf create mode 100644 src/test/resources/golden/paramfeatures/modsecurity3/mainconfig.conf create mode 100644 src/test/resources/golden/paramfeatures/modsecurity3/schema.json diff --git a/docs/engine-behavior.md b/docs/engine-behavior.md index 9078ad5..42437a6 100644 --- a/docs/engine-behavior.md +++ b/docs/engine-behavior.md @@ -33,6 +33,16 @@ Generator design decisions reference this file. Last run: 2026-07-04 against - File parts appear in `FILES_NAMES` (both engines), not in `ARGS_POST`. - `REQBODY_ERROR` did not fire for well-formed multipart on either engine. +## Per-field enforcement limits derived from flattening + +- ModSecurity3 lists only **leaf** keys in ARGS (no container nodes), so + object-property counts (`minProperties`/`maxProperties`) and object-array + element counts cannot be counted reliably per-field: an object with only + nested-object members produces no countable key of its own. These constraints + are enforced via schema.json on Coraza only. +- Array element counts (`minItems`/`maxItems`) ARE enforced per-field for + arrays of primitives (their indexed leaf keys are countable on both engines). + ## Coraza `@validateSchema` (JSON Schema) Coraza's validator honors modern keywords regardless of the declared `$schema` diff --git a/samples/paramfeatures.yaml b/samples/paramfeatures.yaml new file mode 100644 index 0000000..e70316b --- /dev/null +++ b/samples/paramfeatures.yaml @@ -0,0 +1,57 @@ +openapi: 3.0.3 +info: + title: Parameter enforcement features + description: header/cookie validation, required presence, multipleOf, body array counts + version: "1.0" +paths: + /widgets: + get: + operationId: listWidgets + parameters: + - name: q + in: query + required: true + schema: + type: string + - name: X-Request-Id + in: header + required: true + schema: + type: string + format: uuid + - name: X-Trace + in: header + schema: + type: string + pattern: '^[a-f0-9]{8}$' + - name: session + in: cookie + required: true + schema: + type: string + pattern: '^[A-Za-z0-9]{10,64}$' + responses: + "200": + description: ok + post: + operationId: createWidget + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [labels] + properties: + price: + type: integer + multipleOf: 100 + labels: + type: array + minItems: 1 + maxItems: 3 + items: + type: string + responses: + "200": + description: ok diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index f89022b..86fa422 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -846,7 +846,7 @@ private static List lookupModelVars(CodegenProperty prop, Map 1, * 100 -> 2, ...); -1 when the value is not a power of ten >= 10. */ - static int powerOfTenZeros(Number multipleOf) { + public static int powerOfTenZeros(Number multipleOf) { if (multipleOf == null) { return -1; } @@ -1049,6 +1049,24 @@ private void decorateBodyProperty(CodegenProperty prop, java.util.Collection 0 && (typeSource.isInteger || typeSource.isLong)) { + pattern = "^(?:0|[0-9]{1," + (19 - propZeros) + "}0{" + propZeros + "})$"; + } if (typeSource.isNullable && pattern != null && !pattern.isEmpty()) { // JSON null flattens to a present key with an EMPTY value on both engines // (docs/engine-behavior.md), so nullable values must accept empty. diff --git a/src/main/resources/modsecurity3/config.mustache b/src/main/resources/modsecurity3/config.mustache index 94e9661..7a6163b 100644 --- a/src/main/resources/modsecurity3/config.mustache +++ b/src/main/resources/modsecurity3/config.mustache @@ -13,6 +13,9 @@ SecRule REQUEST_METHOD "!@within {{httpMethod}}" "id:{{vendorExtensions.x-codege {{#pattern}} {{#isQueryParam}} SecRule ARGS_GET:{{paramName}} "!@rx {{pattern}}" "id:{{vendorExtensions.x-codegen-globalIndex_6}},phase:2,block,msg:'Forbidden parameter value detected',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{#required}} +SecRule &ARGS_GET:{{paramName}} "@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_22}},phase:2,block,msg:'Missing required parameter {{paramName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{/required}} {{^isArray}} SecRule &ARGS_GET:{{paramName}} "@gt 1" "id:{{vendorExtensions.x-codegen-globalIndex_7}},phase:2,block,msg:'Multiple values for non-array parameter',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/isArray}} @@ -31,6 +34,9 @@ SecRule ARGS_GET:{{paramName}} "{{#exclusiveMaximum}}@ge{{/exclusiveMaximum}}{{^ {{/isQueryParam}} {{#isFormParam}} SecRule ARGS_POST:{{paramName}} "!@rx {{pattern}}" "id:{{vendorExtensions.x-codegen-globalIndex_10}},phase:2,block,msg:'Forbidden parameter value detected',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{#required}} +SecRule &ARGS_POST:{{paramName}} "@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_23}},phase:2,block,msg:'Missing required parameter {{paramName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{/required}} {{^isArray}} SecRule &ARGS_POST:{{paramName}} "@gt 1" "id:{{vendorExtensions.x-codegen-globalIndex_11}},phase:2,block,msg:'Multiple values for non-array parameter',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/isArray}} @@ -41,6 +47,18 @@ SecRule ARGS_POST:{{paramName}} "{{#exclusiveMinimum}}@le{{/exclusiveMinimum}}{{ SecRule ARGS_POST:{{paramName}} "{{#exclusiveMaximum}}@ge{{/exclusiveMaximum}}{{^exclusiveMaximum}}@gt{{/exclusiveMaximum}} {{maximum}}" "id:{{vendorExtensions.x-codegen-globalIndex_13}},phase:2,block,msg:'Parameter value above maximum',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/maximum}} {{/isFormParam}} +{{#isHeaderParam}} +SecRule REQUEST_HEADERS:{{baseName}} "!@rx {{pattern}}" "id:{{vendorExtensions.x-codegen-globalIndex_18}},phase:2,block,msg:'Forbidden header value detected',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{#required}} +SecRule &REQUEST_HEADERS:{{baseName}} "@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_19}},phase:2,block,msg:'Missing required header {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{/required}} +{{/isHeaderParam}} +{{#isCookieParam}} +SecRule REQUEST_COOKIES:{{baseName}} "!@rx {{pattern}}" "id:{{vendorExtensions.x-codegen-globalIndex_20}},phase:2,block,msg:'Forbidden cookie value detected',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{#required}} +SecRule &REQUEST_COOKIES:{{baseName}} "@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_21}},phase:2,block,msg:'Missing required cookie {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{/required}} +{{/isCookieParam}} {{/pattern}} {{/allParams}} @@ -81,6 +99,12 @@ SecRule ARGS:{{vendorExtensions.x-oashield-argTarget}} "{{#exclusiveMinimum}}@le {{#maximum}} SecRule ARGS:{{vendorExtensions.x-oashield-argTarget}} "{{#exclusiveMaximum}}@ge{{/exclusiveMaximum}}{{^exclusiveMaximum}}@gt{{/exclusiveMaximum}} {{maximum}}" "id:{{vendorExtensions.x-codegen-propIndex_4}},phase:2,block,msg:'Property value above maximum for {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/maximum}} +{{#vendorExtensions.x-oashield-countMin}} +SecRule &ARGS:{{vendorExtensions.x-oashield-countSelector}} "@lt {{vendorExtensions.x-oashield-countMin}}" "id:{{vendorExtensions.x-codegen-propIndex_5}},phase:2,block,msg:'Too few array elements for {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{/vendorExtensions.x-oashield-countMin}} +{{#vendorExtensions.x-oashield-countMax}} +SecRule &ARGS:{{vendorExtensions.x-oashield-countSelector}} "@gt {{vendorExtensions.x-oashield-countMax}}" "id:{{vendorExtensions.x-codegen-propIndex_6}},phase:2,block,msg:'Too many array elements for {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{/vendorExtensions.x-oashield-countMax}} {{/vendorExtensions.x-codegen-modelProperties}} {{/isBodyParam}} {{/allParams}} diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java index e269e4f..0bbfa0f 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java @@ -29,7 +29,8 @@ public class GoldenFileTest { private static final Path GOLDEN_ROOT = Paths.get("src/test/resources/golden"); - private static final List SAMPLES = List.of("petstore", "composed", "getparam", "urlintparam", "multipart"); + private static final List SAMPLES = List.of( + "petstore", "composed", "getparam", "urlintparam", "multipart", "paramfeatures"); private static final List FLAVORS = List.of("modsecurity3", "coraza"); static Stream cases() { diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase4EnforcementTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase4EnforcementTest.java new file mode 100644 index 0000000..d741ef5 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase4EnforcementTest.java @@ -0,0 +1,111 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; + +import com.oashield.openapi.generators.modsecurity3.Modsecurity3Generator; + +/** + * New enforcement (Phase 4): header/cookie parameter validation, required + * parameter presence, power-of-10 multipleOf, and body array element counts. + */ +public class Phase4EnforcementTest { + + @TempDir + static Path outputDir; + private static String conf; + + @BeforeAll + static void generate() throws IOException { + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("samples/paramfeatures.yaml") + .setOutputDir(outputDir.toString()) + .toClientOptInput()) + .generate(); + try (Stream files = Files.list(outputDir)) { + conf = files.filter(p -> p.getFileName().toString().endsWith("Api.conf")) + .map(p -> { + try { + return Files.readString(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.joining("\n")); + } + } + + @Test + void requiredQueryParamGetsPresenceRule() { + assertTrue(conf.contains("SecRule &ARGS_GET:q \"@eq 0\""), + "required query parameter needs a presence rule:\n" + conf); + } + + @Test + void headerParamsAreValidated() { + assertTrue(conf.contains("SecRule REQUEST_HEADERS:X-Request-Id \"!@rx"), + "header parameter value rule missing:\n" + conf); + assertTrue(conf.contains("SecRule &REQUEST_HEADERS:X-Request-Id \"@eq 0\""), + "required header presence rule missing:\n" + conf); + assertTrue(conf.contains("SecRule REQUEST_HEADERS:X-Trace \"!@rx ^[a-f0-9]{8}$\""), + "optional header spec pattern rule missing:\n" + conf); + assertFalse(conf.contains("&REQUEST_HEADERS:X-Trace"), + "optional header must not get a presence rule:\n" + conf); + } + + @Test + void cookieParamsAreValidated() { + assertTrue(conf.contains("SecRule REQUEST_COOKIES:session \"!@rx ^[A-Za-z0-9]{10,64}$\""), + "cookie parameter value rule missing:\n" + conf); + assertTrue(conf.contains("SecRule &REQUEST_COOKIES:session \"@eq 0\""), + "required cookie presence rule missing:\n" + conf); + } + + @Test + void headerAndCookieNamesAreNotInArgsAllowlist() { + // undeclared headers/cookies must pass; only query/form/body names are allowlisted + assertTrue(conf.contains("ARGS_NAMES \"!@rx ^(?:q)$\""), + "GET allowlist should contain only the query param:\n" + conf); + } + + @Test + void powerOfTenMultipleOfBecomesTrailingZerosPattern() { + assertTrue(conf.contains("ARGS:json.price \"!@rx ^(?:0|[0-9]{1,17}0{2})$\""), + "multipleOf: 100 should produce a trailing-zeros pattern:\n" + conf); + } + + @Test + void bodyArrayCountsAreEnforced() { + assertTrue(conf.contains("SecRule &ARGS:/^json\\.labels\\.(?:array_)?\\d{1,9}$/ \"@lt 1\""), + "minItems count rule missing:\n" + conf); + assertTrue(conf.contains("SecRule &ARGS:/^json\\.labels\\.(?:array_)?\\d{1,9}$/ \"@gt 3\""), + "maxItems count rule missing:\n" + conf); + } + + @Test + void powerOfTenZerosHelper() { + assertEquals(1, Modsecurity3Generator.powerOfTenZeros(10)); + assertEquals(2, Modsecurity3Generator.powerOfTenZeros(100)); + assertEquals(3, Modsecurity3Generator.powerOfTenZeros(1000L)); + assertEquals(-1, Modsecurity3Generator.powerOfTenZeros(null)); + assertEquals(-1, Modsecurity3Generator.powerOfTenZeros(5)); + assertEquals(-1, Modsecurity3Generator.powerOfTenZeros(250)); + assertEquals(-1, Modsecurity3Generator.powerOfTenZeros(0.1)); + assertEquals(-1, Modsecurity3Generator.powerOfTenZeros(1)); + } +} diff --git a/src/test/java/com/oashield/openapi/integration/actions/HttpRequestAction.java b/src/test/java/com/oashield/openapi/integration/actions/HttpRequestAction.java index 934096f..5018d9e 100644 --- a/src/test/java/com/oashield/openapi/integration/actions/HttpRequestAction.java +++ b/src/test/java/com/oashield/openapi/integration/actions/HttpRequestAction.java @@ -37,6 +37,33 @@ public static Response executePostRequest(String url, String requestBody) { .post(url); } + /** + * Execute a GET request with explicit headers (including Cookie) and return + * the status code. Uses java.net.http directly so headers reach the WAF + * verbatim. + * + * @param url the URL to send the GET request to + * @param headers header name/value pairs to send + * @return the HTTP status code + */ + public static int executeRawGetRequest(String url, Map headers) { + logger.info("Executing raw GET request to URL: {} with headers: {}", url, headers.keySet()); + try { + java.net.http.HttpRequest.Builder builder = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(url)) + .timeout(java.time.Duration.ofSeconds(30)) + .GET(); + for (Map.Entry h : headers.entrySet()) { + builder.header(h.getKey(), h.getValue()); + } + return java.net.http.HttpClient.newHttpClient() + .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString()) + .statusCode(); + } catch (java.io.IOException | InterruptedException e) { + throw new RuntimeException("Raw GET request to " + url + " failed", e); + } + } + /** * Execute a POST request with an explicit content type (form, multipart, * binary, ...) and return the status code. Uses java.net.http directly: diff --git a/src/test/java/com/oashield/openapi/integration/actions/TestActionService.java b/src/test/java/com/oashield/openapi/integration/actions/TestActionService.java index ef3b91f..bb26fb3 100644 --- a/src/test/java/com/oashield/openapi/integration/actions/TestActionService.java +++ b/src/test/java/com/oashield/openapi/integration/actions/TestActionService.java @@ -104,6 +104,22 @@ public static Integer executeRawPostStatus(String path, String contentType, Stri return HttpRequestAction.executeRawPostRequest(buildUrl(path), contentType, requestBody); } + /** + * Execute GET request with explicit headers (including Cookie) and return the + * status code. + * + * @param path endpoint path or full URL + * @param headers header name/value pairs + * @return status code, or null if skipped via skip.http.calls + */ + public static Integer executeRawGetStatus(String path, java.util.Map headers) { + if (configService.isHttpCallsSkipped()) { + logger.info("Skipping raw GET request to {} due to skip.http.calls", path); + return null; + } + return HttpRequestAction.executeRawGetRequest(buildUrl(path), headers); + } + /** * Assert a raw-request status code; a null actual status means the request was * skipped (skip.http.calls) and the assertion is a no-op. diff --git a/src/test/java/com/oashield/openapi/integration/steps/ModSecurityStepDefinitions.java b/src/test/java/com/oashield/openapi/integration/steps/ModSecurityStepDefinitions.java index 269ee21..b7653b5 100644 --- a/src/test/java/com/oashield/openapi/integration/steps/ModSecurityStepDefinitions.java +++ b/src/test/java/com/oashield/openapi/integration/steps/ModSecurityStepDefinitions.java @@ -184,4 +184,25 @@ public void postRequestWithoutBodyShouldReturnStatusCode(String path, int expect private static String unescapeBody(String body) { return body.replace("\\r\\n", "\r\n"); } + + // Headers/cookies accumulated by "the request has ..." steps, consumed and + // cleared by the next raw GET step. + private final java.util.Map pendingHeaders = new java.util.LinkedHashMap<>(); + + @io.cucumber.java.en.When("the request has header {string} set to {string}") + public void requestHasHeader(String name, String value) { + pendingHeaders.put(name, value); + } + + @io.cucumber.java.en.When("the request has cookie {string} set to {string}") + public void requestHasCookie(String name, String value) { + pendingHeaders.merge("Cookie", name + "=" + value, (a, b) -> a + "; " + b); + } + + @Then("a raw GET request to {string} should return a {int} status code") + public void rawGetRequestShouldReturnStatusCode(String path, int expectedStatus) { + Integer status = TestActionService.executeRawGetStatus(path, new java.util.LinkedHashMap<>(pendingHeaders)); + pendingHeaders.clear(); + TestActionService.assertRawStatus(status, expectedStatus, "raw GET request to " + path); + } } diff --git a/src/test/resources/features/modsecurity_rule_generation.feature b/src/test/resources/features/modsecurity_rule_generation.feature index 7d54628..8372f13 100644 --- a/src/test/resources/features/modsecurity_rule_generation.feature +++ b/src/test/resources/features/modsecurity_rule_generation.feature @@ -116,3 +116,35 @@ Feature: ModSecurity Rule Generation and Testing | engine | | coraza | | modsecurity3 | + + Scenario Outline: Header, cookie, required-parameter and array-count enforcement + Given an OpenAPI specification file at "samples/paramfeatures.yaml" + When I generate rules with body validation for "" + And I start the WAF server with the generated rules + When the request has header "X-Request-Id" set to "123e4567-e89b-12d3-a456-426614174000" + And the request has cookie "session" set to "abcdefghij1234" + Then a raw GET request to "/widgets?q=test" should return a 200 status code + When the request has cookie "session" set to "abcdefghij1234" + Then a raw GET request to "/widgets?q=test" should return a 403 status code + When the request has header "X-Request-Id" set to "not-a-uuid" + And the request has cookie "session" set to "abcdefghij1234" + Then a raw GET request to "/widgets?q=test" should return a 403 status code + When the request has header "X-Request-Id" set to "123e4567-e89b-12d3-a456-426614174000" + And the request has header "X-Unknown-Header" set to "anything at all" + And the request has cookie "session" set to "abcdefghij1234" + And the request has cookie "tracking_junk" set to "xyz" + Then a raw GET request to "/widgets?q=test" should return a 200 status code + When the request has header "X-Request-Id" set to "123e4567-e89b-12d3-a456-426614174000" + Then a raw GET request to "/widgets?q=test" should return a 403 status code + When the request has header "X-Request-Id" set to "123e4567-e89b-12d3-a456-426614174000" + And the request has cookie "session" set to "abcdefghij1234" + Then a raw GET request to "/widgets" should return a 403 status code + And a POST request to "/widgets" with content type "application/json" and body "{\"price\":300,\"labels\":[\"a\"]}" should return a 200 status code + And a POST request to "/widgets" with content type "application/json" and body "{\"price\":123,\"labels\":[\"a\"]}" should be blocked with a 403 status code + And a POST request to "/widgets" with content type "application/json" and body "{\"price\":300}" should be blocked with a 403 status code + And a POST request to "/widgets" with content type "application/json" and body "{\"price\":300,\"labels\":[\"a\",\"b\",\"c\",\"d\"]}" should be blocked with a 403 status code + + Examples: + | engine | + | coraza | + | modsecurity3 | diff --git a/src/test/resources/golden/getparam/coraza/PetApi.conf b/src/test/resources/golden/getparam/coraza/PetApi.conf index e3566ed..56caec7 100644 --- a/src/test/resources/golden/getparam/coraza/PetApi.conf +++ b/src/test/resources/golden/getparam/coraza/PetApi.conf @@ -8,6 +8,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByStatus$" "id:4200002,phase:2,pass, SecRule REQUEST_METHOD "!@within GET" "id:4200003,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" SecRule ARGS_GET:status "!@rx ^(?:(available|pending|sold))(?:,(?:(available|pending|sold))){0,999}$" "id:4210006,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:status "@eq 0" "id:4210022,phase:2,block,msg:'Missing required parameter status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. @@ -38,6 +39,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByTags$" "id:4200042,phase:2,pass,no SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_findPetsByTags" SecRule ARGS_GET:tags "!@rx ^(?:.+)(?:,(?:.+)){0,999}$" "id:4210046,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:tags "@eq 0" "id:4210062,phase:2,block,msg:'Missing required parameter tags',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. diff --git a/src/test/resources/golden/getparam/modsecurity3/PetApi.conf b/src/test/resources/golden/getparam/modsecurity3/PetApi.conf index e3566ed..56caec7 100644 --- a/src/test/resources/golden/getparam/modsecurity3/PetApi.conf +++ b/src/test/resources/golden/getparam/modsecurity3/PetApi.conf @@ -8,6 +8,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByStatus$" "id:4200002,phase:2,pass, SecRule REQUEST_METHOD "!@within GET" "id:4200003,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" SecRule ARGS_GET:status "!@rx ^(?:(available|pending|sold))(?:,(?:(available|pending|sold))){0,999}$" "id:4210006,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:status "@eq 0" "id:4210022,phase:2,block,msg:'Missing required parameter status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. @@ -38,6 +39,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByTags$" "id:4200042,phase:2,pass,no SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_findPetsByTags" SecRule ARGS_GET:tags "!@rx ^(?:.+)(?:,(?:.+)){0,999}$" "id:4210046,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:tags "@eq 0" "id:4210062,phase:2,block,msg:'Missing required parameter tags',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. diff --git a/src/test/resources/golden/paramfeatures/coraza/DefaultApi.conf b/src/test/resources/golden/paramfeatures/coraza/DefaultApi.conf new file mode 100644 index 0000000..d021cea --- /dev/null +++ b/src/test/resources/golden/paramfeatures/coraza/DefaultApi.conf @@ -0,0 +1,79 @@ + +# createWidget: POST /widgets +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/widgets$" "id:4200002,phase:2,pass,nolog,skipAfter:END_createWidget" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_createWidget" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.price|json\.labels|json\.labels\.(?:array_)?\d{1,9})$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_createWidget_0" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.price "!@rx ^(?:0|[0-9]{1,17}0{2})$" "id:4210006,phase:2,block,msg:'Invalid value for property json.price',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210018,phase:2,block,msg:'Invalid value for property json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "@lt 1" "id:4210021,phase:2,block,msg:'Too few array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "@gt 3" "id:4210022,phase:2,block,msg:'Too many array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210003,phase:2,block,msg:'JSON schema validation failed for createWidget',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createWidget" + +SecMarker ENDMEDIA_createWidget_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_createWidget + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_createWidget + +# listWidgets: GET /widgets +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/widgets$" "id:4200042,phase:2,pass,nolog,skipAfter:END_listWidgets" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_listWidgets" + +SecRule ARGS_GET:q "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:q "@eq 0" "id:4210090,phase:2,block,msg:'Missing required parameter q',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:q "@gt 1" "id:4210075,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQUEST_HEADERS:X-Request-Id "!@rx ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" "id:4210126,phase:2,block,msg:'Forbidden header value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &REQUEST_HEADERS:X-Request-Id "@eq 0" "id:4210127,phase:2,block,msg:'Missing required header X-Request-Id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQUEST_COOKIES:session "!@rx ^[A-Za-z0-9]{10,64}$" "id:4210168,phase:2,block,msg:'Forbidden cookie value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &REQUEST_COOKIES:session "@eq 0" "id:4210169,phase:2,block,msg:'Missing required cookie session',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQUEST_HEADERS:X-Trace "!@rx ^[a-f0-9]{8}$" "id:4210206,phase:2,block,msg:'Forbidden header value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:q)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_listWidgets" + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_listWidgets + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_listWidgets diff --git a/src/test/resources/golden/paramfeatures/coraza/mainconfig.conf b/src/test/resources/golden/paramfeatures/coraza/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/paramfeatures/coraza/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/paramfeatures/coraza/schema.json b/src/test/resources/golden/paramfeatures/coraza/schema.json new file mode 100644 index 0000000..ac61137 --- /dev/null +++ b/src/test/resources/golden/paramfeatures/coraza/schema.json @@ -0,0 +1,26 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "createWidget_request" : { + "title" : "createWidget_request", + "type" : "object", + "properties" : { + "price" : { + "type" : "integer" + }, + "labels" : { + "type" : "array", + "items" : { + "type" : "string" + }, + "minItems" : 1, + "maxItems" : 3 + } + }, + "required" : [ "labels" ] + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/paramfeatures/modsecurity3/DefaultApi.conf b/src/test/resources/golden/paramfeatures/modsecurity3/DefaultApi.conf new file mode 100644 index 0000000..e219a53 --- /dev/null +++ b/src/test/resources/golden/paramfeatures/modsecurity3/DefaultApi.conf @@ -0,0 +1,76 @@ + +# createWidget: POST /widgets +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/widgets$" "id:4200002,phase:2,pass,nolog,skipAfter:END_createWidget" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_createWidget" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.price|json\.labels|json\.labels\.(?:array_)?\d{1,9})$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_createWidget_0" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.price "!@rx ^(?:0|[0-9]{1,17}0{2})$" "id:4210006,phase:2,block,msg:'Invalid value for property json.price',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210018,phase:2,block,msg:'Invalid value for property json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "@lt 1" "id:4210021,phase:2,block,msg:'Too few array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "@gt 3" "id:4210022,phase:2,block,msg:'Too many array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createWidget" + +SecMarker ENDMEDIA_createWidget_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_createWidget + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_createWidget + +# listWidgets: GET /widgets +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/widgets$" "id:4200042,phase:2,pass,nolog,skipAfter:END_listWidgets" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within GET" "id:4200043,phase:2,pass,nolog,skipAfter:END_listWidgets" + +SecRule ARGS_GET:q "!@rx ^.+$" "id:4210074,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:q "@eq 0" "id:4210090,phase:2,block,msg:'Missing required parameter q',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:q "@gt 1" "id:4210075,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQUEST_HEADERS:X-Request-Id "!@rx ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" "id:4210126,phase:2,block,msg:'Forbidden header value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &REQUEST_HEADERS:X-Request-Id "@eq 0" "id:4210127,phase:2,block,msg:'Missing required header X-Request-Id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQUEST_COOKIES:session "!@rx ^[A-Za-z0-9]{10,64}$" "id:4210168,phase:2,block,msg:'Forbidden cookie value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &REQUEST_COOKIES:session "@eq 0" "id:4210169,phase:2,block,msg:'Missing required cookie session',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule REQUEST_HEADERS:X-Trace "!@rx ^[a-f0-9]{8}$" "id:4210206,phase:2,block,msg:'Forbidden header value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:q)$" "id:4200054,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_listWidgets" + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200061,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_listWidgets + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200062" + +## End of checks for this operation +SecMarker END_listWidgets diff --git a/src/test/resources/golden/paramfeatures/modsecurity3/mainconfig.conf b/src/test/resources/golden/paramfeatures/modsecurity3/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/paramfeatures/modsecurity3/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/paramfeatures/modsecurity3/schema.json b/src/test/resources/golden/paramfeatures/modsecurity3/schema.json new file mode 100644 index 0000000..ac61137 --- /dev/null +++ b/src/test/resources/golden/paramfeatures/modsecurity3/schema.json @@ -0,0 +1,26 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "createWidget_request" : { + "title" : "createWidget_request", + "type" : "object", + "properties" : { + "price" : { + "type" : "integer" + }, + "labels" : { + "type" : "array", + "items" : { + "type" : "string" + }, + "minItems" : 1, + "maxItems" : 3 + } + }, + "required" : [ "labels" ] + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/petstore/coraza/PetApi.conf b/src/test/resources/golden/petstore/coraza/PetApi.conf index 93d5198..d7b89f6 100644 --- a/src/test/resources/golden/petstore/coraza/PetApi.conf +++ b/src/test/resources/golden/petstore/coraza/PetApi.conf @@ -61,6 +61,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pa # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200043,phase:2,pass,nolog,skipAfter:END_deletePet" +SecRule REQUEST_HEADERS:api_key "!@rx ^.*$" "id:4210202,phase:2,block,msg:'Forbidden header value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. @@ -91,6 +92,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByStatus$" "id:4200082,phase:2,pass, SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" SecRule ARGS_GET:status "!@rx ^(?:(available|pending|sold))(?:,(?:(available|pending|sold))){0,999}$" "id:4210230,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:status "@eq 0" "id:4210246,phase:2,block,msg:'Missing required parameter status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. @@ -121,6 +123,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByTags$" "id:4200122,phase:2,pass,no SecRule REQUEST_METHOD "!@within GET" "id:4200123,phase:2,pass,nolog,skipAfter:END_findPetsByTags" SecRule ARGS_GET:tags "!@rx ^(?:.+)(?:,(?:.+)){0,999}$" "id:4210270,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:tags "@eq 0" "id:4210286,phase:2,block,msg:'Missing required parameter tags',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. diff --git a/src/test/resources/golden/petstore/coraza/UserApi.conf b/src/test/resources/golden/petstore/coraza/UserApi.conf index 460751e..0b5b7b9 100644 --- a/src/test/resources/golden/petstore/coraza/UserApi.conf +++ b/src/test/resources/golden/petstore/coraza/UserApi.conf @@ -204,8 +204,10 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/user/login$" "id:4200684,phase:2,pass,nolog, SecRule REQUEST_METHOD "!@within GET" "id:4200685,phase:2,pass,nolog,skipAfter:END_loginUser" SecRule ARGS_GET:username "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4211438,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:username "@eq 0" "id:4211454,phase:2,block,msg:'Missing required parameter username',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS_GET:username "@gt 1" "id:4211439,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS_GET:password "!@rx ^.+$" "id:4211478,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:password "@eq 0" "id:4211494,phase:2,block,msg:'Missing required parameter password',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS_GET:password "@gt 1" "id:4211479,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body diff --git a/src/test/resources/golden/petstore/modsecurity3/PetApi.conf b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf index 55d99c5..5ad4d95 100644 --- a/src/test/resources/golden/petstore/modsecurity3/PetApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf @@ -58,6 +58,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/(?:[0-9]{1,19})$" "id:4200042,phase:2,pa # Skip this operation if the request method does not match the operation SecRule REQUEST_METHOD "!@within DELETE" "id:4200043,phase:2,pass,nolog,skipAfter:END_deletePet" +SecRule REQUEST_HEADERS:api_key "!@rx ^.*$" "id:4210202,phase:2,block,msg:'Forbidden header value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. @@ -88,6 +89,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByStatus$" "id:4200082,phase:2,pass, SecRule REQUEST_METHOD "!@within GET" "id:4200083,phase:2,pass,nolog,skipAfter:END_findPetsByStatus" SecRule ARGS_GET:status "!@rx ^(?:(available|pending|sold))(?:,(?:(available|pending|sold))){0,999}$" "id:4210230,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:status "@eq 0" "id:4210246,phase:2,block,msg:'Missing required parameter status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. @@ -118,6 +120,7 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/pet/findByTags$" "id:4200122,phase:2,pass,no SecRule REQUEST_METHOD "!@within GET" "id:4200123,phase:2,pass,nolog,skipAfter:END_findPetsByTags" SecRule ARGS_GET:tags "!@rx ^(?:.+)(?:,(?:.+)){0,999}$" "id:4210270,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:tags "@eq 0" "id:4210286,phase:2,block,msg:'Missing required parameter tags',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. diff --git a/src/test/resources/golden/petstore/modsecurity3/UserApi.conf b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf index 95db1f8..36e1e38 100644 --- a/src/test/resources/golden/petstore/modsecurity3/UserApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf @@ -195,8 +195,10 @@ SecRule REQUEST_FILENAME "!@rx ^/v2/user/login$" "id:4200684,phase:2,pass,nolog, SecRule REQUEST_METHOD "!@within GET" "id:4200685,phase:2,pass,nolog,skipAfter:END_loginUser" SecRule ARGS_GET:username "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4211438,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:username "@eq 0" "id:4211454,phase:2,block,msg:'Missing required parameter username',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS_GET:username "@gt 1" "id:4211439,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS_GET:password "!@rx ^.+$" "id:4211478,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:password "@eq 0" "id:4211494,phase:2,block,msg:'Missing required parameter password',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS_GET:password "@gt 1" "id:4211479,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body diff --git a/src/test/resources/specs/phase3-features.yaml b/src/test/resources/specs/phase3-features.yaml index 342b064..ac51997 100644 --- a/src/test/resources/specs/phase3-features.yaml +++ b/src/test/resources/specs/phase3-features.yaml @@ -72,12 +72,20 @@ components: Thing: type: object required: [id, name, nickname] + maxProperties: 10 properties: id: type: integer readOnly: true name: type: string + score: + type: number + minimum: 0 + exclusiveMinimum: true + price: + type: integer + multipleOf: 100 nickname: type: string nullable: true From 8794c55d3594deec11282767537dbdde8b6630c3 Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 15:28:42 -0500 Subject: [PATCH 6/9] Extend schema.json generation: additionalProperties, multipleOf, bounds, nullable (Phase 5) Maps emit additionalProperties with their value schema, free-form objects emit additionalProperties true, multipleOf and min/maxProperties are emitted, exclusive bounds use the numeric exclusiveMinimum/Maximum form, nullable properties become [type, null] arrays, and required lists drop readOnly properties (mirroring the per-field rules). Also removes the postProcessModels override that repeatedly overwrote schema.json with partial single-model content before the combined write. Co-Authored-By: Claude Fable 5 --- .../modsecurity3/JsonSchemaGenerator.java | 61 +++++++++++- .../modsecurity3/Modsecurity3Generator.java | 35 +------ .../modsecurity3/tests/Phase5SchemaTest.java | 99 +++++++++++++++++++ .../golden/paramfeatures/coraza/schema.json | 3 +- .../paramfeatures/modsecurity3/schema.json | 3 +- 5 files changed, 165 insertions(+), 36 deletions(-) create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase5SchemaTest.java diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java index 880d2bf..7dbf5e8 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java @@ -112,6 +112,14 @@ public ObjectNode generateModelSchema(CodegenModel model) { // Set type to object schemaNode.put("type", "object"); + // Model-level property counts + if (model.getMinProperties() != null) { + schemaNode.put("minProperties", model.getMinProperties()); + } + if (model.getMaxProperties() != null) { + schemaNode.put("maxProperties", model.getMaxProperties()); + } + // Process properties if (model.vars != null && !model.vars.isEmpty()) { processProperties(model.vars, schemaNode); @@ -219,6 +227,25 @@ private void processProperties(List vars, ObjectNode schemaNode String name = var.name; ObjectNode property = properties.putObject(name); + // Maps and free-form objects: emit additionalProperties instead of a + // scalar type wrongly derived from the container dataType + if (var.isMap || var.isFreeFormObject) { + property.put("type", "object"); + if (var.isMap && var.items != null) { + ObjectNode valueSchema = property.putObject("additionalProperties"); + setItemType(var, valueSchema); + processValidationConstraints(var.items, valueSchema); + addEnumValues(var.items, valueSchema); + } else { + property.put("additionalProperties", true); + } + if (var.description != null && !var.description.isEmpty()) { + property.put("description", var.description); + } + processValidationConstraints(var, property); + continue; + } + // Set property type setPropertyType(var, property); @@ -260,6 +287,12 @@ private void processProperties(List vars, ObjectNode schemaNode } } + // Nullable: JSON null must validate (type arrays are core JSON Schema) + if (var.isNullable && property.has("type") && property.get("type").isTextual()) { + String baseType = property.get("type").asText(); + property.putArray("type").add(baseType).add("null"); + } + // Handle enums addEnumValues(var, property); } @@ -274,8 +307,15 @@ private void processProperties(List vars, ObjectNode schemaNode private void processRequiredProperties(List requiredVars, ObjectNode schemaNode) { ArrayNode required = schemaNode.putArray("required"); for (CodegenProperty var : requiredVars) { + // readOnly properties may legally be omitted from requests + if (var.isReadOnly) { + continue; + } required.add(var.name); } + if (required.isEmpty()) { + schemaNode.remove("required"); + } } /** @@ -361,10 +401,11 @@ private void setPrimitiveType(String type, ObjectNode node) { * @param property The property node to add constraints to */ private void processValidationConstraints(CodegenProperty var, ObjectNode property) { - // Minimum value + // Minimum value (numeric exclusiveMinimum form when the bound is exclusive) if (var.minimum != null) { try { - property.put("minimum", Double.parseDouble(var.minimum)); + property.put(var.exclusiveMinimum ? "exclusiveMinimum" : "minimum", + Double.parseDouble(var.minimum)); } catch (NumberFormatException e) { log.warn("Invalid minimum value: {}", var.minimum); } @@ -373,12 +414,26 @@ private void processValidationConstraints(CodegenProperty var, ObjectNode proper // Maximum value if (var.maximum != null) { try { - property.put("maximum", Double.parseDouble(var.maximum)); + property.put(var.exclusiveMaximum ? "exclusiveMaximum" : "maximum", + Double.parseDouble(var.maximum)); } catch (NumberFormatException e) { log.warn("Invalid maximum value: {}", var.maximum); } } + // multipleOf + if (var.multipleOf != null) { + property.put("multipleOf", new java.math.BigDecimal(var.multipleOf.toString())); + } + + // Object property counts + if (var.getMinProperties() != null) { + property.put("minProperties", var.getMinProperties()); + } + if (var.getMaxProperties() != null) { + property.put("maxProperties", var.getMaxProperties()); + } + // Minimum length if (var.getMinLength() != null) { property.put("minLength", var.getMinLength()); diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index 86fa422..0dff1b5 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -1149,37 +1149,10 @@ public String getHelp() { } /** - * Process models and generate JSON Schema. - * - * @param objs The models to process - * @return The processed models - */ - @Override - public ModelsMap postProcessModels(ModelsMap objs) { - ModelsMap result = super.postProcessModels(objs); - - try { - LOGGER.info("Generating JSON Schema from models..."); - JsonSchemaGenerator jsonSchemaGenerator = new JsonSchemaGenerator(); - String jsonSchema = jsonSchemaGenerator.generateJsonSchema(result); - - // Save the JSON Schema to a file - String outputPath = outputFolder + File.separator + "schema.json"; - try { - Files.write(Paths.get(outputPath), jsonSchema.getBytes()); - LOGGER.info("JSON Schema generated successfully: {}", outputPath); - } catch (IOException e) { - LOGGER.error("Error writing JSON Schema to file: {}", e.getMessage()); - } - } catch (Exception e) { - LOGGER.error("Error generating JSON Schema: {}", e.getMessage()); - } - - return result; - } - - /** - * Process all models and generate JSON Schema. + * Process all models and generate JSON Schema. (The per-ModelsMap + * postProcessModels hook is deliberately NOT overridden: it used to overwrite + * schema.json with partial single-model content on every call before + * postProcessAllModels wrote the combined file.) */ @Override public Map postProcessAllModels(Map objs) { diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase5SchemaTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase5SchemaTest.java new file mode 100644 index 0000000..a762589 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase5SchemaTest.java @@ -0,0 +1,99 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * schema.json generator coverage (Phase 5): additionalProperties, multipleOf, + * exclusive bounds, nullable type arrays, readOnly-aware required lists, and + * object property counts. + */ +public class Phase5SchemaTest { + + @TempDir + static Path outputDir; + private static JsonNode thing; + + @BeforeAll + static void generate() throws IOException { + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("src/test/resources/specs/phase3-features.yaml") + .setOutputDir(outputDir.toString()) + .toClientOptInput()) + .generate(); + JsonNode schema = new ObjectMapper().readTree(outputDir.resolve("schema.json").toFile()); + thing = schema.path("definitions").path("Thing"); + assertTrue(thing.isObject(), "Thing definition missing: " + schema); + } + + @Test + void mapPropertyEmitsAdditionalPropertiesWithValueSchema() { + JsonNode attrs = thing.path("properties").path("attrs"); + assertEquals("object", attrs.path("type").asText(), "attrs should be an object: " + attrs); + assertEquals("^[a-z]{1,20}$", attrs.path("additionalProperties").path("pattern").asText(), + "map value schema should carry the additionalProperties pattern: " + attrs); + } + + @Test + void freeFormObjectEmitsAdditionalPropertiesTrue() { + JsonNode misc = thing.path("properties").path("misc"); + assertTrue(misc.path("additionalProperties").asBoolean(false), + "free-form object should emit additionalProperties true: " + misc); + } + + @Test + void multipleOfIsEmitted() { + assertEquals(100, thing.path("properties").path("price").path("multipleOf").asInt(), + "multipleOf should be emitted: " + thing); + } + + @Test + void exclusiveBoundIsEmittedNumerically() { + JsonNode score = thing.path("properties").path("score"); + assertEquals(0.0, score.path("exclusiveMinimum").asDouble(-1), + "boolean exclusiveMinimum should become the numeric draft form: " + score); + assertFalse(score.has("minimum"), "minimum must not also be emitted: " + score); + } + + @Test + void nullablePropertyGetsTypeArray() { + JsonNode type = thing.path("properties").path("nickname").path("type"); + assertTrue(type.isArray(), "nullable property should have a type array: " + thing); + List types = new ArrayList<>(); + type.forEach(t -> types.add(t.asText())); + assertTrue(types.contains("string") && types.contains("null"), + "nullable string should be [string, null]: " + types); + } + + @Test + void requiredExcludesReadOnlyProperties() { + List required = new ArrayList<>(); + thing.path("required").forEach(r -> required.add(r.asText())); + assertFalse(required.contains("id"), "readOnly required property must be dropped: " + required); + assertTrue(required.contains("name") && required.contains("nickname"), + "other required properties must remain: " + required); + } + + @Test + void modelLevelMaxPropertiesIsEmitted() { + assertEquals(10, thing.path("maxProperties").asInt(), + "model-level maxProperties should be emitted: " + thing); + } +} diff --git a/src/test/resources/golden/paramfeatures/coraza/schema.json b/src/test/resources/golden/paramfeatures/coraza/schema.json index ac61137..4f92f21 100644 --- a/src/test/resources/golden/paramfeatures/coraza/schema.json +++ b/src/test/resources/golden/paramfeatures/coraza/schema.json @@ -9,7 +9,8 @@ "type" : "object", "properties" : { "price" : { - "type" : "integer" + "type" : "integer", + "multipleOf" : 100 }, "labels" : { "type" : "array", diff --git a/src/test/resources/golden/paramfeatures/modsecurity3/schema.json b/src/test/resources/golden/paramfeatures/modsecurity3/schema.json index ac61137..4f92f21 100644 --- a/src/test/resources/golden/paramfeatures/modsecurity3/schema.json +++ b/src/test/resources/golden/paramfeatures/modsecurity3/schema.json @@ -9,7 +9,8 @@ "type" : "object", "properties" : { "price" : { - "type" : "integer" + "type" : "integer", + "multipleOf" : 100 }, "labels" : { "type" : "array", From be72d14968ff9ea0f01d9453ccbda232aaab99b4 Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 15:31:58 -0500 Subject: [PATCH 7/9] Add XSD generation behind validateXmlSchema option (Phase 6) New XsdGenerator emits an XML Schema from the models (elements, attributes, wrapped arrays, and simple-type facets: pattern, lengths, bounds, enumerations), validated in tests via JAXP accept/reject round-trips. The @validateSchema XML rule and schema.xsd are gated behind the new validateXmlSchema option, DEFAULT FALSE: container probes showed current libmodsecurity3 cannot load XSDs at request time (its XXE hardening disables libxml2's entity loader, so the operator fails open and blocks ALL XML including valid documents) and Coraza rejects XML rules at config load. Documented in docs/engine-behavior.md; the XSD remains useful for application-side validation and for engine builds where the loader works. Co-Authored-By: Claude Fable 5 --- docs/configuration.md | 3 + samples/xmlbody.yaml | 50 ++++ .../modsecurity3/Modsecurity3Generator.java | 56 +++++ .../generators/modsecurity3/XsdGenerator.java | 229 ++++++++++++++++++ .../resources/modsecurity3/config.mustache | 9 +- .../modsecurity3/tests/GoldenFileTest.java | 2 +- .../modsecurity3/tests/XsdGeneratorTest.java | 139 +++++++++++ .../integration/util/RuleGenerationUtil.java | 5 +- .../golden/petstore/coraza/PetApi.conf | 4 - .../golden/petstore/modsecurity3/PetApi.conf | 4 - .../golden/urlintparam/coraza/PetApi.conf | 4 - .../urlintparam/modsecurity3/PetApi.conf | 4 - .../golden/xmlbody/coraza/DefaultApi.conf | 33 +++ .../golden/xmlbody/coraza/mainconfig.conf | 20 ++ .../golden/xmlbody/coraza/schema.json | 40 +++ .../xmlbody/modsecurity3/DefaultApi.conf | 33 +++ .../xmlbody/modsecurity3/mainconfig.conf | 20 ++ .../golden/xmlbody/modsecurity3/schema.json | 40 +++ 18 files changed, 674 insertions(+), 21 deletions(-) create mode 100644 samples/xmlbody.yaml create mode 100644 src/main/java/com/oashield/openapi/generators/modsecurity3/XsdGenerator.java create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/XsdGeneratorTest.java create mode 100644 src/test/resources/golden/xmlbody/coraza/DefaultApi.conf create mode 100644 src/test/resources/golden/xmlbody/coraza/mainconfig.conf create mode 100644 src/test/resources/golden/xmlbody/coraza/schema.json create mode 100644 src/test/resources/golden/xmlbody/modsecurity3/DefaultApi.conf create mode 100644 src/test/resources/golden/xmlbody/modsecurity3/mainconfig.conf create mode 100644 src/test/resources/golden/xmlbody/modsecurity3/schema.json diff --git a/docs/configuration.md b/docs/configuration.md index 034bfc6..3ed3ef6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,6 +20,9 @@ the known limitations of request-body validation. | `includeEngineConfig` | `true` | Emit `SecRuleEngine On`, `SecRequestBodyAccess On`, and the `SecDefaultAction` in `mainconfig.conf`. Set `false` when your existing WAF configuration already defines these | | `unknownMediaTypePolicy` | `pass` | Handling of declared request media types the WAF cannot inspect (e.g. `application/octet-stream`, `text/plain`): `pass` lets them through after the content-type gate, `block` rejects them | | `basePath` | auto | Path prefix for all generated path-match rules. Defaults to the path component of the spec's first `servers.url` (server variables match one path segment). Pass an empty string to disable prefixing | +| `validateXmlSchema` | `false` | Generate an XSD from the models and emit `@validateSchema` XML rules (modsecurity3 flavor only). Off by default because current libmodsecurity3 cannot load XSDs at request time (its XXE hardening breaks the schema load, blocking all XML) and Coraza has no XML support — see `docs/engine-behavior.md` | +| `xsdOutputFile` | `schema.xsd` | XSD output file name | +| `xsdRulePath` | same as `xsdOutputFile` | XSD path written inside the `@validateSchema` XML rule | Pass them comma-separated: diff --git a/samples/xmlbody.yaml b/samples/xmlbody.yaml new file mode 100644 index 0000000..9cbc8ef --- /dev/null +++ b/samples/xmlbody.yaml @@ -0,0 +1,50 @@ +openapi: 3.0.3 +info: + title: XML request bodies + version: "1.0" +paths: + /pets: + post: + operationId: createPet + requestBody: + required: true + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + responses: + "200": + description: ok +components: + schemas: + Pet: + type: object + required: [name] + xml: + name: pet + properties: + id: + type: integer + format: int64 + xml: + attribute: true + name: + type: string + minLength: 1 + maxLength: 30 + pattern: '^[A-Za-z ]+$' + status: + type: string + enum: [available, pending, sold] + weight: + type: number + minimum: 0 + tags: + type: array + maxItems: 5 + xml: + wrapped: true + items: + type: string + xml: + name: tag diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index 0dff1b5..337ce77 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -68,6 +68,12 @@ public void setOutputDir(String dir) { // Deployed base path prefix for path-match regexes; null = auto-extract from // the first servers.url, "" = no prefix. private String basePathOverride = null; + // XSD generation + @validateSchema XML rules. Default OFF: libmodsecurity3 + // currently cannot load XSDs at request time (fails open to match-everything) + // and Coraza has no XML support — see docs/engine-behavior.md. + private boolean validateXmlSchema = false; + public String xsdOutputFile = "schema.xsd"; + private String xsdRulePath = null; // false = emit no SecRuleEngine/SecRequestBodyAccess/SecDefaultAction, for // deployments whose existing ModSecurity config already sets them private boolean includeEngineConfig = true; @@ -176,6 +182,19 @@ public void processOpts() { LOGGER.info("basePath set to: '{}'", basePathOverride); } + if (additionalProperties.containsKey("validateXmlSchema")) { + validateXmlSchema = Boolean.parseBoolean(additionalProperties.get("validateXmlSchema").toString()); + LOGGER.info("validateXmlSchema set to: {}", validateXmlSchema); + } + additionalProperties.put("validateXmlSchema", validateXmlSchema); + if (additionalProperties.containsKey("xsdOutputFile")) { + xsdOutputFile = additionalProperties.get("xsdOutputFile").toString(); + } + if (additionalProperties.containsKey("xsdRulePath")) { + xsdRulePath = additionalProperties.get("xsdRulePath").toString(); + } + additionalProperties.put("xsdRulePath", xsdRulePath != null ? xsdRulePath : xsdOutputFile); + // Real boolean for the mustache section; derived strings so templates stay flat additionalProperties.put("includeEngineConfig", includeEngineConfig); additionalProperties.put("logAction", enableLogging ? "log,auditlog" : "nolog"); @@ -1189,9 +1208,34 @@ public Map postProcessAllModels(Map objs) generateJsonSchema(result); } + if (validateXmlSchema) { + generateXmlSchema(result); + } + return result; } + /** + * Generate the XSD from models (only when validateXmlSchema is enabled). + */ + private void generateXmlSchema(Map models) { + LOGGER.info("Generating XSD from models..."); + try { + String xsd = new XsdGenerator().generateXsd(models); + File outputDir = new File(outputFolder); + if (!outputDir.exists()) { + outputDir.mkdirs(); + } + File xsdFile = new File(outputDir, xsdOutputFile); + try (FileWriter writer = new FileWriter(xsdFile)) { + writer.write(xsd); + } + LOGGER.info("XSD generated successfully: {}", xsdFile.getAbsolutePath()); + } catch (Exception e) { + LOGGER.error("Error generating XSD", e); + } + } + /** * Generate JSON Schema from models. * @@ -1334,6 +1378,18 @@ public Modsecurity3Generator() { cliOptions.add(new CliOption("basePath", "Base path prefix for all generated path-match rules; defaults to the path " + "component of the first servers.url, use an empty string to disable")); + additionalProperties.put("validateXmlSchema", false); + additionalProperties.put("xsdRulePath", xsdOutputFile); + cliOptions.add(new CliOption("validateXmlSchema", + "Generate an XSD from the models and emit @validateSchema XML rules " + + "(modsecurity3 flavor). Default false: current libmodsecurity3 cannot " + + "load XSDs at request time and Coraza has no XML support") + .defaultValue(Boolean.toString(validateXmlSchema))); + cliOptions.add(new CliOption("xsdOutputFile", "XSD output file name") + .defaultValue(xsdOutputFile)); + cliOptions.add(new CliOption("xsdRulePath", + "XSD path as referenced from the generated @validateSchema XML rule") + .defaultValue(xsdOutputFile)); /** * Supporting Files. You can write single files for the generator with the diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/XsdGenerator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/XsdGenerator.java new file mode 100644 index 0000000..1ad644d --- /dev/null +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/XsdGenerator.java @@ -0,0 +1,229 @@ +package com.oashield.openapi.generators.modsecurity3; + +import java.io.StringWriter; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import org.openapitools.codegen.CodegenModel; +import org.openapitools.codegen.CodegenProperty; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import lombok.extern.slf4j.Slf4j; + +/** + * Generates an XML Schema (XSD) from OpenAPI models, mirroring + * {@link JsonSchemaGenerator}'s role for JSON bodies. Scope: elements, + * attributes, and simple-type facets (pattern, lengths, bounds, enumerations); + * composed schemas and map types in XML are out of scope. + * + * NOTE: current libmodsecurity3 cannot load XSDs at request time (its XXE + * hardening disables libxml2's entity loader, so @validateSchema fails open to + * "match everything") and Coraza has no XML support at all — see + * docs/engine-behavior.md. The generated XSD is therefore only wired into rules + * behind the validateXmlSchema option (default false); it remains useful for + * upstream/application-side validation. + */ +@Slf4j +public class XsdGenerator { + + private static final String XS_NS = "http://www.w3.org/2001/XMLSchema"; + + /** + * Generate one XSD containing a named complexType per model plus a top-level + * element per model, so any model can be an XML document root. + */ + public String generateXsd(Map models) { + try { + Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + Element schema = doc.createElementNS(XS_NS, "xs:schema"); + doc.appendChild(schema); + + for (Map.Entry entry : models.entrySet()) { + if (entry.getValue().getModels() == null || entry.getValue().getModels().isEmpty()) { + continue; + } + ModelMap modelMap = entry.getValue().getModels().get(0); + CodegenModel model = modelMap.getModel(); + if (model == null) { + continue; + } + addModel(doc, schema, entry.getKey(), model); + } + + StringWriter writer = new StringWriter(); + TransformerFactory tf = TransformerFactory.newInstance(); + tf.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD, ""); + tf.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + javax.xml.transform.Transformer t = tf.newTransformer(); + t.setOutputProperty(OutputKeys.INDENT, "yes"); + t.transform(new DOMSource(doc), new StreamResult(writer)); + return writer.toString(); + } catch (Exception e) { + log.error("Error generating XSD", e); + return ""; + } + } + + private void addModel(Document doc, Element schema, String modelName, CodegenModel model) { + String rootName = model.getXmlName() != null ? model.getXmlName() : modelName; + + Element complexType = doc.createElementNS(XS_NS, "xs:complexType"); + complexType.setAttribute("name", modelName); + Element sequence = doc.createElementNS(XS_NS, "xs:sequence"); + complexType.appendChild(sequence); + + if (model.vars != null) { + for (CodegenProperty var : model.vars) { + addProperty(doc, sequence, complexType, var); + } + } + schema.appendChild(complexType); + + Element rootElement = doc.createElementNS(XS_NS, "xs:element"); + rootElement.setAttribute("name", rootName); + rootElement.setAttribute("type", modelName); + schema.appendChild(rootElement); + } + + private void addProperty(Document doc, Element sequence, Element complexType, CodegenProperty var) { + String name = var.getXmlName() != null ? var.getXmlName() : var.baseName; + + if (var.isXmlAttribute) { + Element attr = doc.createElementNS(XS_NS, "xs:attribute"); + attr.setAttribute("name", name); + attr.setAttribute("type", xsdType(var)); + if (var.required) { + attr.setAttribute("use", "required"); + } + // xs:attribute children must follow the content model inside xs:complexType + complexType.appendChild(attr); + return; + } + + if (var.isArray) { + CodegenProperty item = var.items; + String itemName = item != null && item.getXmlName() != null ? item.getXmlName() : name; + Element repeated = doc.createElementNS(XS_NS, "xs:element"); + repeated.setAttribute("name", itemName); + applyOccurs(repeated, var); + typeOrRestriction(doc, repeated, item != null ? item : var); + + if (var.isXmlWrapped) { + Element wrapper = doc.createElementNS(XS_NS, "xs:element"); + wrapper.setAttribute("name", name); + wrapper.setAttribute("minOccurs", var.required ? "1" : "0"); + Element wrapperType = doc.createElementNS(XS_NS, "xs:complexType"); + Element wrapperSeq = doc.createElementNS(XS_NS, "xs:sequence"); + wrapperSeq.appendChild(repeated); + wrapperType.appendChild(wrapperSeq); + wrapper.appendChild(wrapperType); + sequence.appendChild(wrapper); + } else { + sequence.appendChild(repeated); + } + return; + } + + Element element = doc.createElementNS(XS_NS, "xs:element"); + element.setAttribute("name", name); + element.setAttribute("minOccurs", var.required ? "1" : "0"); + typeOrRestriction(doc, element, var); + sequence.appendChild(element); + } + + private void applyOccurs(Element repeated, CodegenProperty arrayVar) { + Integer min = arrayVar.getMinItems(); + Integer max = arrayVar.getMaxItems(); + repeated.setAttribute("minOccurs", + min != null ? min.toString() : (arrayVar.required && !arrayVar.isXmlWrapped ? "1" : "0")); + repeated.setAttribute("maxOccurs", max != null ? max.toString() : "unbounded"); + } + + /** + * Set the element's type: a reference for model properties, an inline + * restriction when facets exist, otherwise a plain built-in type. + */ + private void typeOrRestriction(Document doc, Element element, CodegenProperty var) { + if (var.isModel && var.complexType != null) { + element.setAttribute("type", var.complexType); + return; + } + + boolean hasFacets = var.pattern != null || var.getMinLength() != null || var.getMaxLength() != null + || var.minimum != null || var.maximum != null + || (var.allowableValues != null && var.allowableValues.get("values") instanceof List); + if (!hasFacets) { + element.setAttribute("type", xsdType(var)); + return; + } + + Element simpleType = doc.createElementNS(XS_NS, "xs:simpleType"); + Element restriction = doc.createElementNS(XS_NS, "xs:restriction"); + restriction.setAttribute("base", xsdType(var)); + + String pattern = Modsecurity3Generator.sanitizeSpecPattern(var.pattern); + if (pattern != null && !pattern.isEmpty()) { + // XSD patterns are implicitly anchored + addFacet(doc, restriction, "xs:pattern", "value", Modsecurity3Generator.stripAnchors(pattern)); + } + if (var.getMinLength() != null) { + addFacet(doc, restriction, "xs:minLength", "value", var.getMinLength().toString()); + } + if (var.getMaxLength() != null) { + addFacet(doc, restriction, "xs:maxLength", "value", var.getMaxLength().toString()); + } + if (var.minimum != null) { + addFacet(doc, restriction, var.exclusiveMinimum ? "xs:minExclusive" : "xs:minInclusive", + "value", var.minimum); + } + if (var.maximum != null) { + addFacet(doc, restriction, var.exclusiveMaximum ? "xs:maxExclusive" : "xs:maxInclusive", + "value", var.maximum); + } + if (var.allowableValues != null && var.allowableValues.get("values") instanceof List) { + for (Object value : (List) var.allowableValues.get("values")) { + addFacet(doc, restriction, "xs:enumeration", "value", String.valueOf(value)); + } + } + + simpleType.appendChild(restriction); + element.appendChild(simpleType); + } + + private void addFacet(Document doc, Element restriction, String facet, String attr, String value) { + Element el = doc.createElementNS(XS_NS, facet); + el.setAttribute(attr, value); + restriction.appendChild(el); + } + + private String xsdType(CodegenProperty var) { + if (var.isInteger) { + return "xs:int"; + } + if (var.isLong) { + return "xs:long"; + } + if (var.isNumber || var.isFloat || var.isDouble || var.isDecimal) { + return "xs:decimal"; + } + if (var.isBoolean) { + return "xs:boolean"; + } + if (var.isDate) { + return "xs:date"; + } + if (var.isDateTime) { + return "xs:dateTime"; + } + return "xs:string"; + } +} diff --git a/src/main/resources/modsecurity3/config.mustache b/src/main/resources/modsecurity3/config.mustache index 7a6163b..a1f3231 100644 --- a/src/main/resources/modsecurity3/config.mustache +++ b/src/main/resources/modsecurity3/config.mustache @@ -121,8 +121,13 @@ SecMarker ENDMEDIA_{{operationId}}_{{consumeIndex}} {{#isXml}} SecRule REQUEST_HEADERS:Content-Type "!@rx ^{{mediaTypeRegex}}" "id:{{oasGateId}},phase:2,pass,nolog,skipAfter:ENDMEDIA_{{operationId}}_{{consumeIndex}}" SecRule REQBODY_ERROR "!@eq 0" "id:{{oasBodyErrId}},phase:2,block,msg:'Failed to parse request body',{{logAction}},skipAfter:FAILED_API_CHECKS" - -# TODO: Implement XML schema validation (issue #14 covers JSON objects) +{{#validateXmlSchema}} +{{#isModsec3}} +# XSD validation is opt-in: current libmodsecurity3 fails to load XSDs at request +# time (docs/engine-behavior.md); enable only on an engine build where it works. +SecRule XML "@validateSchema {{xsdRulePath}}" "id:{{oasSchemaId}},phase:2,block,msg:'XML schema validation failed for {{operationId}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{/isModsec3}} +{{/validateXmlSchema}} SecAction "id:{{oasPassId}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" SecMarker ENDMEDIA_{{operationId}}_{{consumeIndex}} diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java index 0bbfa0f..6312f71 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java @@ -30,7 +30,7 @@ public class GoldenFileTest { private static final Path GOLDEN_ROOT = Paths.get("src/test/resources/golden"); private static final List SAMPLES = List.of( - "petstore", "composed", "getparam", "urlintparam", "multipart", "paramfeatures"); + "petstore", "composed", "getparam", "urlintparam", "multipart", "paramfeatures", "xmlbody"); private static final List FLAVORS = List.of("modsecurity3", "coraza"); static Stream cases() { diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/XsdGeneratorTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/XsdGeneratorTest.java new file mode 100644 index 0000000..c68f586 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/XsdGeneratorTest.java @@ -0,0 +1,139 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.xml.XMLConstants; +import javax.xml.transform.stream.StreamSource; +import javax.xml.validation.Schema; +import javax.xml.validation.SchemaFactory; +import javax.xml.validation.Validator; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; +import org.xml.sax.SAXException; + +/** + * XSD generation (Phase 6): the generated schema.xsd must be a valid XML Schema + * (parsed with JAXP) that accepts conforming documents and rejects violations, + * and the @validateSchema XML rule is emitted only when validateXmlSchema=true. + */ +public class XsdGeneratorTest { + + @TempDir + static Path outputDir; + private static String xsd; + private static String conf; + + @BeforeAll + static void generate() throws IOException { + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("samples/xmlbody.yaml") + .setOutputDir(outputDir.toString()) + .addAdditionalProperty("validateXmlSchema", "true") + .toClientOptInput()) + .generate(); + xsd = Files.readString(outputDir.resolve("schema.xsd")); + try (Stream files = Files.list(outputDir)) { + conf = files.filter(p -> p.getFileName().toString().endsWith("Api.conf")) + .map(p -> { + try { + return Files.readString(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.joining("\n")); + } + } + + private static Validator validator() throws SAXException { + Schema schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI) + .newSchema(new StreamSource(new StringReader(xsd))); + return schema.newValidator(); + } + + private static void validate(String xml) throws SAXException, IOException { + validator().validate(new StreamSource(new StringReader(xml))); + } + + @Test + void generatedXsdIsAValidSchema() throws SAXException { + validator(); + } + + @Test + void conformingDocumentValidates() throws SAXException, IOException { + validate("Rexavailable" + + "4.2smallbrown"); + } + + @Test + void minimalRequiredDocumentValidates() throws SAXException, IOException { + validate("Rex"); + } + + @Test + void missingRequiredElementIsRejected() { + assertThrows(SAXException.class, () -> validate("available")); + } + + @Test + void patternViolationIsRejected() { + assertThrows(SAXException.class, () -> validate("R3x!")); + } + + @Test + void enumViolationIsRejected() { + assertThrows(SAXException.class, + () -> validate("Rexhibernating")); + } + + @Test + void nonIntegerAttributeIsRejected() { + assertThrows(SAXException.class, () -> validate("Rex")); + } + + @Test + void validateSchemaRuleEmittedOnlyWhenEnabled(@TempDir Path defaultOut) throws IOException { + assertTrue(conf.contains("SecRule XML \"@validateSchema schema.xsd\""), + "opt-in run should emit the XML @validateSchema rule:\n" + conf); + + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("samples/xmlbody.yaml") + .setOutputDir(defaultOut.toString()) + .toClientOptInput()) + .generate(); + String defaultConf; + try (Stream files = Files.list(defaultOut)) { + defaultConf = files.filter(p -> p.getFileName().toString().endsWith("Api.conf")) + .map(p -> { + try { + return Files.readString(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.joining("\n")); + } + assertFalse(defaultConf.contains("@validateSchema schema.xsd"), + "default run must not emit XML @validateSchema (engine cannot load XSDs):\n" + defaultConf); + assertFalse(Files.exists(defaultOut.resolve("schema.xsd")), + "default run must not write schema.xsd"); + } +} diff --git a/src/test/java/com/oashield/openapi/integration/util/RuleGenerationUtil.java b/src/test/java/com/oashield/openapi/integration/util/RuleGenerationUtil.java index 6894acb..c5cc889 100644 --- a/src/test/java/com/oashield/openapi/integration/util/RuleGenerationUtil.java +++ b/src/test/java/com/oashield/openapi/integration/util/RuleGenerationUtil.java @@ -98,9 +98,10 @@ public static String generateRules(String openApiSpecPath, String outputDir, boo Files.createDirectories(Paths.get(schemasDir)); String rulesDirPath = outputDir + "/rules"; - // Copy all JSON schema files to the schemas directory + // Copy all schema files (JSON Schema + any generated XSD) to the + // schemas directory Files.list(Paths.get(outputDir)) - .filter(path -> path.toString().endsWith(".json")) + .filter(path -> path.toString().endsWith(".json") || path.toString().endsWith(".xsd")) .forEach(src -> { try { Path dest = Paths.get(schemasDir, src.getFileName().toString()); diff --git a/src/test/resources/golden/petstore/coraza/PetApi.conf b/src/test/resources/golden/petstore/coraza/PetApi.conf index d7b89f6..d4cf987 100644 --- a/src/test/resources/golden/petstore/coraza/PetApi.conf +++ b/src/test/resources/golden/petstore/coraza/PetApi.conf @@ -34,8 +34,6 @@ SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" SecMarker ENDMEDIA_addPet_0 SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210005,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_1" SecRule REQBODY_ERROR "!@eq 0" "id:4210006,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" - -# TODO: Implement XML schema validation (issue #14 covers JSON objects) SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" SecMarker ENDMEDIA_addPet_1 @@ -209,8 +207,6 @@ SecAction "id:4210348,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" SecMarker ENDMEDIA_updatePet_0 SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210349,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_1" SecRule REQBODY_ERROR "!@eq 0" "id:4210350,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" - -# TODO: Implement XML schema validation (issue #14 covers JSON objects) SecAction "id:4210352,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" SecMarker ENDMEDIA_updatePet_1 diff --git a/src/test/resources/golden/petstore/modsecurity3/PetApi.conf b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf index 5ad4d95..f613d7a 100644 --- a/src/test/resources/golden/petstore/modsecurity3/PetApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf @@ -31,8 +31,6 @@ SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" SecMarker ENDMEDIA_addPet_0 SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210005,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_1" SecRule REQBODY_ERROR "!@eq 0" "id:4210006,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" - -# TODO: Implement XML schema validation (issue #14 covers JSON objects) SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" SecMarker ENDMEDIA_addPet_1 @@ -203,8 +201,6 @@ SecAction "id:4210348,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" SecMarker ENDMEDIA_updatePet_0 SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210349,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_1" SecRule REQBODY_ERROR "!@eq 0" "id:4210350,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" - -# TODO: Implement XML schema validation (issue #14 covers JSON objects) SecAction "id:4210352,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" SecMarker ENDMEDIA_updatePet_1 diff --git a/src/test/resources/golden/urlintparam/coraza/PetApi.conf b/src/test/resources/golden/urlintparam/coraza/PetApi.conf index b972a6a..61b94d1 100644 --- a/src/test/resources/golden/urlintparam/coraza/PetApi.conf +++ b/src/test/resources/golden/urlintparam/coraza/PetApi.conf @@ -34,8 +34,6 @@ SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" SecMarker ENDMEDIA_addPet_0 SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210005,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_1" SecRule REQBODY_ERROR "!@eq 0" "id:4210006,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" - -# TODO: Implement XML schema validation (issue #14 covers JSON objects) SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" SecMarker ENDMEDIA_addPet_1 @@ -117,8 +115,6 @@ SecAction "id:4210188,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" SecMarker ENDMEDIA_updatePet_0 SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210189,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_1" SecRule REQBODY_ERROR "!@eq 0" "id:4210190,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" - -# TODO: Implement XML schema validation (issue #14 covers JSON objects) SecAction "id:4210192,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" SecMarker ENDMEDIA_updatePet_1 diff --git a/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf b/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf index 71af0b5..1d94580 100644 --- a/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf +++ b/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf @@ -31,8 +31,6 @@ SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" SecMarker ENDMEDIA_addPet_0 SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210005,phase:2,pass,nolog,skipAfter:ENDMEDIA_addPet_1" SecRule REQBODY_ERROR "!@eq 0" "id:4210006,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" - -# TODO: Implement XML schema validation (issue #14 covers JSON objects) SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" SecMarker ENDMEDIA_addPet_1 @@ -111,8 +109,6 @@ SecAction "id:4210188,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" SecMarker ENDMEDIA_updatePet_0 SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210189,phase:2,pass,nolog,skipAfter:ENDMEDIA_updatePet_1" SecRule REQBODY_ERROR "!@eq 0" "id:4210190,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" - -# TODO: Implement XML schema validation (issue #14 covers JSON objects) SecAction "id:4210192,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" SecMarker ENDMEDIA_updatePet_1 diff --git a/src/test/resources/golden/xmlbody/coraza/DefaultApi.conf b/src/test/resources/golden/xmlbody/coraza/DefaultApi.conf new file mode 100644 index 0000000..cbc496f --- /dev/null +++ b/src/test/resources/golden/xmlbody/coraza/DefaultApi.conf @@ -0,0 +1,33 @@ + +# createPet: POST /pets +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pets$" "id:4200002,phase:2,pass,nolog,skipAfter:END_createPet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_createPet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.name|json\.status|json\.weight|json\.tags|json\.tags\.(?:array_)?\d{1,9})$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_createPet_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createPet" + +SecMarker ENDMEDIA_createPet_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_createPet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_createPet diff --git a/src/test/resources/golden/xmlbody/coraza/mainconfig.conf b/src/test/resources/golden/xmlbody/coraza/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/xmlbody/coraza/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/xmlbody/coraza/schema.json b/src/test/resources/golden/xmlbody/coraza/schema.json new file mode 100644 index 0000000..55a5671 --- /dev/null +++ b/src/test/resources/golden/xmlbody/coraza/schema.json @@ -0,0 +1,40 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "Pet" : { + "title" : "Pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string", + "minLength" : 1, + "maxLength" : 30, + "pattern" : "^[A-Za-z ]+$" + }, + "status" : { + "type" : "string", + "enum" : [ "available", "pending", "sold" ] + }, + "weight" : { + "type" : "number", + "minimum" : 0.0 + }, + "tags" : { + "type" : "array", + "items" : { + "type" : "string" + }, + "maxItems" : 5 + } + }, + "required" : [ "name" ] + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/xmlbody/modsecurity3/DefaultApi.conf b/src/test/resources/golden/xmlbody/modsecurity3/DefaultApi.conf new file mode 100644 index 0000000..cbc496f --- /dev/null +++ b/src/test/resources/golden/xmlbody/modsecurity3/DefaultApi.conf @@ -0,0 +1,33 @@ + +# createPet: POST /pets +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/pets$" "id:4200002,phase:2,pass,nolog,skipAfter:END_createPet" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_createPet" + + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.id|json\.name|json\.status|json\.weight|json\.tags|json\.tags\.(?:array_)?\d{1,9})$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/xml" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_createPet_0" +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createPet" + +SecMarker ENDMEDIA_createPet_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_createPet + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_createPet diff --git a/src/test/resources/golden/xmlbody/modsecurity3/mainconfig.conf b/src/test/resources/golden/xmlbody/modsecurity3/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/xmlbody/modsecurity3/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/xmlbody/modsecurity3/schema.json b/src/test/resources/golden/xmlbody/modsecurity3/schema.json new file mode 100644 index 0000000..55a5671 --- /dev/null +++ b/src/test/resources/golden/xmlbody/modsecurity3/schema.json @@ -0,0 +1,40 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "Pet" : { + "title" : "Pet", + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string", + "minLength" : 1, + "maxLength" : 30, + "pattern" : "^[A-Za-z ]+$" + }, + "status" : { + "type" : "string", + "enum" : [ "available", "pending", "sold" ] + }, + "weight" : { + "type" : "number", + "minimum" : 0.0 + }, + "tags" : { + "type" : "array", + "items" : { + "type" : "string" + }, + "maxItems" : 5 + } + }, + "required" : [ "name" ] + } + } +} \ No newline at end of file From f9f24dbc5acab50fd733868fedb2cb66810935b0 Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 15:47:32 -0500 Subject: [PATCH 8/9] Cover the OpenAPI 3.1 long tail: const, patternProperties, dependentRequired, content params (Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw-spec keyword pass: the generator lazily re-parses the original document (openapi-generator's normalizer strips 3.1 keywords like prefixItems from the in-memory model) and walks it along flattened body paths. Per-field rules: const becomes an exact-match value rule (overriding inferred nullability); dependentRequired at the body root becomes chained presence rules; patternProperties replaces the free-form wildcard with name-scoped allowlist entries plus typed value rules; content: parameters get a bounded length cap (clamped to RE2's repeat-count limit of 1000). schema.json: const, prefixItems, patternProperties, dependentRequired, dependentSchemas, if/then/else, contains, propertyNames are copied verbatim from the raw spec (refs rewritten to #/definitions), staying on draft-07 — container probes confirmed Coraza enforces all of them under that $schema. Also fixes chained rules to carry a t:none action (libmodsecurity rejects actionless chain members — latent bug in the nested-required chain too) and adds samples/oas31.yaml with a both-engines Cucumber scenario. Co-Authored-By: Claude Fable 5 --- docs/engine-behavior.md | 19 +- samples/oas31.yaml | 58 ++++++ .../modsecurity3/JsonSchemaGenerator.java | 74 +++++++ .../modsecurity3/Modsecurity3Generator.java | 190 +++++++++++++++++- .../resources/modsecurity3/config.mustache | 9 +- .../modsecurity3/tests/GoldenFileTest.java | 2 +- .../tests/Phase7LongTailTest.java | 104 ++++++++++ .../modsecurity_rule_generation.feature | 18 ++ .../golden/oas31/coraza/DefaultApi.conf | 49 +++++ .../golden/oas31/coraza/mainconfig.conf | 20 ++ .../resources/golden/oas31/coraza/schema.json | 61 ++++++ .../golden/oas31/modsecurity3/DefaultApi.conf | 46 +++++ .../golden/oas31/modsecurity3/mainconfig.conf | 20 ++ .../golden/oas31/modsecurity3/schema.json | 61 ++++++ 14 files changed, 723 insertions(+), 8 deletions(-) create mode 100644 samples/oas31.yaml create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase7LongTailTest.java create mode 100644 src/test/resources/golden/oas31/coraza/DefaultApi.conf create mode 100644 src/test/resources/golden/oas31/coraza/mainconfig.conf create mode 100644 src/test/resources/golden/oas31/coraza/schema.json create mode 100644 src/test/resources/golden/oas31/modsecurity3/DefaultApi.conf create mode 100644 src/test/resources/golden/oas31/modsecurity3/mainconfig.conf create mode 100644 src/test/resources/golden/oas31/modsecurity3/schema.json diff --git a/docs/engine-behavior.md b/docs/engine-behavior.md index 42437a6..af9d8be 100644 --- a/docs/engine-behavior.md +++ b/docs/engine-behavior.md @@ -46,10 +46,21 @@ Generator design decisions reference this file. Last run: 2026-07-04 against ## Coraza `@validateSchema` (JSON Schema) Coraza's validator honors modern keywords regardless of the declared `$schema` -draft. Verified enforced: `const`, `dependentRequired`, `if`/`then`/`else` -(under draft-07 *and* 2020-12), `prefixItems` (2020-12). Verified **ignored**: -legacy draft-07 `dependencies` — always emit `dependentRequired`, never -`dependencies`. +draft. Verified enforced under a draft-07 `$schema`: `const`, +`dependentRequired`, `if`/`then`/`else`, `prefixItems`, `patternProperties`, +`propertyNames`. Verified **ignored**: legacy draft-07 `dependencies` — always +emit `dependentRequired`, never `dependencies`. schema.json therefore stays +draft-07 with modern keywords copied verbatim from the raw spec. + +Note: openapi-generator's normalizer rewrites the parsed spec in place (it +drops 3.1 `prefixItems`, among others), so raw keyword lookups re-parse the +original document (`Modsecurity3Generator.rawOpenAPI()`). + +Per-field (SecRule) coverage of the long tail: `const` → exact-match value +rule; `dependentRequired` (body root) → chained presence rules; +`patternProperties` → name-scoped allowlist entries + typed value rules. +`prefixItems`, `contains`, `if`/`then`/`else`, `propertyNames`, +`unevaluatedProperties` are schema.json-only (Coraza). ModSecurity3 has no JSON `@validateSchema` (XSD only), which is why the modsecurity3 flavor relies on per-field rules. diff --git a/samples/oas31.yaml b/samples/oas31.yaml new file mode 100644 index 0000000..2289a46 --- /dev/null +++ b/samples/oas31.yaml @@ -0,0 +1,58 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1 long-tail features + description: const, prefixItems, patternProperties, dependentRequired, if/then/else, content params + version: "1.0" +paths: + /events: + post: + operationId: createEvent + parameters: + - name: meta + in: query + content: + application/json: + schema: + type: object + maxLength: 512 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Event' + responses: + "200": + description: ok +components: + schemas: + Event: + type: object + required: [kind] + dependentRequired: + end: [start] + if: + properties: + kind: + const: reminder + then: + required: [start] + properties: + kind: + const: reminder + start: + type: string + end: + type: string + window: + type: array + prefixItems: + - type: integer + - type: integer + labels: + type: object + patternProperties: + '^x-': + type: integer + note: + type: [string, "null"] diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java index 7dbf5e8..25db7e7 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import java.util.List; +import java.util.Map; /** * Utility class for generating JSON Schema documents from OpenAPI models. @@ -137,6 +138,79 @@ public ObjectNode generateModelSchema(CodegenModel model) { } } + // JSON Schema keywords openapi-generator's Codegen abstractions do not surface; + // copied verbatim from the raw parsed spec schema. Coraza's validator enforces + // all of them even under a draft-07 $schema (docs/engine-behavior.md). + private static final String[] RAW_KEYWORDS = { + "const", "prefixItems", "patternProperties", "dependentRequired", "dependentSchemas", + "if", "then", "else", "contains", "minContains", "maxContains", "propertyNames" }; + + /** + * Generate a model schema and enrich it (root and per-property) with the + * long-tail keywords from the raw spec schema. + * + * @param model the codegen model + * @param rawSchema the raw parsed spec schema for this model, may be null + * @return the enriched schema node + */ + public ObjectNode generateModelSchema(CodegenModel model, + io.swagger.v3.oas.models.media.Schema rawSchema) { + ObjectNode schemaNode = generateModelSchema(model); + if (schemaNode != null && rawSchema != null) { + enrichWithRawKeywords(schemaNode, rawSchema); + } + return schemaNode; + } + + private void enrichWithRawKeywords(ObjectNode schemaNode, + io.swagger.v3.oas.models.media.Schema rawSchema) { + try { + JsonNode raw = io.swagger.v3.core.util.Json31.mapper().valueToTree(rawSchema); + copyRawKeywords(raw, schemaNode); + JsonNode rawProps = raw.path("properties"); + JsonNode outProps = schemaNode.path("properties"); + if (rawProps.isObject() && outProps.isObject()) { + java.util.Iterator> it = rawProps.fields(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + JsonNode target = outProps.get(entry.getKey()); + if (target instanceof ObjectNode) { + copyRawKeywords(entry.getValue(), (ObjectNode) target); + } + } + } + } catch (Exception e) { + log.warn("Could not enrich schema with raw spec keywords: {}", e.getMessage()); + } + } + + private void copyRawKeywords(JsonNode raw, ObjectNode target) { + for (String keyword : RAW_KEYWORDS) { + JsonNode value = raw.get(keyword); + if (value != null && !value.isNull()) { + target.set(keyword, rewriteRefs(value.deepCopy())); + } + } + } + + /** + * Copied subschemas reference "#/components/schemas/X"; the emitted document + * keys models under "#/definitions/X". + */ + private JsonNode rewriteRefs(JsonNode node) { + if (node instanceof ObjectNode) { + ObjectNode obj = (ObjectNode) node; + JsonNode ref = obj.get("$ref"); + if (ref != null && ref.isTextual() && ref.asText().startsWith("#/components/schemas/")) { + obj.put("$ref", "#/definitions/" + ref.asText().substring("#/components/schemas/".length())); + } + obj.forEach(this::rewriteRefs); + } else if (node.isArray()) { + node.forEach(this::rewriteRefs); + } + return node; + } + /** * Build the schema for one oneOf/anyOf member: a $ref for model members, an * inline primitive schema (type/format/enum/constraints) otherwise. diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index 337ce77..fad6ada 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -537,6 +537,56 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List rawRoot = rawSchemaByName(param.baseType); + if (rawRoot == null) { + rawRoot = rawSchemaByName(param.dataType); + } + if (rawRoot != null) { + for (CodegenProperty prop : flattenedProperties) { + io.swagger.v3.oas.models.media.Schema rawProp = rawSchemaForPath(rawRoot, prop.baseName); + if (rawProp == null) { + continue; + } + if (rawProp.getConst() != null) { + prop.pattern = "^" + escapeRegexLiteral(String.valueOf(rawProp.getConst())) + "$"; + // const admits exactly one value — never null, even when codegen + // inferred nullability from a type-less schema + prop.isNullable = false; + } + if (rawProp.getPatternProperties() != null && (prop.isMap || prop.isFreeFormObject)) { + List> ppRules = new ArrayList>(); + for (Map.Entry pp + : rawProp.getPatternProperties().entrySet()) { + Map rule = new HashMap(); + rule.put("nameRegex", patternPropertiesNameRegex(pp.getKey())); + rule.put("valuePattern", rawValuePattern(pp.getValue())); + ppRules.add(rule); + } + prop.vendorExtensions.put("x-oashield-patternProps", ppRules); + } + } + + // dependentRequired at the body root: presence of the trigger property + // demands the dependent property (chained count rules on both engines) + io.swagger.v3.oas.models.media.Schema resolvedRoot = resolveRawRef(rawRoot); + if (resolvedRoot.getDependentRequired() != null) { + List> depRules = new ArrayList>(); + for (Map.Entry> dep : resolvedRoot.getDependentRequired().entrySet()) { + for (String requiredName : dep.getValue()) { + Map rule = new HashMap(); + rule.put("trigger", JSON_ARGS_PREFIX + dep.getKey()); + rule.put("dependent", JSON_ARGS_PREFIX + requiredName); + rule.put("depRuleId", globalParamIndex++); + depRules.add(rule); + } + } + param.vendorExtensions.put("x-oashield-dependentRules", depRules); + } + } + for (CodegenProperty prop : flattenedProperties) { decorateBodyProperty(prop, argsAllowlist); } @@ -613,6 +663,14 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List flattenModel(CodegenProperty currentProperty, Strin return properties; } + // Un-normalized spec parse, lazily created: openapi-generator's normalizer + // rewrites this.openAPI in place (e.g. it drops 3.1 prefixItems), so raw + // keyword lookups re-read the original document. + private io.swagger.v3.oas.models.OpenAPI rawOpenAPI; + + private io.swagger.v3.oas.models.OpenAPI rawOpenAPI() { + if (rawOpenAPI == null) { + String spec = getInputSpec(); + if (spec != null && !spec.isEmpty()) { + try { + io.swagger.v3.parser.core.models.ParseOptions options = + new io.swagger.v3.parser.core.models.ParseOptions(); + options.setResolve(true); + io.swagger.v3.parser.core.models.SwaggerParseResult result = + new io.swagger.v3.parser.OpenAPIV3Parser().readLocation(spec, null, options); + rawOpenAPI = result != null ? result.getOpenAPI() : null; + } catch (Exception e) { + LOGGER.warn("Could not re-parse spec '{}' for raw keyword lookups: {}", spec, e.getMessage()); + } + } + if (rawOpenAPI == null) { + rawOpenAPI = this.openAPI; + } + } + return rawOpenAPI; + } + + /** + * Look up a raw parsed spec schema by component name (null-safe). + */ + io.swagger.v3.oas.models.media.Schema rawSchemaByName(String name) { + io.swagger.v3.oas.models.OpenAPI raw = name != null ? rawOpenAPI() : null; + if (raw == null || raw.getComponents() == null || raw.getComponents().getSchemas() == null) { + return null; + } + return raw.getComponents().getSchemas().get(name); + } + + private io.swagger.v3.oas.models.media.Schema resolveRawRef(io.swagger.v3.oas.models.media.Schema schema) { + if (schema != null && schema.get$ref() != null) { + String ref = schema.get$ref(); + io.swagger.v3.oas.models.media.Schema resolved = + rawSchemaByName(ref.substring(ref.lastIndexOf('/') + 1)); + return resolved != null ? resolved : schema; + } + return schema; + } + + /** + * Walk a raw spec schema along a flattened body path ("json.tags.0.name") to + * the schema of that leaf; "0" segments descend into array items. Returns null + * when the path cannot be resolved. + */ + io.swagger.v3.oas.models.media.Schema rawSchemaForPath( + io.swagger.v3.oas.models.media.Schema root, String flatPath) { + io.swagger.v3.oas.models.media.Schema current = resolveRawRef(root); + String[] segments = flatPath.split("\\."); + for (int i = 1; i < segments.length && current != null; i++) { // segment 0 is the "json" prefix + if ("0".equals(segments[i])) { + current = resolveRawRef(current.getItems()); + } else { + Map props = current.getProperties(); + current = props != null ? resolveRawRef(props.get(segments[i])) : null; + } + } + return current; + } + + /** + * Convert a patternProperties NAME regex into the fragment matching that name + * inside a flattened ARGS key (unanchored ends admit surrounding characters). + */ + static String patternPropertiesNameRegex(String namePattern) { + return (namePattern.startsWith("^") ? "" : "[^.]*") + + stripAnchors(namePattern) + + (namePattern.endsWith("$") && !namePattern.endsWith("\\$") ? "" : "[^.]*"); + } + + /** + * Value pattern for a raw patternProperties value schema (primitive types only; + * anything structured is admitted by name and validated via schema.json). + */ + String rawValuePattern(io.swagger.v3.oas.models.media.Schema valueSchema) { + io.swagger.v3.oas.models.media.Schema schema = resolveRawRef(valueSchema); + if (schema == null) { + return "^.*$"; + } + if (schema.getPattern() != null && !isInvalidPattern(schema.getPattern())) { + return schema.getPattern(); + } + String type = schema.getType(); + if (type == null && schema.getTypes() != null && !schema.getTypes().isEmpty()) { + type = schema.getTypes().iterator().next(); + } + if ("integer".equals(type)) { + return "^-?[0-9]{1,19}$"; + } + if ("number".equals(type)) { + return "^-?([0-9]{1,15}(\\.[0-9]{1,15})?|\\.[0-9]{1,15})$"; + } + if ("boolean".equals(type)) { + return "^(true|false)$"; + } + return "^.*$"; + } + /** * Classify a request media type into the template section that handles it. * JSON and XML get body validation; form-urlencoded/multipart rely on the @@ -1092,6 +1256,23 @@ private void decorateBodyProperty(CodegenProperty prop, java.util.Collection> ppRules = + (List>) prop.vendorExtensions.get("x-oashield-patternProps"); + if (ppRules != null) { + // patternProperties: only keys matching a declared name pattern are + // admitted (no broad wildcard) and their values are validated per entry + for (Map rule : ppRules) { + String nameRegex = (String) rule.get("nameRegex"); + argsAllowlist.add(body + "\\." + nameRegex); + rule.put("selector", "/^" + body + "\\." + nameRegex + "$/"); + rule.put("ruleId", globalParamIndex++); + } + for (int i = 1; i <= PROP_INDEX_MAX; i++) { + prop.vendorExtensions.put(PROP_INDEX_KEY + "_" + i, globalParamIndex++); + } + return; + } argsAllowlist.add(body + "\\..{1,256}"); CodegenProperty valueSchema = prop.items; if (prop.isMap && valueSchema != null @@ -1270,8 +1451,13 @@ private void generateJsonSchema(Map models) { ModelMap modelMap = modelsMap.getModels().get(0); CodegenModel model = modelMap.getModel(); - // Process the model and add it to the definitions - ObjectNode modelSchema = generator.generateModelSchema(model); + // Process the model and add it to the definitions, enriched with the raw + // spec keywords the codegen abstractions do not surface + io.swagger.v3.oas.models.media.Schema rawSchema = rawSchemaByName(model.name); + if (rawSchema == null) { + rawSchema = rawSchemaByName(modelName); + } + ObjectNode modelSchema = generator.generateModelSchema(model, rawSchema); if (modelSchema != null) { definitions.set(modelName, modelSchema); } diff --git a/src/main/resources/modsecurity3/config.mustache b/src/main/resources/modsecurity3/config.mustache index a1f3231..e5b6c1f 100644 --- a/src/main/resources/modsecurity3/config.mustache +++ b/src/main/resources/modsecurity3/config.mustache @@ -80,11 +80,18 @@ SecRule REQBODY_ERROR "!@eq 0" "id:{{oasBodyErrId}},phase:2,block,msg:'Failed to {{#vendorExtensions.validateBodySchema}} {{#allParams}} {{#isBodyParam}} +{{#vendorExtensions.x-oashield-dependentRules}} +SecRule &ARGS:{{trigger}} "@gt 0" "id:{{depRuleId}},phase:2,block,msg:'Property {{trigger}} requires {{dependent}}',{{logAction}},skipAfter:FAILED_API_CHECKS,chain" +SecRule &ARGS:{{dependent}} "@eq 0" "t:none" +{{/vendorExtensions.x-oashield-dependentRules}} {{#vendorExtensions.x-codegen-modelProperties}} +{{#vendorExtensions.x-oashield-patternProps}} +SecRule ARGS:{{selector}} "!@rx {{valuePattern}}" "id:{{ruleId}},phase:2,block,msg:'Invalid value for patternProperties key under {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{/vendorExtensions.x-oashield-patternProps}} {{#vendorExtensions.x-oashield-requiredRule}} {{#vendorExtensions.x-oashield-parentSelector}} SecRule &ARGS:{{vendorExtensions.x-oashield-parentSelector}} "@gt 0" "id:{{vendorExtensions.x-codegen-propIndex_1}},phase:2,block,msg:'Missing required property {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS,chain" -SecRule &ARGS:{{vendorExtensions.x-oashield-argTarget}} "@eq 0" +SecRule &ARGS:{{vendorExtensions.x-oashield-argTarget}} "@eq 0" "t:none" {{/vendorExtensions.x-oashield-parentSelector}} {{^vendorExtensions.x-oashield-parentSelector}} SecRule &ARGS:{{vendorExtensions.x-oashield-argTarget}} "@eq 0" "id:{{vendorExtensions.x-codegen-propIndex_1}},phase:2,block,msg:'Missing required property {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java index 6312f71..72e3b92 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java @@ -30,7 +30,7 @@ public class GoldenFileTest { private static final Path GOLDEN_ROOT = Paths.get("src/test/resources/golden"); private static final List SAMPLES = List.of( - "petstore", "composed", "getparam", "urlintparam", "multipart", "paramfeatures", "xmlbody"); + "petstore", "composed", "getparam", "urlintparam", "multipart", "paramfeatures", "xmlbody", "oas31"); private static final List FLAVORS = List.of("modsecurity3", "coraza"); static Stream cases() { diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase7LongTailTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase7LongTailTest.java new file mode 100644 index 0000000..779c14d --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase7LongTailTest.java @@ -0,0 +1,104 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * OpenAPI 3.1 long tail (Phase 7): const, prefixItems, patternProperties, + * dependentRequired, if/then/else, and content: parameters — per-field rules + * where tractable, schema.json keywords for Coraza otherwise. + */ +public class Phase7LongTailTest { + + @TempDir + static Path outputDir; + private static String conf; + private static JsonNode event; + + @BeforeAll + static void generate() throws IOException { + new DefaultGenerator() + .opts(new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("samples/oas31.yaml") + .setOutputDir(outputDir.toString()) + .toClientOptInput()) + .generate(); + try (Stream files = Files.list(outputDir)) { + conf = files.filter(p -> p.getFileName().toString().endsWith("Api.conf")) + .map(p -> { + try { + return Files.readString(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.joining("\n")); + } + JsonNode schema = new ObjectMapper().readTree(outputDir.resolve("schema.json").toFile()); + event = schema.path("definitions").path("Event"); + assertTrue(event.isObject(), "Event definition missing: " + schema); + } + + @Test + void constBecomesExactMatchRule() { + assertTrue(conf.contains("ARGS:json.kind \"!@rx ^reminder$\""), + "const should produce an exact-match value rule:\n" + conf); + } + + @Test + void dependentRequiredBecomesChainedRules() { + assertTrue(conf.contains("SecRule &ARGS:json.end \"@gt 0\"") + && conf.contains("SecRule &ARGS:json.start \"@eq 0\""), + "dependentRequired should produce chained presence rules:\n" + conf); + } + + @Test + void patternPropertiesRestrictAllowlistAndValidateValues() { + assertTrue(conf.contains("json\\.labels\\.^x-[^.]*".replace("^x-", "x-")) + || conf.contains("json\\.labels\\.x-[^.]*"), + "patternProperties names should be allowlisted by their pattern:\n" + conf); + assertFalse(conf.contains("json\\.labels\\..{1,256}"), + "patternProperties must replace the broad free-form wildcard:\n" + conf); + assertTrue(conf.contains("ARGS:/^json\\.labels\\.x-[^.]*$/ \"!@rx ^-?[0-9]{1,19}$\""), + "patternProperties values should be validated by type:\n" + conf); + } + + @Test + void contentParameterGetsBoundedPatternAndAllowlistEntry() { + assertTrue(conf.contains("ARGS_GET:meta \"!@rx ^[\\s\\S]{0,"), + "content: param should get a bounded length cap:\n" + conf); + assertTrue(conf.matches("(?s).*ARGS_NAMES \"!@rx \\^\\(\\?:[^\"]*meta[^\"]*\\)\\$\".*"), + "content: param name should be allowlisted:\n" + conf); + } + + @Test + void schemaCarriesRawKeywords() { + assertEquals("reminder", event.path("properties").path("kind").path("const").asText(), + "const should be copied into schema.json: " + event); + assertTrue(event.path("properties").path("window").path("prefixItems").isArray(), + "prefixItems should be copied: " + event); + assertTrue(event.path("properties").path("labels").path("patternProperties").has("^x-"), + "patternProperties should be copied: " + event); + assertTrue(event.path("dependentRequired").has("end"), + "dependentRequired should be copied: " + event); + assertTrue(event.has("if") && event.has("then"), + "if/then should be copied: " + event); + } +} diff --git a/src/test/resources/features/modsecurity_rule_generation.feature b/src/test/resources/features/modsecurity_rule_generation.feature index 8372f13..bf4d168 100644 --- a/src/test/resources/features/modsecurity_rule_generation.feature +++ b/src/test/resources/features/modsecurity_rule_generation.feature @@ -148,3 +148,21 @@ Feature: ModSecurity Rule Generation and Testing | engine | | coraza | | modsecurity3 | + + Scenario Outline: OpenAPI 3.1 const, dependentRequired, patternProperties and nullable + Given an OpenAPI specification file at "samples/oas31.yaml" + When I generate rules with body validation for "" + And I start the WAF server with the generated rules + Then a POST request to "/events" with content type "application/json" and body "{\"kind\":\"reminder\",\"start\":\"now\"}" should return a 200 status code + And a POST request to "/events" with content type "application/json" and body "{\"kind\":\"other\",\"start\":\"now\"}" should be blocked with a 403 status code + And a POST request to "/events" with content type "application/json" and body "{\"kind\":\"reminder\",\"start\":\"now\",\"end\":\"later\"}" should return a 200 status code + And a POST request to "/events" with content type "application/json" and body "{\"kind\":\"reminder\",\"end\":\"later\"}" should be blocked with a 403 status code + And a POST request to "/events" with content type "application/json" and body "{\"kind\":\"reminder\",\"start\":\"now\",\"labels\":{\"x-a\":5}}" should return a 200 status code + And a POST request to "/events" with content type "application/json" and body "{\"kind\":\"reminder\",\"start\":\"now\",\"labels\":{\"x-a\":\"notanumber\"}}" should be blocked with a 403 status code + And a POST request to "/events" with content type "application/json" and body "{\"kind\":\"reminder\",\"start\":\"now\",\"labels\":{\"unrelated\":5}}" should be blocked with a 403 status code + And a POST request to "/events" with content type "application/json" and body "{\"kind\":\"reminder\",\"start\":\"now\",\"note\":null}" should return a 200 status code + + Examples: + | engine | + | coraza | + | modsecurity3 | diff --git a/src/test/resources/golden/oas31/coraza/DefaultApi.conf b/src/test/resources/golden/oas31/coraza/DefaultApi.conf new file mode 100644 index 0000000..56db5c9 --- /dev/null +++ b/src/test/resources/golden/oas31/coraza/DefaultApi.conf @@ -0,0 +1,49 @@ + +# createEvent: POST /events +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/events$" "id:4200002,phase:2,pass,nolog,skipAfter:END_createEvent" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_createEvent" + +SecRule ARGS_GET:meta "!@rx ^[\s\S]{0,1000}$" "id:4210124,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:meta "@gt 1" "id:4210125,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.kind|json\.start|json\.end|json\.window|json\.window\.(?:array_)?\d{1,9}|json\.labels|json\.labels\.x-[^.]*|json\.note|meta)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_createEvent_0" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.end "@gt 0" "id:4210005,phase:2,block,msg:'Property json.end requires json.start',log,auditlog,skipAfter:FAILED_API_CHECKS,chain" +SecRule &ARGS:json.start "@eq 0" "t:none" +SecRule &ARGS:json.kind "@eq 0" "id:4210006,phase:2,block,msg:'Missing required property json.kind',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.kind "!@rx ^reminder$" "id:4210007,phase:2,block,msg:'Invalid value for property json.kind',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.start "!@rx ^.+$" "id:4210019,phase:2,block,msg:'Invalid value for property json.start',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.end "!@rx ^.+$" "id:4210031,phase:2,block,msg:'Invalid value for property json.end',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.window\.(?:array_)?\d{1,9}$/ "!@rx ^(?:.+)?$" "id:4210043,phase:2,block,msg:'Invalid value for property json.window',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.labels\.x-[^.]*$/ "!@rx ^-?[0-9]{1,19}$" "id:4210054,phase:2,block,msg:'Invalid value for patternProperties key under json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.note "!@rx ^(?:.+)?$" "id:4210068,phase:2,block,msg:'Invalid value for property json.note',log,auditlog,skipAfter:FAILED_API_CHECKS" +# Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, +# which is why the modsecurity3 flavor relies on the per-field rules above. +SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210003,phase:2,block,msg:'JSON schema validation failed for createEvent',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createEvent" + +SecMarker ENDMEDIA_createEvent_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_createEvent + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_createEvent diff --git a/src/test/resources/golden/oas31/coraza/mainconfig.conf b/src/test/resources/golden/oas31/coraza/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/oas31/coraza/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/oas31/coraza/schema.json b/src/test/resources/golden/oas31/coraza/schema.json new file mode 100644 index 0000000..85d987a --- /dev/null +++ b/src/test/resources/golden/oas31/coraza/schema.json @@ -0,0 +1,61 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "Event" : { + "title" : "Event", + "type" : "object", + "properties" : { + "kind" : { + "type" : [ "string", "null" ], + "const" : "reminder" + }, + "start" : { + "type" : "string" + }, + "end" : { + "type" : "string" + }, + "window" : { + "type" : "array", + "items" : { + "$ref" : "#/definitions/AnyType" + }, + "prefixItems" : [ { + "type" : "integer" + }, { + "type" : "integer" + } ] + }, + "labels" : { + "type" : "object", + "additionalProperties" : true, + "patternProperties" : { + "^x-" : { + "type" : "integer" + } + } + }, + "note" : { + "type" : [ "string", "null" ] + } + }, + "required" : [ "kind" ], + "dependentRequired" : { + "end" : [ "start" ] + }, + "if" : { + "properties" : { + "kind" : { + "const" : "reminder" + } + } + }, + "then" : { + "required" : [ "start" ] + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/golden/oas31/modsecurity3/DefaultApi.conf b/src/test/resources/golden/oas31/modsecurity3/DefaultApi.conf new file mode 100644 index 0000000..95c81bd --- /dev/null +++ b/src/test/resources/golden/oas31/modsecurity3/DefaultApi.conf @@ -0,0 +1,46 @@ + +# createEvent: POST /events +# Skip this operation if the request does not match the operation path. +# Path parameter validation patterns are embedded in the regex, so this rule +# both routes and validates path parameters (works on ModSecurity3 and Coraza). +SecRule REQUEST_FILENAME "!@rx ^/events$" "id:4200002,phase:2,pass,nolog,skipAfter:END_createEvent" +# Skip this operation if the request method does not match the operation +SecRule REQUEST_METHOD "!@within POST" "id:4200003,phase:2,pass,nolog,skipAfter:END_createEvent" + +SecRule ARGS_GET:meta "!@rx ^[\s\S]{0,1000}$" "id:4210124,phase:2,block,msg:'Forbidden parameter value detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:meta "@gt 1" "id:4210125,phase:2,block,msg:'Multiple values for non-array parameter',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body +# names on both engines, so one allowlist covers them all. +SecRule ARGS_NAMES "!@rx ^(?:json\.kind|json\.start|json\.end|json\.window|json\.window\.(?:array_)?\d{1,9}|json\.labels|json\.labels\.x-[^.]*|json\.note|meta)$" "id:4200014,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type +SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase:2,pass,nolog,skipAfter:ENDMEDIA_createEvent_0" +# ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, +# but its @validateSchema rule below rejects malformed JSON instead. +SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:json.end "@gt 0" "id:4210005,phase:2,block,msg:'Property json.end requires json.start',log,auditlog,skipAfter:FAILED_API_CHECKS,chain" +SecRule &ARGS:json.start "@eq 0" "t:none" +SecRule &ARGS:json.kind "@eq 0" "id:4210006,phase:2,block,msg:'Missing required property json.kind',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.kind "!@rx ^reminder$" "id:4210007,phase:2,block,msg:'Invalid value for property json.kind',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.start "!@rx ^.+$" "id:4210019,phase:2,block,msg:'Invalid value for property json.start',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.end "!@rx ^.+$" "id:4210031,phase:2,block,msg:'Invalid value for property json.end',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.window\.(?:array_)?\d{1,9}$/ "!@rx ^(?:.+)?$" "id:4210043,phase:2,block,msg:'Invalid value for property json.window',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/^json\.labels\.x-[^.]*$/ "!@rx ^-?[0-9]{1,19}$" "id:4210054,phase:2,block,msg:'Invalid value for patternProperties key under json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:json.note "!@rx ^(?:.+)?$" "id:4210068,phase:2,block,msg:'Invalid value for property json.note',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createEvent" + +SecMarker ENDMEDIA_createEvent_0 + + +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:4200021,log,auditlog,block,phase:2,msg:'Unexpected content type'" + + +SecMarker AFTER_CONSUMES_createEvent + +## The request passed all checks +SecAction "phase:2,allow:request,id:4200022" + +## End of checks for this operation +SecMarker END_createEvent diff --git a/src/test/resources/golden/oas31/modsecurity3/mainconfig.conf b/src/test/resources/golden/oas31/modsecurity3/mainconfig.conf new file mode 100644 index 0000000..4816ef4 --- /dev/null +++ b/src/test/resources/golden/oas31/modsecurity3/mainconfig.conf @@ -0,0 +1,20 @@ +# This is the main configuration file that will include the other configurations + +# Enable disruption actions +SecRuleEngine On +SecRequestBodyAccess On + +# Default action applied when a rule blocks +SecDefaultAction "phase:2,log,auditlog,deny,status:403" + +# Select body processors in phase 1: body parsing happens between phases 1 and 2, +# so a phase-2 ctl would be a no-op and JSON/XML bodies would never reach ARGS. +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^application/(?:[a-z0-9.+-]+\+)?json" "id:4200000,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^(?:application|text)/(?:[a-z0-9.+-]+\+)?xml" "id:4199999,phase:1,pass,nolog,ctl:requestBodyProcessor=XML" + +# Include the configuration for each operation +Include DefaultApi.conf + +# For anything else, deny by default +SecMarker FAILED_API_CHECKS +SecAction "id:4220001,log,auditlog,block,phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/resources/golden/oas31/modsecurity3/schema.json b/src/test/resources/golden/oas31/modsecurity3/schema.json new file mode 100644 index 0000000..85d987a --- /dev/null +++ b/src/test/resources/golden/oas31/modsecurity3/schema.json @@ -0,0 +1,61 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "type" : "object", + "definitions" : { + "Event" : { + "title" : "Event", + "type" : "object", + "properties" : { + "kind" : { + "type" : [ "string", "null" ], + "const" : "reminder" + }, + "start" : { + "type" : "string" + }, + "end" : { + "type" : "string" + }, + "window" : { + "type" : "array", + "items" : { + "$ref" : "#/definitions/AnyType" + }, + "prefixItems" : [ { + "type" : "integer" + }, { + "type" : "integer" + } ] + }, + "labels" : { + "type" : "object", + "additionalProperties" : true, + "patternProperties" : { + "^x-" : { + "type" : "integer" + } + } + }, + "note" : { + "type" : [ "string", "null" ] + } + }, + "required" : [ "kind" ], + "dependentRequired" : { + "end" : [ "start" ] + }, + "if" : { + "properties" : { + "kind" : { + "const" : "reminder" + } + } + }, + "then" : { + "required" : [ "start" ] + } + } + } +} \ No newline at end of file From 7ca0f420f20086c24cb976e2e30ccf0342f1017a Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 16:01:46 -0500 Subject: [PATCH 9/9] Fix Coraza root-array handling: bare json node, selector case folding, schema root type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container-probed and fixed three Coraza behaviors that silently broke root-array request bodies end-to-end: - Coraza lists a bare 'json' node in ARGS_NAMES for root-array bodies (not for object bodies); root-array allowlists now include it. - Coraza LOWERCASES arg keys before matching regex collection selectors, so any selector containing an uppercase property name (userStatus) silently never matched — a latent false NEGATIVE across all generated per-field rules for mixed-case properties. All generated selectors now carry an inline (?i) flag, accepted by both engines. - The schema.json root no longer declares type:object, which made Coraza's @validateSchema reject every root-array body. All facts recorded in docs/engine-behavior.md. Adds root-array valid/invalid steps to the petstore body-validation scenario; both engines verified live (valid 200 / invalid element 403). Co-Authored-By: Claude Fable 5 --- docs/engine-behavior.md | 9 +++++ .../modsecurity3/JsonSchemaGenerator.java | 3 +- .../modsecurity3/Modsecurity3Generator.java | 17 +++++---- .../modsecurity3/tests/EngineFlavorTest.java | 2 +- .../tests/JsonSchemaGeneratorTest.java | 5 ++- .../modsecurity3/tests/Phase1FixesTest.java | 7 ++-- .../modsecurity3/tests/Phase3FixesTest.java | 2 +- .../tests/Phase4EnforcementTest.java | 4 +-- .../tests/Phase7LongTailTest.java | 2 +- .../modsecurity_rule_generation.feature | 2 ++ .../golden/composed/coraza/schema.json | 1 - .../golden/composed/modsecurity3/schema.json | 1 - .../golden/getparam/coraza/schema.json | 1 - .../golden/getparam/modsecurity3/schema.json | 1 - .../golden/multipart/coraza/schema.json | 1 - .../golden/multipart/modsecurity3/schema.json | 1 - .../golden/oas31/coraza/DefaultApi.conf | 4 +-- .../resources/golden/oas31/coraza/schema.json | 1 - .../golden/oas31/modsecurity3/DefaultApi.conf | 4 +-- .../golden/oas31/modsecurity3/schema.json | 1 - .../paramfeatures/coraza/DefaultApi.conf | 6 ++-- .../golden/paramfeatures/coraza/schema.json | 1 - .../modsecurity3/DefaultApi.conf | 6 ++-- .../paramfeatures/modsecurity3/schema.json | 1 - .../golden/petstore/coraza/PetApi.conf | 12 +++---- .../golden/petstore/coraza/UserApi.conf | 36 +++++++++---------- .../golden/petstore/coraza/schema.json | 1 - .../golden/petstore/modsecurity3/PetApi.conf | 12 +++---- .../golden/petstore/modsecurity3/UserApi.conf | 36 +++++++++---------- .../golden/petstore/modsecurity3/schema.json | 1 - .../golden/urlintparam/coraza/PetApi.conf | 12 +++---- .../golden/urlintparam/coraza/schema.json | 1 - .../urlintparam/modsecurity3/PetApi.conf | 12 +++---- .../urlintparam/modsecurity3/schema.json | 1 - .../golden/xmlbody/coraza/schema.json | 1 - .../golden/xmlbody/modsecurity3/schema.json | 1 - 36 files changed, 107 insertions(+), 102 deletions(-) diff --git a/docs/engine-behavior.md b/docs/engine-behavior.md index af9d8be..d358bd1 100644 --- a/docs/engine-behavior.md +++ b/docs/engine-behavior.md @@ -12,6 +12,15 @@ Generator design decisions reference this file. Last run: 2026-07-04 against (generated selectors use `(?:array_)?\d{1,9}` to match both). - Coraza also lists container nodes (`json.category`, `json.photoUrls`) in `ARGS_NAMES`, not just leaves — allowlists must include intermediate prefixes. +- For ROOT-level array bodies Coraza additionally lists the bare `json` node in + `ARGS_NAMES` (it does not for object bodies) — root-array allowlists must + include the literal `json` entry. +- **Coraza lowercases arg keys before matching regex collection selectors** + (`ARGS:/.../`): a selector containing `userStatus` silently never matches. + ARGS_NAMES *values* keep their original case. All generated selectors carry + an inline `(?i)` flag, which both engines accept. +- The root of the generated schema.json must not declare `type: object` — + root-array request bodies would fail Coraza's `@validateSchema`. - **JSON `null` flattens to a present key with an EMPTY value on both engines** (indistinguishable from `""`). Nullable properties are therefore validated with an optional-wrapped value pattern; a required-presence rule still sees the key. diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java index 25db7e7..491d281 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java @@ -68,7 +68,8 @@ private ObjectNode createRootSchema() { schema.put("$schema", JSON_SCHEMA_DRAFT7); schema.put("title", "OpenAPI Schema Definitions"); schema.put("description", "JSON Schema definitions generated from OpenAPI specification"); - schema.put("type", "object"); + // No root "type": request bodies may be objects or arrays; a type:object + // root would make Coraza's @validateSchema reject root-array bodies. return schema; } diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index fad6ada..4321838 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -598,6 +598,9 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List flattenedProperties = new ArrayList(); List itemVars = null; if (param.items != null) { @@ -1238,7 +1241,7 @@ private void decorateBodyProperty(CodegenProperty prop, java.util.Collection rule : ppRules) { String nameRegex = (String) rule.get("nameRegex"); argsAllowlist.add(body + "\\." + nameRegex); - rule.put("selector", "/^" + body + "\\." + nameRegex + "$/"); + rule.put("selector", "/(?i)^" + body + "\\." + nameRegex + "$/"); rule.put("ruleId", globalParamIndex++); } for (int i = 1; i <= PROP_INDEX_MAX; i++) { @@ -1278,7 +1281,7 @@ private void decorateBodyProperty(CodegenProperty prop, java.util.Collection models) { rootSchema.put("$schema", "http://json-schema.org/draft-07/schema#"); rootSchema.put("title", "OpenAPI Schema Definitions"); rootSchema.put("description", "JSON Schema definitions generated from OpenAPI specification"); - rootSchema.put("type", "object"); + // No root "type": the document is a definitions container and the request + // body may legally be an object OR an array (root-array bodies would fail + // a type:object root under Coraza's @validateSchema). ObjectNode definitions = rootSchema.putObject("definitions"); JsonSchemaGenerator generator = new JsonSchemaGenerator(); diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/EngineFlavorTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/EngineFlavorTest.java index dc6c286..5957de8 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/EngineFlavorTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/EngineFlavorTest.java @@ -56,7 +56,7 @@ public void defaultFlavorEmitsPerFieldRulesWithoutCorazaOperators() throws IOExc // issue #14: per-field body validation assertTrue(conf.contains("SecRule &ARGS:json.name \"@eq 0\""), "required property presence rule"); assertTrue(conf.contains("SecRule ARGS:json.id \"!@rx ^[0-9]{1,19}$\""), "typed property rule"); - assertTrue(conf.contains("ARGS:/^json\\.photoUrls\\.(?:array_)?\\d{1,9}$/"), + assertTrue(conf.contains("ARGS:/(?i)^json\\.photoUrls\\.(?:array_)?\\d{1,9}$/"), "array element rule must match both engines' index forms"); assertTrue(conf.contains("SecRule ARGS:json.status \"!@rx ^(available|pending|sold)$\""), "enum rule"); // additionalProperties enforcement incl. container prefixes (Coraza lists them) diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/JsonSchemaGeneratorTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/JsonSchemaGeneratorTest.java index 793577c..d112ea0 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/JsonSchemaGeneratorTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/JsonSchemaGeneratorTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; @@ -58,7 +59,9 @@ public void testGenerateJsonSchema() throws Exception { // Validate the schema structure assertEquals("http://json-schema.org/draft-07/schema#", schemaNode.get("$schema").asText()); assertEquals("OpenAPI Schema Definitions", schemaNode.get("title").asText()); - assertEquals("object", schemaNode.get("type").asText()); + // no root "type": bodies may be objects or arrays (root-array bodies would + // fail a type:object root under Coraza's @validateSchema) + assertNull(schemaNode.get("type")); // Validate the model definition JsonNode definitions = schemaNode.get("definitions"); diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase1FixesTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase1FixesTest.java index a5255f4..1858fa2 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase1FixesTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase1FixesTest.java @@ -84,15 +84,16 @@ void genericStringPathParamsDoNotCrossSegments() { void rootArrayBodyOfModelsIsFlattened() { assertTrue(conf.contains("json\\.(?:array_)?\\d{1,9}\\.username"), "root-array element fields should be validated and allowlisted:\n" + conf); - // the array-body operation's ARGS_NAMES allowlist covers element keys and fields + // the array-body operation's ARGS_NAMES allowlist covers the bare json + // container (Coraza lists it for root arrays), index keys, and fields assertTrue(conf.contains( - "^(?:json\\.(?:array_)?\\d{1,9}|json\\.(?:array_)?\\d{1,9}\\.username|json\\.(?:array_)?\\d{1,9}\\.level)$"), + "^(?:json|json\\.(?:array_)?\\d{1,9}|json\\.(?:array_)?\\d{1,9}\\.username|json\\.(?:array_)?\\d{1,9}\\.level)$"), "root-array body allowlist should cover index keys and element fields:\n" + conf); } @Test void rootArrayBodyOfPrimitivesIsFlattened() { - assertTrue(conf.contains("/^json\\.(?:array_)?\\d{1,9}$/"), + assertTrue(conf.contains("/(?i)^json\\.(?:array_)?\\d{1,9}$/"), "root-array primitive elements should get an indexed selector:\n" + conf); } diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase3FixesTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase3FixesTest.java index d4ffc52..7ff0019 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase3FixesTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase3FixesTest.java @@ -98,7 +98,7 @@ void nullablePropertyAcceptsEmptyValue() { void mapPropertyAllowsArbitraryKeysAndValidatesValues() { assertTrue(conf.contains("json\\.attrs\\..{1,256}"), "map property should allowlist arbitrary sub-keys:\n" + conf); - assertTrue(conf.contains("ARGS:/^json\\.attrs\\.[^.]{1,64}$/ \"!@rx ^[a-z]{1,20}$\""), + assertTrue(conf.contains("ARGS:/(?i)^json\\.attrs\\.[^.]{1,64}$/ \"!@rx ^[a-z]{1,20}$\""), "map values should be validated against the additionalProperties schema:\n" + conf); } diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase4EnforcementTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase4EnforcementTest.java index d741ef5..9861fe1 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase4EnforcementTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase4EnforcementTest.java @@ -91,9 +91,9 @@ void powerOfTenMultipleOfBecomesTrailingZerosPattern() { @Test void bodyArrayCountsAreEnforced() { - assertTrue(conf.contains("SecRule &ARGS:/^json\\.labels\\.(?:array_)?\\d{1,9}$/ \"@lt 1\""), + assertTrue(conf.contains("SecRule &ARGS:/(?i)^json\\.labels\\.(?:array_)?\\d{1,9}$/ \"@lt 1\""), "minItems count rule missing:\n" + conf); - assertTrue(conf.contains("SecRule &ARGS:/^json\\.labels\\.(?:array_)?\\d{1,9}$/ \"@gt 3\""), + assertTrue(conf.contains("SecRule &ARGS:/(?i)^json\\.labels\\.(?:array_)?\\d{1,9}$/ \"@gt 3\""), "maxItems count rule missing:\n" + conf); } diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase7LongTailTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase7LongTailTest.java index 779c14d..8d56ee2 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase7LongTailTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase7LongTailTest.java @@ -76,7 +76,7 @@ void patternPropertiesRestrictAllowlistAndValidateValues() { "patternProperties names should be allowlisted by their pattern:\n" + conf); assertFalse(conf.contains("json\\.labels\\..{1,256}"), "patternProperties must replace the broad free-form wildcard:\n" + conf); - assertTrue(conf.contains("ARGS:/^json\\.labels\\.x-[^.]*$/ \"!@rx ^-?[0-9]{1,19}$\""), + assertTrue(conf.contains("ARGS:/(?i)^json\\.labels\\.x-[^.]*$/ \"!@rx ^-?[0-9]{1,19}$\""), "patternProperties values should be validated by type:\n" + conf); } diff --git a/src/test/resources/features/modsecurity_rule_generation.feature b/src/test/resources/features/modsecurity_rule_generation.feature index bf4d168..774ca22 100644 --- a/src/test/resources/features/modsecurity_rule_generation.feature +++ b/src/test/resources/features/modsecurity_rule_generation.feature @@ -93,6 +93,8 @@ Feature: ModSecurity Rule Generation and Testing And a POST request to "/v2/pet" with an invalid body "missing_required" should be blocked with a 403 status code And a POST request to "/v2/pet" with an invalid body "invalid_type" should be blocked with a 403 status code And a POST request to "/v2/pet" with an invalid body "extra_property" should be blocked with a 403 status code + And a POST request to "/v2/user/createWithList" with content type "application/json" and body "[{\"username\":\"alice\"}]" should return a 200 status code + And a POST request to "/v2/user/createWithList" with content type "application/json" and body "[{\"username\":\"alice\",\"userStatus\":\"notanumber\"}]" should be blocked with a 403 status code Examples: | engine | diff --git a/src/test/resources/golden/composed/coraza/schema.json b/src/test/resources/golden/composed/coraza/schema.json index 128233b..dfce7ac 100644 --- a/src/test/resources/golden/composed/coraza/schema.json +++ b/src/test/resources/golden/composed/coraza/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "Animal" : { "title" : "Animal", diff --git a/src/test/resources/golden/composed/modsecurity3/schema.json b/src/test/resources/golden/composed/modsecurity3/schema.json index 128233b..dfce7ac 100644 --- a/src/test/resources/golden/composed/modsecurity3/schema.json +++ b/src/test/resources/golden/composed/modsecurity3/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "Animal" : { "title" : "Animal", diff --git a/src/test/resources/golden/getparam/coraza/schema.json b/src/test/resources/golden/getparam/coraza/schema.json index 05c6a88..72ff97a 100644 --- a/src/test/resources/golden/getparam/coraza/schema.json +++ b/src/test/resources/golden/getparam/coraza/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "ApiResponse" : { "title" : "An uploaded response", diff --git a/src/test/resources/golden/getparam/modsecurity3/schema.json b/src/test/resources/golden/getparam/modsecurity3/schema.json index 05c6a88..72ff97a 100644 --- a/src/test/resources/golden/getparam/modsecurity3/schema.json +++ b/src/test/resources/golden/getparam/modsecurity3/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "ApiResponse" : { "title" : "An uploaded response", diff --git a/src/test/resources/golden/multipart/coraza/schema.json b/src/test/resources/golden/multipart/coraza/schema.json index f86cc48..f9f8f69 100644 --- a/src/test/resources/golden/multipart/coraza/schema.json +++ b/src/test/resources/golden/multipart/coraza/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "postNote_request" : { "title" : "postNote_request", diff --git a/src/test/resources/golden/multipart/modsecurity3/schema.json b/src/test/resources/golden/multipart/modsecurity3/schema.json index f86cc48..f9f8f69 100644 --- a/src/test/resources/golden/multipart/modsecurity3/schema.json +++ b/src/test/resources/golden/multipart/modsecurity3/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "postNote_request" : { "title" : "postNote_request", diff --git a/src/test/resources/golden/oas31/coraza/DefaultApi.conf b/src/test/resources/golden/oas31/coraza/DefaultApi.conf index 56db5c9..a659516 100644 --- a/src/test/resources/golden/oas31/coraza/DefaultApi.conf +++ b/src/test/resources/golden/oas31/coraza/DefaultApi.conf @@ -25,8 +25,8 @@ SecRule &ARGS:json.kind "@eq 0" "id:4210006,phase:2,block,msg:'Missing required SecRule ARGS:json.kind "!@rx ^reminder$" "id:4210007,phase:2,block,msg:'Invalid value for property json.kind',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.start "!@rx ^.+$" "id:4210019,phase:2,block,msg:'Invalid value for property json.start',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.end "!@rx ^.+$" "id:4210031,phase:2,block,msg:'Invalid value for property json.end',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.window\.(?:array_)?\d{1,9}$/ "!@rx ^(?:.+)?$" "id:4210043,phase:2,block,msg:'Invalid value for property json.window',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.labels\.x-[^.]*$/ "!@rx ^-?[0-9]{1,19}$" "id:4210054,phase:2,block,msg:'Invalid value for patternProperties key under json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.window\.(?:array_)?\d{1,9}$/ "!@rx ^(?:.+)?$" "id:4210043,phase:2,block,msg:'Invalid value for property json.window',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.labels\.x-[^.]*$/ "!@rx ^-?[0-9]{1,19}$" "id:4210054,phase:2,block,msg:'Invalid value for patternProperties key under json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.note "!@rx ^(?:.+)?$" "id:4210068,phase:2,block,msg:'Invalid value for property json.note',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. diff --git a/src/test/resources/golden/oas31/coraza/schema.json b/src/test/resources/golden/oas31/coraza/schema.json index 85d987a..3a00450 100644 --- a/src/test/resources/golden/oas31/coraza/schema.json +++ b/src/test/resources/golden/oas31/coraza/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "Event" : { "title" : "Event", diff --git a/src/test/resources/golden/oas31/modsecurity3/DefaultApi.conf b/src/test/resources/golden/oas31/modsecurity3/DefaultApi.conf index 95c81bd..b17162a 100644 --- a/src/test/resources/golden/oas31/modsecurity3/DefaultApi.conf +++ b/src/test/resources/golden/oas31/modsecurity3/DefaultApi.conf @@ -25,8 +25,8 @@ SecRule &ARGS:json.kind "@eq 0" "id:4210006,phase:2,block,msg:'Missing required SecRule ARGS:json.kind "!@rx ^reminder$" "id:4210007,phase:2,block,msg:'Invalid value for property json.kind',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.start "!@rx ^.+$" "id:4210019,phase:2,block,msg:'Invalid value for property json.start',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.end "!@rx ^.+$" "id:4210031,phase:2,block,msg:'Invalid value for property json.end',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.window\.(?:array_)?\d{1,9}$/ "!@rx ^(?:.+)?$" "id:4210043,phase:2,block,msg:'Invalid value for property json.window',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.labels\.x-[^.]*$/ "!@rx ^-?[0-9]{1,19}$" "id:4210054,phase:2,block,msg:'Invalid value for patternProperties key under json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.window\.(?:array_)?\d{1,9}$/ "!@rx ^(?:.+)?$" "id:4210043,phase:2,block,msg:'Invalid value for property json.window',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.labels\.x-[^.]*$/ "!@rx ^-?[0-9]{1,19}$" "id:4210054,phase:2,block,msg:'Invalid value for patternProperties key under json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.note "!@rx ^(?:.+)?$" "id:4210068,phase:2,block,msg:'Invalid value for property json.note',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createEvent" diff --git a/src/test/resources/golden/oas31/modsecurity3/schema.json b/src/test/resources/golden/oas31/modsecurity3/schema.json index 85d987a..3a00450 100644 --- a/src/test/resources/golden/oas31/modsecurity3/schema.json +++ b/src/test/resources/golden/oas31/modsecurity3/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "Event" : { "title" : "Event", diff --git a/src/test/resources/golden/paramfeatures/coraza/DefaultApi.conf b/src/test/resources/golden/paramfeatures/coraza/DefaultApi.conf index d021cea..2ff0c58 100644 --- a/src/test/resources/golden/paramfeatures/coraza/DefaultApi.conf +++ b/src/test/resources/golden/paramfeatures/coraza/DefaultApi.conf @@ -18,9 +18,9 @@ SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase: # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.price "!@rx ^(?:0|[0-9]{1,17}0{2})$" "id:4210006,phase:2,block,msg:'Invalid value for property json.price',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210018,phase:2,block,msg:'Invalid value for property json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "@lt 1" "id:4210021,phase:2,block,msg:'Too few array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "@gt 3" "id:4210022,phase:2,block,msg:'Too many array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.labels\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210018,phase:2,block,msg:'Invalid value for property json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:/(?i)^json\.labels\.(?:array_)?\d{1,9}$/ "@lt 1" "id:4210021,phase:2,block,msg:'Too few array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:/(?i)^json\.labels\.(?:array_)?\d{1,9}$/ "@gt 3" "id:4210022,phase:2,block,msg:'Too many array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. SecRule REQUEST_BODY "@validateSchema schema.json" "id:4210003,phase:2,block,msg:'JSON schema validation failed for createWidget',log,auditlog,skipAfter:FAILED_API_CHECKS" diff --git a/src/test/resources/golden/paramfeatures/coraza/schema.json b/src/test/resources/golden/paramfeatures/coraza/schema.json index 4f92f21..10d4e94 100644 --- a/src/test/resources/golden/paramfeatures/coraza/schema.json +++ b/src/test/resources/golden/paramfeatures/coraza/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "createWidget_request" : { "title" : "createWidget_request", diff --git a/src/test/resources/golden/paramfeatures/modsecurity3/DefaultApi.conf b/src/test/resources/golden/paramfeatures/modsecurity3/DefaultApi.conf index e219a53..83d6150 100644 --- a/src/test/resources/golden/paramfeatures/modsecurity3/DefaultApi.conf +++ b/src/test/resources/golden/paramfeatures/modsecurity3/DefaultApi.conf @@ -18,9 +18,9 @@ SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4210001,phase: # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4210002,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.price "!@rx ^(?:0|[0-9]{1,17}0{2})$" "id:4210006,phase:2,block,msg:'Invalid value for property json.price',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210018,phase:2,block,msg:'Invalid value for property json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "@lt 1" "id:4210021,phase:2,block,msg:'Too few array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule &ARGS:/^json\.labels\.(?:array_)?\d{1,9}$/ "@gt 3" "id:4210022,phase:2,block,msg:'Too many array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.labels\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210018,phase:2,block,msg:'Invalid value for property json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:/(?i)^json\.labels\.(?:array_)?\d{1,9}$/ "@lt 1" "id:4210021,phase:2,block,msg:'Too few array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule &ARGS:/(?i)^json\.labels\.(?:array_)?\d{1,9}$/ "@gt 3" "id:4210022,phase:2,block,msg:'Too many array elements for json.labels',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createWidget" SecMarker ENDMEDIA_createWidget_0 diff --git a/src/test/resources/golden/paramfeatures/modsecurity3/schema.json b/src/test/resources/golden/paramfeatures/modsecurity3/schema.json index 4f92f21..10d4e94 100644 --- a/src/test/resources/golden/paramfeatures/modsecurity3/schema.json +++ b/src/test/resources/golden/paramfeatures/modsecurity3/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "createWidget_request" : { "title" : "createWidget_request", diff --git a/src/test/resources/golden/petstore/coraza/PetApi.conf b/src/test/resources/golden/petstore/coraza/PetApi.conf index d4cf987..2434c39 100644 --- a/src/test/resources/golden/petstore/coraza/PetApi.conf +++ b/src/test/resources/golden/petstore/coraza/PetApi.conf @@ -22,9 +22,9 @@ SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210022,phase:2,block,msg SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210034,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS:json.name "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.name "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210094,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. @@ -195,9 +195,9 @@ SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210366,phase:2,block,msg SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210378,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS:json.name "@eq 0" "id:4210389,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.name "!@rx ^.+$" "id:4210390,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210402,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210414,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210426,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210402,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210414,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210426,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210438,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. diff --git a/src/test/resources/golden/petstore/coraza/UserApi.conf b/src/test/resources/golden/petstore/coraza/UserApi.conf index 0b5b7b9..7cb2313 100644 --- a/src/test/resources/golden/petstore/coraza/UserApi.conf +++ b/src/test/resources/golden/petstore/coraza/UserApi.conf @@ -56,21 +56,21 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter: # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. -SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_NAMES "!@rx ^(?:json|json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Handle request bodies by declared media type SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211073,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUsersWithArrayInput_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4211074,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211138,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211150,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211162,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211138,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211150,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211162,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. SecRule REQUEST_BODY "@validateSchema schema.json" "id:4211075,phase:2,block,msg:'JSON schema validation failed for createUsersWithArrayInput',log,auditlog,skipAfter:FAILED_API_CHECKS" @@ -102,21 +102,21 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter: # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. -SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_NAMES "!@rx ^(?:json|json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Handle request bodies by declared media type SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211213,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUsersWithListInput_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4211214,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211218,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211230,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211242,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211254,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211266,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211278,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211290,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211302,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211218,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211230,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211242,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211254,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211266,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211278,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211290,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211302,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. SecRule REQUEST_BODY "@validateSchema schema.json" "id:4211215,phase:2,block,msg:'JSON schema validation failed for createUsersWithListInput',log,auditlog,skipAfter:FAILED_API_CHECKS" diff --git a/src/test/resources/golden/petstore/coraza/schema.json b/src/test/resources/golden/petstore/coraza/schema.json index 05c6a88..72ff97a 100644 --- a/src/test/resources/golden/petstore/coraza/schema.json +++ b/src/test/resources/golden/petstore/coraza/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "ApiResponse" : { "title" : "An uploaded response", diff --git a/src/test/resources/golden/petstore/modsecurity3/PetApi.conf b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf index f613d7a..26f3568 100644 --- a/src/test/resources/golden/petstore/modsecurity3/PetApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf @@ -22,9 +22,9 @@ SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210022,phase:2,block,msg SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210034,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS:json.name "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.name "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210094,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" @@ -192,9 +192,9 @@ SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210366,phase:2,block,msg SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210378,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS:json.name "@eq 0" "id:4210389,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.name "!@rx ^.+$" "id:4210390,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210402,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210414,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210426,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210402,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210414,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210426,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210438,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4210348,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" diff --git a/src/test/resources/golden/petstore/modsecurity3/UserApi.conf b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf index 36e1e38..1732df2 100644 --- a/src/test/resources/golden/petstore/modsecurity3/UserApi.conf +++ b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf @@ -53,21 +53,21 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200525,phase:2,pass,nolog,skipAfter: # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. -SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_NAMES "!@rx ^(?:json|json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200536,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Handle request bodies by declared media type SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211073,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUsersWithArrayInput_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4211074,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211138,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211150,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211162,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211078,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211090,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211102,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211114,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211126,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211138,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211150,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211162,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4211076,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithArrayInput" SecMarker ENDMEDIA_createUsersWithArrayInput_0 @@ -96,21 +96,21 @@ SecRule REQUEST_METHOD "!@within POST" "id:4200565,phase:2,pass,nolog,skipAfter: # Reject unknown parameters: ARGS_NAMES holds query, form, and flattened JSON body # names on both engines, so one allowlist covers them all. -SecRule ARGS_NAMES "!@rx ^(?:json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS_NAMES "!@rx ^(?:json|json\.(?:array_)?\d{1,9}|json\.(?:array_)?\d{1,9}\.id|json\.(?:array_)?\d{1,9}\.username|json\.(?:array_)?\d{1,9}\.firstName|json\.(?:array_)?\d{1,9}\.lastName|json\.(?:array_)?\d{1,9}\.email|json\.(?:array_)?\d{1,9}\.password|json\.(?:array_)?\d{1,9}\.phone|json\.(?:array_)?\d{1,9}\.userStatus)$" "id:4200576,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" # Handle request bodies by declared media type SecRule REQUEST_HEADERS:Content-Type "!@rx ^application/json" "id:4211213,phase:2,pass,nolog,skipAfter:ENDMEDIA_createUsersWithListInput_0" # ModSecurity3 flags unparseable bodies here; Coraza does not set REQBODY_ERROR, # but its @validateSchema rule below rejects malformed JSON instead. SecRule REQBODY_ERROR "!@eq 0" "id:4211214,phase:2,block,msg:'Failed to parse request body',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211218,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211230,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211242,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211254,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211266,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211278,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211290,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211302,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4211218,phase:2,block,msg:'Invalid value for property json.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.username$/ "!@rx ^.+$" "id:4211230,phase:2,block,msg:'Invalid value for property json.0.username',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.firstName$/ "!@rx ^.+$" "id:4211242,phase:2,block,msg:'Invalid value for property json.0.firstName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.lastName$/ "!@rx ^.+$" "id:4211254,phase:2,block,msg:'Invalid value for property json.0.lastName',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.email$/ "!@rx ^.+$" "id:4211266,phase:2,block,msg:'Invalid value for property json.0.email',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.password$/ "!@rx ^.+$" "id:4211278,phase:2,block,msg:'Invalid value for property json.0.password',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.phone$/ "!@rx ^.+$" "id:4211290,phase:2,block,msg:'Invalid value for property json.0.phone',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.(?:array_)?\d{1,9}\.userStatus$/ "!@rx ^[0-9]{1,19}$" "id:4211302,phase:2,block,msg:'Invalid value for property json.0.userStatus',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4211216,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithListInput" SecMarker ENDMEDIA_createUsersWithListInput_0 diff --git a/src/test/resources/golden/petstore/modsecurity3/schema.json b/src/test/resources/golden/petstore/modsecurity3/schema.json index 05c6a88..72ff97a 100644 --- a/src/test/resources/golden/petstore/modsecurity3/schema.json +++ b/src/test/resources/golden/petstore/modsecurity3/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "ApiResponse" : { "title" : "An uploaded response", diff --git a/src/test/resources/golden/urlintparam/coraza/PetApi.conf b/src/test/resources/golden/urlintparam/coraza/PetApi.conf index 61b94d1..af4a325 100644 --- a/src/test/resources/golden/urlintparam/coraza/PetApi.conf +++ b/src/test/resources/golden/urlintparam/coraza/PetApi.conf @@ -22,9 +22,9 @@ SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210022,phase:2,block,msg SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210034,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS:json.name "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.name "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210094,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. @@ -103,9 +103,9 @@ SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210206,phase:2,block,msg SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210218,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS:json.name "@eq 0" "id:4210229,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.name "!@rx ^.+$" "id:4210230,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210242,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210254,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210266,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210242,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210254,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210266,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210278,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" # Coraza implements @validateSchema for JSON Schema; ModSecurity3's is XSD-only, # which is why the modsecurity3 flavor relies on the per-field rules above. diff --git a/src/test/resources/golden/urlintparam/coraza/schema.json b/src/test/resources/golden/urlintparam/coraza/schema.json index 05c6a88..72ff97a 100644 --- a/src/test/resources/golden/urlintparam/coraza/schema.json +++ b/src/test/resources/golden/urlintparam/coraza/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "ApiResponse" : { "title" : "An uploaded response", diff --git a/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf b/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf index 1d94580..db6fcfa 100644 --- a/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf +++ b/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf @@ -22,9 +22,9 @@ SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210022,phase:2,block,msg SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210034,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS:json.name "@eq 0" "id:4210045,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.name "!@rx ^.+$" "id:4210046,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210058,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210070,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210082,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210094,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4210004,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" @@ -100,9 +100,9 @@ SecRule ARGS:json.category.id "!@rx ^[0-9]{1,19}$" "id:4210206,phase:2,block,msg SecRule ARGS:json.category.name "!@rx ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$" "id:4210218,phase:2,block,msg:'Invalid value for property json.category.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule &ARGS:json.name "@eq 0" "id:4210229,phase:2,block,msg:'Missing required property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.name "!@rx ^.+$" "id:4210230,phase:2,block,msg:'Invalid value for property json.name',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210242,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210254,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" -SecRule ARGS:/^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210266,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.photoUrls\.(?:array_)?\d{1,9}$/ "!@rx ^.+$" "id:4210242,phase:2,block,msg:'Invalid value for property json.photoUrls',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.id$/ "!@rx ^[0-9]{1,19}$" "id:4210254,phase:2,block,msg:'Invalid value for property json.tags.0.id',log,auditlog,skipAfter:FAILED_API_CHECKS" +SecRule ARGS:/(?i)^json\.tags\.(?:array_)?\d{1,9}\.name$/ "!@rx ^.+$" "id:4210266,phase:2,block,msg:'Invalid value for property json.tags.0.name',log,auditlog,skipAfter:FAILED_API_CHECKS" SecRule ARGS:json.status "!@rx ^(available|pending|sold)$" "id:4210278,phase:2,block,msg:'Invalid value for property json.status',log,auditlog,skipAfter:FAILED_API_CHECKS" SecAction "id:4210188,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" diff --git a/src/test/resources/golden/urlintparam/modsecurity3/schema.json b/src/test/resources/golden/urlintparam/modsecurity3/schema.json index 05c6a88..72ff97a 100644 --- a/src/test/resources/golden/urlintparam/modsecurity3/schema.json +++ b/src/test/resources/golden/urlintparam/modsecurity3/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "ApiResponse" : { "title" : "An uploaded response", diff --git a/src/test/resources/golden/xmlbody/coraza/schema.json b/src/test/resources/golden/xmlbody/coraza/schema.json index 55a5671..c753a89 100644 --- a/src/test/resources/golden/xmlbody/coraza/schema.json +++ b/src/test/resources/golden/xmlbody/coraza/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "Pet" : { "title" : "Pet", diff --git a/src/test/resources/golden/xmlbody/modsecurity3/schema.json b/src/test/resources/golden/xmlbody/modsecurity3/schema.json index 55a5671..c753a89 100644 --- a/src/test/resources/golden/xmlbody/modsecurity3/schema.json +++ b/src/test/resources/golden/xmlbody/modsecurity3/schema.json @@ -2,7 +2,6 @@ "$schema" : "http://json-schema.org/draft-07/schema#", "title" : "OpenAPI Schema Definitions", "description" : "JSON Schema definitions generated from OpenAPI specification", - "type" : "object", "definitions" : { "Pet" : { "title" : "Pet",