diff --git a/docs/configuration.md b/docs/configuration.md index 3ed3ef6..0527df6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -33,10 +33,11 @@ Pass them comma-separated: ## Deny behavior and logging -Every generated rule uses the `block` action, so the actual disruptive -behavior is decided in one place: the `SecDefaultAction` emitted at the top of -`mainconfig.conf`. `denyAction`/`denyStatus`/`denyRedirectUrl` control that -line: +When the engine config is emitted (the default), every generated rule uses the +`block` action, so the actual disruptive behavior is decided in one place: the +`SecDefaultAction` emitted at the top of `mainconfig.conf`. +`denyAction`/`denyStatus`/`denyRedirectUrl` control that line (and, when the +engine config is omitted, the inline action described below): ```bash # Return 429 instead of 403 @@ -51,8 +52,12 @@ line: If your ModSecurity/Coraza deployment already configures the engine (rule engine mode, body access, default action), generate only the rules with -`includeEngineConfig=false` — your existing `SecDefaultAction` then decides -what blocking means. +`includeEngineConfig=false`. Because there is then no `SecDefaultAction` of +ours for `block` to inherit — and a host ruleset such as the OWASP Core Rule +Set defaults phase 2 to `pass` — the generated rules emit the disruptive action +(from `denyAction`/`denyStatus`) **inline** instead of `block`, so they still +deny (or, with `denyAction=pass`, run detection-only) regardless of the host's +default action. ## Engine flavors 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 4321838..b165362 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -198,7 +198,14 @@ public void processOpts() { // Real boolean for the mustache section; derived strings so templates stay flat additionalProperties.put("includeEngineConfig", includeEngineConfig); additionalProperties.put("logAction", enableLogging ? "log,auditlog" : "nolog"); - additionalProperties.put("denyActionDirective", buildDenyActionDirective()); + String denyActionDirective = buildDenyActionDirective(); + additionalProperties.put("denyActionDirective", denyActionDirective); + // Per-rule disruptive action. When the engine config is included, rules keep + // the bare `block`, which inherits the emitted SecDefaultAction. When it is + // omitted (rules layered onto a host engine, e.g. Coraza+CRS), there is no + // SecDefaultAction of ours to inherit — and a host like CRS defaults phase 2 + // to `pass` — so emit the deny action inline instead, or the rules no-op. + additionalProperties.put("ruleDisruptiveAction", includeEngineConfig ? "block" : denyActionDirective); } /** @@ -1546,6 +1553,7 @@ public Modsecurity3Generator() { additionalProperties.put("includeEngineConfig", includeEngineConfig); additionalProperties.put("logAction", "log,auditlog"); additionalProperties.put("denyActionDirective", "deny,status:" + denyStatus); + additionalProperties.put("ruleDisruptiveAction", includeEngineConfig ? "block" : "deny,status:" + denyStatus); cliOptions.add(new CliOption("denyAction", "Disruptive action applied when a rule blocks: 'deny', 'drop', 'redirect' or 'pass' (detection-only)") .defaultValue(denyAction)); diff --git a/src/main/resources/modsecurity3/config.mustache b/src/main/resources/modsecurity3/config.mustache index e5b6c1f..2414973 100644 --- a/src/main/resources/modsecurity3/config.mustache +++ b/src/main/resources/modsecurity3/config.mustache @@ -12,51 +12,51 @@ SecRule REQUEST_METHOD "!@within {{httpMethod}}" "id:{{vendorExtensions.x-codege {{#allParams}} {{#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" +SecRule ARGS_GET:{{paramName}} "!@rx {{pattern}}" "id:{{vendorExtensions.x-codegen-globalIndex_6}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule &ARGS_GET:{{paramName}} "@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_22}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule &ARGS_GET:{{paramName}} "@gt 1" "id:{{vendorExtensions.x-codegen-globalIndex_7}},phase:2,{{{ruleDisruptiveAction}}},msg:'Multiple values for non-array parameter',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/isArray}} {{#vendorExtensions.x-codegen-hasArrayMin}} -SecRule &ARGS_GET:{{paramName}} "@lt {{minItems}}" "id:{{vendorExtensions.x-codegen-globalIndex_8}},phase:2,block,msg:'Too few values for array parameter',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:{{paramName}} "@lt {{minItems}}" "id:{{vendorExtensions.x-codegen-globalIndex_8}},phase:2,{{{ruleDisruptiveAction}}},msg:'Too few values for array parameter',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/vendorExtensions.x-codegen-hasArrayMin}} {{#vendorExtensions.x-codegen-hasArrayMax}} -SecRule &ARGS_GET:{{paramName}} "@gt {{maxItems}}" "id:{{vendorExtensions.x-codegen-globalIndex_9}},phase:2,block,msg:'Too many values for array parameter',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule &ARGS_GET:{{paramName}} "@gt {{maxItems}}" "id:{{vendorExtensions.x-codegen-globalIndex_9}},phase:2,{{{ruleDisruptiveAction}}},msg:'Too many values for array parameter',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/vendorExtensions.x-codegen-hasArrayMax}} {{#minimum}} -SecRule ARGS_GET:{{paramName}} "{{#exclusiveMinimum}}@le{{/exclusiveMinimum}}{{^exclusiveMinimum}}@lt{{/exclusiveMinimum}} {{minimum}}" "id:{{vendorExtensions.x-codegen-globalIndex_16}},phase:2,block,msg:'Parameter value below minimum',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:{{paramName}} "{{#exclusiveMinimum}}@le{{/exclusiveMinimum}}{{^exclusiveMinimum}}@lt{{/exclusiveMinimum}} {{minimum}}" "id:{{vendorExtensions.x-codegen-globalIndex_16}},phase:2,{{{ruleDisruptiveAction}}},msg:'Parameter value below minimum',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/minimum}} {{#maximum}} -SecRule ARGS_GET:{{paramName}} "{{#exclusiveMaximum}}@ge{{/exclusiveMaximum}}{{^exclusiveMaximum}}@gt{{/exclusiveMaximum}} {{maximum}}" "id:{{vendorExtensions.x-codegen-globalIndex_17}},phase:2,block,msg:'Parameter value above maximum',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule ARGS_GET:{{paramName}} "{{#exclusiveMaximum}}@ge{{/exclusiveMaximum}}{{^exclusiveMaximum}}@gt{{/exclusiveMaximum}} {{maximum}}" "id:{{vendorExtensions.x-codegen-globalIndex_17}},phase:2,{{{ruleDisruptiveAction}}},msg:'Parameter value above maximum',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/maximum}} {{/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" +SecRule ARGS_POST:{{paramName}} "!@rx {{pattern}}" "id:{{vendorExtensions.x-codegen-globalIndex_10}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule &ARGS_POST:{{paramName}} "@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_23}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule &ARGS_POST:{{paramName}} "@gt 1" "id:{{vendorExtensions.x-codegen-globalIndex_11}},phase:2,{{{ruleDisruptiveAction}}},msg:'Multiple values for non-array parameter',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/isArray}} {{#minimum}} -SecRule ARGS_POST:{{paramName}} "{{#exclusiveMinimum}}@le{{/exclusiveMinimum}}{{^exclusiveMinimum}}@lt{{/exclusiveMinimum}} {{minimum}}" "id:{{vendorExtensions.x-codegen-globalIndex_12}},phase:2,block,msg:'Parameter value below minimum',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule ARGS_POST:{{paramName}} "{{#exclusiveMinimum}}@le{{/exclusiveMinimum}}{{^exclusiveMinimum}}@lt{{/exclusiveMinimum}} {{minimum}}" "id:{{vendorExtensions.x-codegen-globalIndex_12}},phase:2,{{{ruleDisruptiveAction}}},msg:'Parameter value below minimum',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/minimum}} {{#maximum}} -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" +SecRule ARGS_POST:{{paramName}} "{{#exclusiveMaximum}}@ge{{/exclusiveMaximum}}{{^exclusiveMaximum}}@gt{{/exclusiveMaximum}} {{maximum}}" "id:{{vendorExtensions.x-codegen-globalIndex_13}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule REQUEST_HEADERS:{{baseName}} "!@rx {{pattern}}" "id:{{vendorExtensions.x-codegen-globalIndex_18}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule &REQUEST_HEADERS:{{baseName}} "@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_19}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule REQUEST_COOKIES:{{baseName}} "!@rx {{pattern}}" "id:{{vendorExtensions.x-codegen-globalIndex_20}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule &REQUEST_COOKIES:{{baseName}} "@eq 0" "id:{{vendorExtensions.x-codegen-globalIndex_21}},phase:2,{{{ruleDisruptiveAction}}},msg:'Missing required cookie {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/required}} {{/isCookieParam}} {{/pattern}} @@ -64,7 +64,7 @@ SecRule &REQUEST_COOKIES:{{baseName}} "@eq 0" "id:{{vendorExtensions.x-codegen-g # 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 ^(?:{{vendorExtensions.x-codegen-argsAllowlist}})$" "id:{{vendorExtensions.x-codegen-globalIndex_14}},phase:2,block,msg:'Unknown parameter detected',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule ARGS_NAMES "!@rx ^(?:{{vendorExtensions.x-codegen-argsAllowlist}})$" "id:{{vendorExtensions.x-codegen-globalIndex_14}},phase:2,{{{ruleDisruptiveAction}}},msg:'Unknown parameter detected',{{logAction}},skipAfter:FAILED_API_CHECKS" # Handle request bodies by declared media type {{#vendorExtensions.x-codegen-optionalBody}} @@ -76,41 +76,41 @@ SecRule &REQUEST_HEADERS:Content-Type "@eq 0" "id:{{vendorExtensions.x-codegen-g 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:{{oasBodyErrId}},phase:2,block,msg:'Failed to parse request body',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:{{oasBodyErrId}},phase:2,{{{ruleDisruptiveAction}}},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:{{trigger}} "@gt 0" "id:{{depRuleId}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule ARGS:{{selector}} "!@rx {{valuePattern}}" "id:{{ruleId}},phase:2,{{{ruleDisruptiveAction}}},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-parentSelector}} "@gt 0" "id:{{vendorExtensions.x-codegen-propIndex_1}},phase:2,{{{ruleDisruptiveAction}}},msg:'Missing required property {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS,chain" 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" +SecRule &ARGS:{{vendorExtensions.x-oashield-argTarget}} "@eq 0" "id:{{vendorExtensions.x-codegen-propIndex_1}},phase:2,{{{ruleDisruptiveAction}}},msg:'Missing required property {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/vendorExtensions.x-oashield-parentSelector}} {{/vendorExtensions.x-oashield-requiredRule}} {{#vendorExtensions.x-oashield-pattern}} -SecRule ARGS:{{vendorExtensions.x-oashield-argTarget}} "!@rx {{vendorExtensions.x-oashield-pattern}}" "id:{{vendorExtensions.x-codegen-propIndex_2}},phase:2,block,msg:'Invalid value for property {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule ARGS:{{vendorExtensions.x-oashield-argTarget}} "!@rx {{vendorExtensions.x-oashield-pattern}}" "id:{{vendorExtensions.x-codegen-propIndex_2}},phase:2,{{{ruleDisruptiveAction}}},msg:'Invalid value for property {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/vendorExtensions.x-oashield-pattern}} {{#minimum}} -SecRule ARGS:{{vendorExtensions.x-oashield-argTarget}} "{{#exclusiveMinimum}}@le{{/exclusiveMinimum}}{{^exclusiveMinimum}}@lt{{/exclusiveMinimum}} {{minimum}}" "id:{{vendorExtensions.x-codegen-propIndex_3}},phase:2,block,msg:'Property value below minimum for {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule ARGS:{{vendorExtensions.x-oashield-argTarget}} "{{#exclusiveMinimum}}@le{{/exclusiveMinimum}}{{^exclusiveMinimum}}@lt{{/exclusiveMinimum}} {{minimum}}" "id:{{vendorExtensions.x-codegen-propIndex_3}},phase:2,{{{ruleDisruptiveAction}}},msg:'Property value below minimum for {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/minimum}} {{#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" +SecRule ARGS:{{vendorExtensions.x-oashield-argTarget}} "{{#exclusiveMaximum}}@ge{{/exclusiveMaximum}}{{^exclusiveMaximum}}@gt{{/exclusiveMaximum}} {{maximum}}" "id:{{vendorExtensions.x-codegen-propIndex_4}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule &ARGS:{{vendorExtensions.x-oashield-countSelector}} "@lt {{vendorExtensions.x-oashield-countMin}}" "id:{{vendorExtensions.x-codegen-propIndex_5}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule &ARGS:{{vendorExtensions.x-oashield-countSelector}} "@gt {{vendorExtensions.x-oashield-countMax}}" "id:{{vendorExtensions.x-codegen-propIndex_6}},phase:2,{{{ruleDisruptiveAction}}},msg:'Too many array elements for {{baseName}}',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/vendorExtensions.x-oashield-countMax}} {{/vendorExtensions.x-codegen-modelProperties}} {{/isBodyParam}} @@ -118,7 +118,7 @@ SecRule &ARGS:{{vendorExtensions.x-oashield-countSelector}} "@gt {{vendorExtensi {{#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:{{oasSchemaId}},phase:2,block,msg:'JSON schema validation failed for {{operationId}}',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule REQUEST_BODY "@validateSchema {{schemaRulePath}}" "id:{{oasSchemaId}},phase:2,{{{ruleDisruptiveAction}}},msg:'JSON schema validation failed for {{operationId}}',{{logAction}},skipAfter:FAILED_API_CHECKS" {{/isCoraza}} {{/vendorExtensions.validateBodySchema}} SecAction "id:{{oasPassId}},phase:2,pass,nolog,skipAfter:AFTER_CONSUMES_{{operationId}}" @@ -127,12 +127,12 @@ SecMarker ENDMEDIA_{{operationId}}_{{consumeIndex}} {{/isJson}} {{#isXml}} SecRule REQUEST_HEADERS:Content-Type "!@rx ^{{mediaTypeRegex}}" "id:{{oasGateId}},phase:2,pass,nolog,skipAfter:ENDMEDIA_{{operationId}}_{{consumeIndex}}" -SecRule REQBODY_ERROR "!@eq 0" "id:{{oasBodyErrId}},phase:2,block,msg:'Failed to parse request body',{{logAction}},skipAfter:FAILED_API_CHECKS" +SecRule REQBODY_ERROR "!@eq 0" "id:{{oasBodyErrId}},phase:2,{{{ruleDisruptiveAction}}},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" +SecRule XML "@validateSchema {{xsdRulePath}}" "id:{{oasSchemaId}},phase:2,{{{ruleDisruptiveAction}}},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}}" @@ -143,7 +143,7 @@ SecMarker ENDMEDIA_{{operationId}}_{{consumeIndex}} # 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" +SecRule REQBODY_ERROR "!@eq 0" "id:{{oasBodyErrId}},phase:2,{{{ruleDisruptiveAction}}},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}} @@ -152,7 +152,7 @@ SecMarker ENDMEDIA_{{operationId}}_{{consumeIndex}} # 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" +SecAction "id:{{oasPassId}},phase:2,{{{ruleDisruptiveAction}}},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}}" @@ -171,7 +171,7 @@ SecAction "id:{{vendorExtensions.x-codegen-globalIndex_25}},phase:2,pass,nolog,s {{/consumes}} # 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'" +SecAction "id:{{vendorExtensions.x-codegen-globalIndex_21}},{{logAction}},{{{ruleDisruptiveAction}}},phase:2,msg:'Unexpected content type'" SecMarker AFTER_CONSUMES_{{operationId}} diff --git a/src/main/resources/modsecurity3/mainconfig.mustache b/src/main/resources/modsecurity3/mainconfig.mustache index 1e12c70..f49d2bb 100644 --- a/src/main/resources/modsecurity3/mainconfig.mustache +++ b/src/main/resources/modsecurity3/mainconfig.mustache @@ -23,4 +23,4 @@ Include {{classFilename}}.conf # For anything else, deny by default SecMarker FAILED_API_CHECKS -SecAction "id:4220001,{{logAction}},block,phase:2,msg:'Unknown API endpoint'" +SecAction "id:4220001,{{logAction}},{{{ruleDisruptiveAction}}},phase:2,msg:'Unknown API endpoint'" diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/DenyConfigTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/DenyConfigTest.java index b6776f3..b8b26b9 100644 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/DenyConfigTest.java +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/DenyConfigTest.java @@ -108,6 +108,38 @@ public void includeEngineConfigFalseOmitsEngineDirectives() throws IOException { assertFalse(main.contains("SecDefaultAction"), "no SecDefaultAction when includeEngineConfig=false"); assertTrue(main.contains("Include "), "operation includes still emitted"); assertTrue(main.contains("SecMarker FAILED_API_CHECKS"), "catch-all rules still emitted"); + + // With no SecDefaultAction of ours to inherit, the operation rules must + // carry the disruptive action inline; otherwise `block` would no-op on a + // host that defaults phase 2 to `pass` (e.g. Coraza + CRS). + String api = apiConfig(); + assertTrue(api.contains("deny,status:403,msg:'Unknown parameter detected'"), + "rules carry inline deny when engine config is omitted"); + assertFalse(api.contains("block,msg:'Unknown parameter detected'"), + "no bare block to inherit a (missing) default action"); + assertTrue(main.contains("deny,status:403,phase:2,msg:'Unknown API endpoint'"), + "catch-all default-deny also carries the action inline"); + } + + @Test + public void includeEngineConfigFalseHonorsDenyActionInline() throws IOException { + Map props = new HashMap<>(); + props.put("includeEngineConfig", "false"); + props.put("denyAction", "pass"); // detection-only + generate(props); + + String api = apiConfig(); + assertTrue(api.contains("pass,msg:'Unknown parameter detected'"), + "detection-only emits inline pass when engine config is omitted"); + assertFalse(api.contains("block,msg:'Unknown parameter detected'"), "no bare block"); + } + + @Test + public void includeEngineConfigTrueKeepsBareBlock() throws IOException { + generate(new HashMap<>()); // defaults: includeEngineConfig=true + + assertTrue(apiConfig().contains("block,msg:'Unknown parameter detected'"), + "rules keep bare block, inheriting the emitted SecDefaultAction"); } @Test