Skip to content

Allow Rate Limiting Before ModSecurity Processing #14097

@k11h-de

Description

@k11h-de

Summary

Currently, the nginx-ingress-controller template processes ModSecurity rules before the configuration-snippet directive in location blocks. This prevents rate limiting from being applied before WAF rules, causing unnecessary ModSecurity processing on rate-limited requests and resulting in incorrect HTTP status codes (406 from OWASP CRS instead of 429 from rate limiting).

Current Behavior

In the generated nginx.conf, the order of directives in a location block is:

location ~* "^/" {
    # ... various set directives ...
    
    modsecurity_rules '
    SecRuleEngine On
    ';
    modsecurity_transaction_id "$request_id";
    
    # ... proxy headers ...
    
    limit_req zone=globalzone burst=1 nodelay;
    
    # ... proxy_pass ...
}

This means:

  • Request enters location block
  • ModSecurity processes the request (potentially blocking with 406 if OWASP CRS rules match)
  • Rate limiting is checked (but never reached if ModSecurity already blocked)
  • Request is proxied to backend

Expected Behavior

Rate limiting should be evaluated before ModSecurity processing:

location ~* "^/" {
    # ... various set directives ...
    
    limit_req zone=globalzone burst=1 nodelay;
    
    modsecurity_rules '
    SecRuleEngine On
    ';
    modsecurity_transaction_id "$request_id";
    
    # ... proxy headers and proxy_pass ...
}

This would ensure:

  • Rate limiting is checked first
  • If rate limit exceeded, return 429 immediately
  • Only non-rate-limited requests are processed by ModSecurity
  • Reduced CPU usage (no WAF processing for rate-limited requests)
  • Correct HTTP status codes

Use Case

When protecting applications with both rate limiting and ModSecurity/OWASP CRS:

Current Configuration:

Global ConfigMap:

data:
  enable-modsecurity: "true"
  enable-owasp-modsecurity-crs: "true"
  modsecurity-snippet: |
    SecRuleEngine Off
    SecDefaultAction "phase:2,deny,status:406,log"
  http-snippet: |
    limit_req_zone $binary_remote_addr zone=globalzone:10m rate=10r/m;
    limit_req_status 429;

Ingress Annotations:

metadata:
  annotations:
    nginx.ingress.kubernetes.io/modsecurity-snippet: |
      SecRuleEngine On
    nginx.ingress.kubernetes.io/configuration-snippet: |
      limit_req zone=globalzone burst=1 nodelay;

Problem:

When a client exceeds the rate limit while sending malicious payloads (e.g., XSS attempts), they receive:

HTTP 406 (from OWASP CRS blocking the malicious payload)
Instead of HTTP 429 (rate limit exceeded)
The rate limit is never enforced because ModSecurity blocks the request first.

Why This Matters

Performance: Rate-limited requests shouldn't consume CPU cycles processing ModSecurity rules
Correctness: Rate-limited requests should return 429, not 406
DDoS Protection: Rate limiting is a first line of defense and should execute before expensive WAF processing
Monitoring: Proper status codes (429 vs 406) are important for metrics and alerting

Workaround Limitations

ModSecurity v3 Limitations

ModSecurity v3 removed persistent collections (initcol, setvar on collections), making rate limiting within ModSecurity impossible. The v2 solution would have been:

modsecurity_rules '
SecAction "id:10001,phase:1,pass,nolog,initcol:ip=%{REMOTE_ADDR},setvar:ip.counter=+1,expirevar:ip.counter=60"
SecRule IP:COUNTER "@gt 50" "id:10002,phase:1,deny,status:429"
';

This doesn't work in ModSecurity v3.

Template Ordering

The current template order in nginx.tmpl places ModSecurity before configuration-snippet. The relevant section is around line 1400-1500 in the location block.

Alternative Approaches Tried

✗ server-snippet with limit_req - doesn't work, needs location context
✗ Using built-in nginx.ingress.kubernetes.io/limit-rps - generates code after ModSecurity
✗ ModSecurity phase 1 blocking - no persistent storage in v3

Proposed Solution

Modify the location block template to process configuration-snippet before modsecurity_rules:

Current template order (simplified):

{{ if $location.ModSecurity.Enable }}
modsecurity_rules '
{{ $location.ModSecurity.Snippet }}
';
modsecurity_transaction_id "$request_id";
{{ end }}

{{ if $location.ConfigurationSnippet }}
{{ $location.ConfigurationSnippet }}
{{ end }}

Proposed template order:

{{ if $location.ConfigurationSnippet }}
{{ $location.ConfigurationSnippet }}
{{ end }}

{{ if $location.ModSecurity.Enable }}
modsecurity_rules '
{{ $location.ModSecurity.Snippet }}
';
modsecurity_transaction_id "$request_id";
{{ end }}

Most users don't use both ModSecurity and configuration-snippet together
Those who do likely want rate limiting to run first anyway
Can be mitigated with a feature flag for backward compatibility

Benefits:

Better performance (less CPU on rate-limited requests)
Correct HTTP status codes
Aligns with nginx best practices (cheap checks before expensive ones)
Enables proper DDoS protection with WAF

References

Current template: https://github.com/kubernetes/ingress-nginx/blob/main/rootfs/etc/nginx/template/nginx.tmpl
Custom template documentation: https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/custom-template/
ModSecurity v3 limitations: https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v3.x)#Collections

Environment

Ingress-nginx version: latest
ModSecurity version: v3.0.14
OWASP CRS version: 4.15.0
Kubernetes version: 1.31

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/featureCategorizes issue or PR as related to a new feature.needs-priorityneeds-triageIndicates an issue or PR lacks a `triage/foo` label and requires one.

    Type

    No type

    Projects

    Status

    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions