Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
96 changes: 96 additions & 0 deletions docs/engine-behavior.md
Original file line number Diff line number Diff line change
@@ -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 <xsd>"` 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"`.
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@
<version>${junit-version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit-version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
Expand Down
82 changes: 82 additions & 0 deletions samples/multipart.yaml
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions samples/oas31.yaml
Original file line number Diff line number Diff line change
@@ -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"]
57 changes: 57 additions & 0 deletions samples/paramfeatures.yaml
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions samples/xmlbody.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading