diff --git a/docs/configuration.md b/docs/configuration.md index a551cf1..3ed3ef6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,6 +18,11 @@ 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 | +| `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/docs/engine-behavior.md b/docs/engine-behavior.md new file mode 100644 index 0000000..d358bd1 --- /dev/null +++ b/docs/engine-behavior.md @@ -0,0 +1,96 @@ +# 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. +- 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. + +## 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. + +## 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` +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. + +## 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"`. 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/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/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/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/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/JsonSchemaGenerator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java index 2faf265..491d281 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. @@ -67,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; } @@ -112,6 +114,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); @@ -129,6 +139,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. @@ -151,20 +234,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,12 +302,22 @@ 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"); + // 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; } @@ -230,8 +342,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"); @@ -246,17 +362,14 @@ 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); - } - } + // 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); } } @@ -269,8 +382,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"); + } } /** @@ -356,10 +476,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); } @@ -368,12 +489,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 ccae54f..4321838 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,18 @@ 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; + // 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; @@ -155,6 +167,34 @@ 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); + } + + 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"); @@ -179,7 +219,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"; @@ -193,13 +233,20 @@ 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"; // 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; @@ -377,6 +424,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 +447,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 @@ -467,12 +537,93 @@ 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); } // Add the flattened properties to the parameter param.vendorExtensions.put(MODSECURITY_MODEL_PROPERTIES, flattenedProperties); + } else if (param.isBodyParam && param.isArray) { + // Root-level JSON array body: flatten as an array at the root. Element + // index 0 stands in for every element (generalized to a regex later); + // without this the ARGS_NAMES allowlist is empty and every element key + // is rejected as an unknown parameter. + // Coraza also lists the bare "json" container node in ARGS_NAMES for + // root arrays (it does not for object bodies). + argsAllowlist.add("json"); + 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) { @@ -502,23 +653,79 @@ 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); - param.setPattern(patternString); } + // content: parameter — the value is an encoded document (e.g. JSON in a + // query string) that the engines do not parse per-field; cap its length + // and rely on the allowlist entry for the name. + if (param.getContent() != null && !param.getContent().isEmpty() && !param.isBodyParam) { + // RE2 (Coraza) rejects repeat counts above 1000, so the cap is clamped + int cap = Math.min(param.getMaxLength() != null ? param.getMaxLength() : 1000, 1000); + patternString = "^[\\s\\S]{0," + cap + "}$"; + } + // 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) + ")?$"; + } + // Always write back: spec-provided patterns arrive DefaultCodegen-mangled + // (/.../-delimited, backslashes doubled), and the template and + // buildPathMatchRegex read param.pattern directly. + param.setPattern(patternString); LOGGER.debug("param: {}, validation: {}, pattern: {}", param.hasValidation, param.pattern); LOGGER.debug("Parameter: {}, data type: {}, isString: {}, max length: {}", param.baseName, param.getDataType(), param.isString, param.getMaxLength()); } + // Security-scheme parameters never appear in allParams; an apiKey in the + // query string must not be rejected as an unknown parameter. Header/cookie + // keys need no exemption (undeclared headers and cookies are not blocked). + if (co.authMethods != null) { + for (org.openapitools.codegen.CodegenSecurity auth : co.authMethods) { + if (Boolean.TRUE.equals(auth.isApiKey) && Boolean.TRUE.equals(auth.isKeyInQuery) + && auth.keyParamName != null) { + argsAllowlist.add(escapeRegexLiteral(auth.keyParamName)); + } + } + } + // One regex matches the route AND validates path parameter values: each {param} // is replaced with that parameter's validation pattern. Works on both engines, // unlike the Coraza-only @restpath/ARGS_PATH (issue #42). Must run after the // param loop so parameter patterns exist. - co.vendorExtensions.put(MODSECURITY_PATH_REGEX_KEY, buildPathMatchRegex(co)); + co.vendorExtensions.put(MODSECURITY_PATH_REGEX_KEY, basePathRegex + buildPathMatchRegex(co)); co.vendorExtensions.put(MODSECURITY_ARGS_ALLOWLIST, String.join("|", argsAllowlist)); } @@ -544,6 +751,14 @@ public 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()) { @@ -620,6 +835,167 @@ public 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 + * 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. @@ -652,6 +1028,28 @@ private static List lookupModelVars(CodegenProperty prop, Map 1, + * 100 -> 2, ...); -1 when the value is not a power of ten >= 10. + */ + public 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 @@ -693,12 +1091,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); } } @@ -707,18 +1106,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("^")) { @@ -761,9 +1235,68 @@ 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", "/(?i)^" + 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 + && !valueSchema.isModel && !valueSchema.isMap && !valueSchema.isArray + && !valueSchema.isFreeFormObject) { + prop.vendorExtensions.put("x-oashield-argTarget", "/(?i)^" + body + "\\.[^.]{1,64}$/"); + String valuePattern = sanitizeSpecPattern(valueSchema.pattern); + if (valuePattern == null || valuePattern.isEmpty() || isInvalidPattern(valuePattern)) { + valuePattern = patternGenerationService.getPropertyPattern(valueSchema); + } + prop.vendorExtensions.put("x-oashield-pattern", valuePattern); + } + // No required-presence rule: an empty map produces no ARGS keys on + // ModSecurity3, making {} indistinguishable from an absent property. + for (int i = 1; i <= PROP_INDEX_MAX; i++) { + prop.vendorExtensions.put(PROP_INDEX_KEY + "_" + i, globalParamIndex++); + } + return; } - prop.vendorExtensions.put("x-oashield-argTarget", indexed ? "/^" + body + "$/" : path); + prop.vendorExtensions.put("x-oashield-argTarget", indexed ? "/(?i)^" + body + "$/" : path); // Type pattern: for arrays validate each element against the item type CodegenProperty typeSource = prop; @@ -778,16 +1311,27 @@ 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. + pattern = "^(?:" + stripAnchors(pattern) + ")?$"; + } prop.vendorExtensions.put("x-oashield-pattern", pattern); // Required-presence rules only for non-array paths: per-element "required" has no // meaningful &-count form. Nested required properties are guarded on their parent // being present (JSON Schema semantics: required applies only within its object). - if (prop.required && !indexed) { + // readOnly properties may legally be omitted from requests even when required. + if (prop.required && !indexed && !prop.isReadOnly) { prop.vendorExtensions.put("x-oashield-requiredRule", true); String parent = path.substring(0, path.lastIndexOf('.') + 1); if (!parent.equals(JSON_ARGS_PREFIX)) { - prop.vendorExtensions.put("x-oashield-parentSelector", "/^" + escapeRegexLiteral(parent) + "/"); + prop.vendorExtensions.put("x-oashield-parentSelector", "/(?i)^" + escapeRegexLiteral(parent) + "/"); } } @@ -808,37 +1352,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) { @@ -875,9 +1392,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. * @@ -893,7 +1435,9 @@ private void generateJsonSchema(Map 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(); @@ -912,8 +1456,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); } @@ -1012,6 +1561,26 @@ public Modsecurity3Generator() { "Emit SecRuleEngine/SecRequestBodyAccess/SecDefaultAction in mainconfig.conf; " + "set false when your existing WAF configuration already defines them") .defaultValue(Boolean.toString(includeEngineConfig))); + additionalProperties.put("blockOtherMedia", false); + cliOptions.add(new CliOption("unknownMediaTypePolicy", + "Handling of declared request media types the WAF cannot inspect " + + "(e.g. application/octet-stream, text/plain): 'pass' or 'block'") + .defaultValue(unknownMediaTypePolicy)); + 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/PatternGenerationService.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/PatternGenerationService.java index dea6e54..2ad174b 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/PatternGenerationService.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/PatternGenerationService.java @@ -88,30 +88,33 @@ else if (param.isEnum || param.isEmail || param.isDate || param.isDateTime) { } // Fallback else { + // Path parameters must not match across segment boundaries, otherwise + // /user/a/b would route into /user/{username}. + String anyChar = param.isPathParam ? "[^/]" : "."; // Use minLength/maxLength if set for string types if (!minLengthPatternString.isEmpty() || !maxLengthPatternString.isEmpty()) { String min = minLengthPatternString.isEmpty() ? "0" : minLengthPatternString; String pattern; if (!min.isEmpty() && !maxLengthPatternString.isEmpty()) { - pattern = "^.{" + min + "," + maxLengthPatternString + "}$"; + pattern = "^" + anyChar + "{" + min + "," + maxLengthPatternString + "}$"; } else if (!min.isEmpty() && maxLengthPatternString.isEmpty()) { if (min.equals("1")) { - pattern = "^.+$"; + pattern = "^" + anyChar + "+$"; } else if (min.equals("0")) { - pattern = "^.*$"; + pattern = "^" + anyChar + "*$"; } else { - pattern = "^.{" + min + ",}$"; + pattern = "^" + anyChar + "{" + min + ",}$"; } } else if (min.isEmpty() && !maxLengthPatternString.isEmpty()) { // If required, min should be 1; if not required, min is 0 String minVal = isRequired ? "1" : "0"; - pattern = "^.{" + minVal + "," + maxLengthPatternString + "}$"; + pattern = "^" + anyChar + "{" + minVal + "," + maxLengthPatternString + "}$"; } else { - pattern = isRequired ? "^.+$" : "^.*$"; + pattern = isRequired ? "^" + anyChar + "+$" : "^" + anyChar + "*$"; } patternString = pattern; } else { - patternString = isRequired ? "^.+$" : "^.*$"; + patternString = isRequired ? "^" + anyChar + "+$" : "^" + anyChar + "*$"; } } return patternString; @@ -240,20 +243,21 @@ else if (isTimeFormat(param)) { // Binary: hex encoded with bounded length return "[0-9a-fA-F]{0,10000}"; } else if (param.isEnum) { - 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/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 df214a0..e5b6c1f 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}} @@ -48,21 +66,32 @@ SecRule ARGS_POST:{{paramName}} "{{#exclusiveMaximum}}@ge{{/exclusiveMaximum}}{{ # names on both engines, so one allowlist covers them all. SecRule ARGS_NAMES "!@rx ^(?:{{vendorExtensions.x-codegen-argsAllowlist}})$" "id:{{vendorExtensions.x-codegen-globalIndex_14}},phase:2,block,msg:'Unknown parameter detected',{{logAction}},skipAfter:FAILED_API_CHECKS" -# Handle Models +# Handle request bodies by declared media type +{{#vendorExtensions.x-codegen-optionalBody}} +# requestBody is optional (the OAS3 default): a request without a body skips body checks +SecRule &REQUEST_HEADERS:Content-Type "@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_26}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" +{{/vendorExtensions.x-codegen-optionalBody}} {{#consumes}} {{#isJson}} -SecRule REQUEST_HEADERS:Content-Type "!@rx ^{{mediaTypeRegex}}" "id:{{vendorExtensions.x-codegen-globalIndex_16}},phase:2,pass,nolog,skipAfter:ENDJSON_{{operationId}}" +SecRule REQUEST_HEADERS:Content-Type "!@rx ^{{mediaTypeRegex}}" "id:{{oasGateId}},phase:2,pass,nolog,skipAfter:ENDMEDIA_{{operationId}}_{{consumeIndex}}" # 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:{{vendorExtensions.x-codegen-globalIndex_17}},phase:2,block,msg:'Failed to parse request body',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:{{oasBodyErrId}},phase:2,block,msg:'Failed to parse request body',{{logAction}},skipAfter:FAILED_API_CHECKS" {{#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" @@ -77,36 +106,72 @@ 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}} {{#isCoraza}} # 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 {{schemaRulePath}}" "id:{{vendorExtensions.x-codegen-globalIndex_18}},phase:2,block,msg:'JSON schema validation failed for {{operationId}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule REQUEST_BODY "@validateSchema {{schemaRulePath}}" "id:{{oasSchemaId}},phase:2,block,msg:'JSON schema validation failed for {{operationId}}',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/isCoraza}} {{/vendorExtensions.validateBodySchema}} -SecAction "id:{{vendorExtensions.x-codegen-globalIndex_19}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" +SecAction "id:{{oasPassId}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" -SecMarker ENDJSON_{{operationId}} +SecMarker ENDMEDIA_{{operationId}}_{{consumeIndex}} {{/isJson}} {{#isXml}} -SecRule REQUEST_HEADERS:Content-Type "!@rx ^{{mediaTypeRegex}}" "id:{{vendorExtensions.x-codegen-globalIndex_20}},phase:2,pass,nolog,skipAfter:ENDXML_{{operationId}}" -SecRule REQBODY_ERROR "!@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_23}},phase:2,block,msg:'Failed to parse request body',{{logAction}},skipAfter:FAILED_API_CHECKS" +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" +{{#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}}" -# TODO: Implement XML schema validation (issue #14 covers JSON objects) -SecAction "id:{{vendorExtensions.x-codegen-globalIndex_24}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" - -SecMarker ENDXML_{{operationId}} +SecMarker ENDMEDIA_{{operationId}}_{{consumeIndex}} {{/isXml}} +{{#isFormLike}} +# 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 ^{{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" +SecAction "id:{{oasPassId}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" + +SecMarker ENDMEDIA_{{operationId}}_{{consumeIndex}} +{{/isFormLike}} +{{#isOtherMedia}} +# Declared media type the WAF cannot inspect; handling set by unknownMediaTypePolicy +SecRule REQUEST_HEADERS:Content-Type "!@rx ^{{mediaTypeRegex}}" "id:{{oasGateId}},phase:2,pass,nolog,skipAfter:ENDMEDIA_{{operationId}}_{{consumeIndex}}" +{{#blockOtherMedia}} +SecAction "id:{{oasPassId}},phase:2,block,msg:'Uninspectable media type blocked by policy',{{logAction}},skipAfter:FAILED_API_CHECKS" +{{/blockOtherMedia}} +{{^blockOtherMedia}} +SecAction "id:{{oasPassId}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" +{{/blockOtherMedia}} + +SecMarker ENDMEDIA_{{operationId}}_{{consumeIndex}} +{{/isOtherMedia}} +{{#isWildcardAll}} +# consumes */*: any media type is accepted +SecAction "id:{{oasPassId}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" +{{/isWildcardAll}} {{/consumes}} {{^consumes}} SecAction "id:{{vendorExtensions.x-codegen-globalIndex_25}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" {{/consumes}} -# Action has consumes, but it wasn't matched - validation must have failed for both JSON and XML -SecAction "id:{{vendorExtensions.x-codegen-globalIndex_21}},{{logAction}},block,phase:2,msg:'Body processing failed - invalid data content?'" +# Declared consumes exist but the request Content-Type matched none of them +SecAction "id:{{vendorExtensions.x-codegen-globalIndex_21}},{{logAction}},block,phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_{{operationId}} 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 a64f3f3..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 @@ -50,13 +50,13 @@ public void defaultFlavorEmitsPerFieldRulesWithoutCorazaOperators() throws IOExc assertFalse(conf.contains("SecRule REQUEST_BODY \"@validateSchema"), "modsecurity3 output must not use @validateSchema"); // path parameter validation is embedded in the route regex - assertTrue(conf.contains("SecRule REQUEST_FILENAME \"!@rx ^/pet/(?:[0-9]{1,19})$\""), + assertTrue(conf.contains("SecRule REQUEST_FILENAME \"!@rx ^/v2/pet/(?:[0-9]{1,19})$\""), "path param pattern should be embedded in the path regex"); // 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/GoldenFileTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java new file mode 100644 index 0000000..72e3b92 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/GoldenFileTest.java @@ -0,0 +1,102 @@ +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", "multipart", "paramfeatures", "xmlbody", "oas31"); + 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/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/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/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..1858fa2 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase1FixesTest.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.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 the bare json + // container (Coraza lists it for root arrays), index keys, and fields + assertTrue(conf.contains( + "^(?: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("/(?i)^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/generators/modsecurity3/tests/Phase3FixesTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase3FixesTest.java new file mode 100644 index 0000000..7ff0019 --- /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:/(?i)^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/generators/modsecurity3/tests/Phase4EnforcementTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/Phase4EnforcementTest.java new file mode 100644 index 0000000..9861fe1 --- /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:/(?i)^json\\.labels\\.(?:array_)?\\d{1,9}$/ \"@lt 1\""), + "minItems count rule missing:\n" + conf); + assertTrue(conf.contains("SecRule &ARGS:/(?i)^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/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/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..8d56ee2 --- /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:/(?i)^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/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/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/java/com/oashield/openapi/integration/actions/HttpRequestAction.java b/src/test/java/com/oashield/openapi/integration/actions/HttpRequestAction.java index 0618996..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,65 @@ 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: + * 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..bb26fb3 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,52 @@ 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); + } + + /** + * 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. + */ + 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..b7653b5 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,53 @@ 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"); + } + + // 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/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/features/modsecurity_rule_generation.feature b/src/test/resources/features/modsecurity_rule_generation.feature index fbda397..774ca22 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,80 @@ 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 + 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 | + | 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 | + | 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 | + + 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 | 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..8741e09 --- /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 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: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: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 ENDMEDIA_addContact_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_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 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: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: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 ENDMEDIA_addDog_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_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: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 request bodies by declared media type + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findItems" + +# 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 + +## 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..dfce7ac --- /dev/null +++ b/src/test/resources/golden/composed/coraza/schema.json @@ -0,0 +1,94 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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..0e0f11c --- /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 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: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 ENDMEDIA_addContact_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_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 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: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 ENDMEDIA_addDog_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_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: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 request bodies by declared media type + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findItems" + +# 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 + +## 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..dfce7ac --- /dev/null +++ b/src/test/resources/golden/composed/modsecurity3/schema.json @@ -0,0 +1,94 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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..56caec7 --- /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 ^/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))(?:,(?:(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. +SecRule ARGS_NAMES "!@rx ^(?:status)$" "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_findPetsByStatus" + +# 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 + +## 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 ^/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 ^(?:.+)(?:,(?:.+)){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. +SecRule ARGS_NAMES "!@rx ^(?:tags)$" "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_findPetsByTags" + +# 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 + +## 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 ^/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" + +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 request bodies by declared media type + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_listPets" + +# 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 + +## 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..72ff97a --- /dev/null +++ b/src/test/resources/golden/getparam/coraza/schema.json @@ -0,0 +1,153 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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..56caec7 --- /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 ^/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))(?:,(?:(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. +SecRule ARGS_NAMES "!@rx ^(?:status)$" "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_findPetsByStatus" + +# 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 + +## 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 ^/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 ^(?:.+)(?:,(?:.+)){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. +SecRule ARGS_NAMES "!@rx ^(?:tags)$" "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_findPetsByTags" + +# 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 + +## 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 ^/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" + +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 request bodies by declared media type + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_listPets" + +# 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 + +## 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..72ff97a --- /dev/null +++ b/src/test/resources/golden/getparam/modsecurity3/schema.json @@ -0,0 +1,153 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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/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..f9f8f69 --- /dev/null +++ b/src/test/resources/golden/multipart/coraza/schema.json @@ -0,0 +1,17 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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..f9f8f69 --- /dev/null +++ b/src/test/resources/golden/multipart/modsecurity3/schema.json @@ -0,0 +1,17 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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/oas31/coraza/DefaultApi.conf b/src/test/resources/golden/oas31/coraza/DefaultApi.conf new file mode 100644 index 0000000..a659516 --- /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:/(?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. +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..3a00450 --- /dev/null +++ b/src/test/resources/golden/oas31/coraza/schema.json @@ -0,0 +1,60 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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..b17162a --- /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:/(?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" + +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..3a00450 --- /dev/null +++ b/src/test/resources/golden/oas31/modsecurity3/schema.json @@ -0,0 +1,60 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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/paramfeatures/coraza/DefaultApi.conf b/src/test/resources/golden/paramfeatures/coraza/DefaultApi.conf new file mode 100644 index 0000000..2ff0c58 --- /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:/(?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" +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..10d4e94 --- /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", + "definitions" : { + "createWidget_request" : { + "title" : "createWidget_request", + "type" : "object", + "properties" : { + "price" : { + "type" : "integer", + "multipleOf" : 100 + }, + "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..83d6150 --- /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:/(?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 + + +# 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..10d4e94 --- /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", + "definitions" : { + "createWidget_request" : { + "title" : "createWidget_request", + "type" : "object", + "properties" : { + "price" : { + "type" : "integer", + "multipleOf" : 100 + }, + "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 new file mode 100644 index 0000000..2434c39 --- /dev/null +++ b/src/test/resources/golden/petstore/coraza/PetApi.conf @@ -0,0 +1,307 @@ + +# 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 ^/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" + + +# 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 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: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:/(?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. +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 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" +SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDMEDIA_addPet_1 + + +# 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 + +## 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 ^/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" + +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. +SecRule ARGS_NAMES "!@rx ^(?:)$" "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_deletePet" + +# 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 + +## 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 ^/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))(?:,(?:(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. +SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" + +# 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 + +## 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 ^/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 ^(?:.+)(?:,(?:.+)){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. +SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200134,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type + +SecAction "id:4200145,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" + +# 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 + +## 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 ^/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" + + +# 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 + +SecAction "id:4200185,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" + +# 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 + +## 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 ^/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" + + +# 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 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: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:/(?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. +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 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" +SecAction "id:4210352,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDMEDIA_updatePet_1 + + +# 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 + +## 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 ^/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: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 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 + + +# 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 + +## 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 ^/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: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 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 + + +# 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 + +## 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..58857ea --- /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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200346,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteOrder" + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200386,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getInventory" + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200426,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getOrderById" + +# 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 + +## 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 ^/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" + + +# 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 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: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: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 ENDMEDIA_placeOrder_0 + + +# 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 + +## 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..7cb2313 --- /dev/null +++ b/src/test/resources/golden/petstore/coraza/UserApi.conf @@ -0,0 +1,306 @@ + +# 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 ^/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" + + +# 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 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: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: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 ENDMEDIA_createUser_0 + + +# 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 + +## 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 ^/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" + + +# 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|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:/(?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" +SecAction "id:4211076,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithArrayInput" + +SecMarker ENDMEDIA_createUsersWithArrayInput_0 + + +# 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 + +## 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 ^/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" + + +# 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|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:/(?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" +SecAction "id:4211216,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_createUsersWithListInput" + +SecMarker ENDMEDIA_createUsersWithListInput_0 + + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200627,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteUser" + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200667,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getUserByName" + +# 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 + +## 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 ^/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: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 +# 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 request bodies by declared media type + +SecAction "id:4200707,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_loginUser" + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200747,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_logoutUser" + +# 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 + +## 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 ^/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" + + +# 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 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: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: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 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 + +## 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..72ff97a --- /dev/null +++ b/src/test/resources/golden/petstore/coraza/schema.json @@ -0,0 +1,153 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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..26f3568 --- /dev/null +++ b/src/test/resources/golden/petstore/modsecurity3/PetApi.conf @@ -0,0 +1,301 @@ + +# 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 ^/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" + + +# 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 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: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:/(?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" + +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" +SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDMEDIA_addPet_1 + + +# 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 + +## 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 ^/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" + +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. +SecRule ARGS_NAMES "!@rx ^(?:)$" "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_deletePet" + +# 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 + +## 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 ^/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))(?:,(?:(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. +SecRule ARGS_NAMES "!@rx ^(?:status)$" "id:4200094,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type + +SecAction "id:4200105,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByStatus" + +# 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 + +## 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 ^/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 ^(?:.+)(?:,(?:.+)){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. +SecRule ARGS_NAMES "!@rx ^(?:tags)$" "id:4200134,phase:2,block,msg:'Unknown parameter detected',log,auditlog,skipAfter:FAILED_API_CHECKS" + +# Handle request bodies by declared media type + +SecAction "id:4200145,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_findPetsByTags" + +# 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 + +## 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 ^/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" + + +# 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 + +SecAction "id:4200185,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" + +# 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 + +## 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 ^/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" + + +# 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 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: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:/(?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" + +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" +SecAction "id:4210352,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDMEDIA_updatePet_1 + + +# 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 + +## 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 ^/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: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 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 + + +# 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 + +## 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 ^/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: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 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 + + +# 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 + +## 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..c83db6a --- /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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200346,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteOrder" + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200386,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getInventory" + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200426,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getOrderById" + +# 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 + +## 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 ^/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" + + +# 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 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: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 ENDMEDIA_placeOrder_0 + + +# 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 + +## 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..1732df2 --- /dev/null +++ b/src/test/resources/golden/petstore/modsecurity3/UserApi.conf @@ -0,0 +1,294 @@ + +# 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 ^/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" + + +# 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 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: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 ENDMEDIA_createUser_0 + + +# 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 + +## 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 ^/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" + + +# 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|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:/(?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 + + +# 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 + +## 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 ^/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" + + +# 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|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:/(?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 + + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200627,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_deleteUser" + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200667,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getUserByName" + +# 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 + +## 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 ^/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: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 +# 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 request bodies by declared media type + +SecAction "id:4200707,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_loginUser" + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200747,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_logoutUser" + +# 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 + +## 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 ^/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" + + +# 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 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: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 + +## 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..72ff97a --- /dev/null +++ b/src/test/resources/golden/petstore/modsecurity3/schema.json @@ -0,0 +1,153 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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..af4a325 --- /dev/null +++ b/src/test/resources/golden/urlintparam/coraza/PetApi.conf @@ -0,0 +1,133 @@ + +# 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 ^/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" + + +# 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 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: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:/(?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. +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 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" +SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDMEDIA_addPet_1 + + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" + +# 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 + +## 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 ^/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" + + +# 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 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: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:/(?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. +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 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" +SecAction "id:4210192,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDMEDIA_updatePet_1 + + +# 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 + +## 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..72ff97a --- /dev/null +++ b/src/test/resources/golden/urlintparam/coraza/schema.json @@ -0,0 +1,153 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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..db6fcfa --- /dev/null +++ b/src/test/resources/golden/urlintparam/modsecurity3/PetApi.conf @@ -0,0 +1,127 @@ + +# 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 ^/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" + + +# 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 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: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:/(?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" + +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" +SecAction "id:4210008,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_addPet" + +SecMarker ENDMEDIA_addPet_1 + + +# 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 + +## 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 ^/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" + + +# 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 request bodies by declared media type + +SecAction "id:4200065,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_getPetById" + +# 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 + +## 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 ^/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" + + +# 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 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: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:/(?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" + +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" +SecAction "id:4210192,phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_updatePet" + +SecMarker ENDMEDIA_updatePet_1 + + +# 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 + +## 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..72ff97a --- /dev/null +++ b/src/test/resources/golden/urlintparam/modsecurity3/schema.json @@ -0,0 +1,153 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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/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..c753a89 --- /dev/null +++ b/src/test/resources/golden/xmlbody/coraza/schema.json @@ -0,0 +1,39 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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..c753a89 --- /dev/null +++ b/src/test/resources/golden/xmlbody/modsecurity3/schema.json @@ -0,0 +1,39 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "title" : "OpenAPI Schema Definitions", + "description" : "JSON Schema definitions generated from OpenAPI specification", + "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/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] diff --git a/src/test/resources/specs/phase3-features.yaml b/src/test/resources/specs/phase3-features.yaml new file mode 100644 index 0000000..ac51997 --- /dev/null +++ b/src/test/resources/specs/phase3-features.yaml @@ -0,0 +1,98 @@ +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] + 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 + 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