Securing MapProxy with Nginx and ModSecurity

A step-by-step hardening runbook for fronting MapProxy with an Nginx reverse proxy and a ModSecurity web application firewall, tuned so that legitimate WMS, WMTS, and tile traffic survives inspection while malicious payloads are rejected at the edge.

This guide sits under the Security Boundary Mapping for OGC Services guide and the broader Core Portal Architecture & Security Boundaries framework, which treats the public edge as the outermost trust boundary — the layer that must sanitize every OGC request before it reaches a MapProxy worker. Where that overview maps that boundary conceptually, this page is the hands-on implementation: how to terminate TLS and shape traffic in Nginx, how to calibrate the OWASP Core Rule Set so geospatial coordinate arrays do not trip XSS and SQLi signatures, and how to prove the combined stack passes clean tile requests while blocking abuse. When choosing where this proxy sits relative to the rendering tier, the GeoNode vs MapProxy Architecture Comparison clarifies which component owns caching versus origin rendering.

The request inspection pipeline below shows how Nginx and ModSecurity sanitize OGC traffic at the edge before it ever reaches the MapProxy worker pool.

Nginx and ModSecurity OGC Request Inspection Pipeline A client request enters the Nginx edge trust boundary, which terminates TLS, applies per-IP rate limiting and buffering, then forwards to ModSecurity running the OWASP Core Rule Set with scoped OGC rule exclusions. A clean OGC request is proxied to the MapProxy backend, which serves the response from its tile cache or an upstream WMS. A request that trips a CRS signature is rejected at the edge with a 403 and a correlatable entry in modsec_audit.log, never reaching MapProxy. Client WMS / WMTS / tiles NGINX EDGE — TRUST BOUNDARY Nginx TLS · rate limit buffering ModSecurity CRS + OGC rule exclusions MapProxy backend WSGI worker pool Tile cache / upstream WMS 403 Forbidden modsec_audit.log clean OGC request blocked

Prerequisites

Confirm the following before changing any edge configuration:

  • Nginx 1.22+ built with http_ssl_module and http_v2_module, plus the ModSecurity-nginx connector and libmodsecurity v3.0.8+ (the v3 engine, not the legacy mod_security2 Apache module).
  • The OWASP Core Rule Set (CRS) 4.x unpacked to /etc/nginx/modsec/crs/, with crs-setup.conf and the rule files included by your main ModSecurity config.
  • A running MapProxy 1.16+ instance reachable on a private loopback or back-end subnet (for example 127.0.0.1:8080), served by a WSGI runner such as gunicorn so worker counts can be tuned.
  • A valid TLS certificate and key for the portal hostname (for example tiles.example.gov), readable by the Nginx worker user.
  • Shell access with permission to edit files under /etc/nginx/ and /etc/nginx/modsec/, plus systemctl reload nginx rights.
  • A baseline of the largest expected GetCapabilities document and GetFeatureInfo payload, so buffer and body-size limits can be sized rather than guessed.

Step-by-step implementation

1. Define the upstream and TLS-terminating server

Nginx is the primary ingress controller for MapProxy. Because tile requests are stateless and bursty, the upstream block should hold keepalive connections open to prevent socket exhaustion during tile storms, and the server block terminates TLS once at the edge so the back end only ever sees plaintext loopback traffic.

upstream mapproxy_backend {
    server 127.0.0.1:8080;
    keepalive 64;                       # reuse sockets across bursty tile requests
}

server {
    listen 443 ssl http2;
    server_name tiles.example.gov;

    ssl_certificate     /etc/nginx/tls/tiles.example.gov.crt;
    ssl_certificate_key /etc/nginx/tls/tiles.example.gov.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # Propagate the real client identity for audit logging and cache keys
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_set_header Connection "";     # required for upstream keepalive
}

2. Route OGC endpoints with the right buffering policy

MapProxy serves two distinct traffic shapes: small, cacheable tiles and large streamed WMS/GetFeatureInfo responses. Leave proxy_buffering on globally so the proxy absorbs back-end latency for tiles, but disable it on streaming endpoints where buffering a large raster in memory causes thrashing.

    # Global buffering for tile delivery
    proxy_buffering on;
    proxy_buffer_size 4k;
    proxy_buffers 8 4k;

    location /tiles/ {
        proxy_pass http://mapproxy_backend/tiles/;
    }

    location /wms/ {
        proxy_pass http://mapproxy_backend/wms/;
        proxy_buffering off;            # stream large WMS / GetFeatureInfo responses
        proxy_read_timeout 300s;
    }

    location /wmts/ {
        proxy_pass http://mapproxy_backend/wmts/;
    }

3. Bound oversized requests and rate-limit abusive clients

Malformed BBOX parameters and oversized GetFeatureInfo bodies can exhaust MapProxy worker threads before any rendering happens. Reject them in Nginx with strict header-buffer and body-size limits, and add a per-IP rate-limit zone to blunt cache stampedes and tile scraping. Configure proxy_next_upstream to retry only on error and timeout so genuine MapProxy 500s and 404s surface instead of being silently masked.

# http {} context — declare the rate-limit zone once
limit_req_zone $binary_remote_addr zone=mapproxy_req:10m rate=30r/s;

server {
    # server {} context
    limit_req zone=mapproxy_req burst=60 nodelay;
    limit_req_status 429;

    large_client_header_buffers 4 16k;  # absorb long but legitimate OGC query strings
    client_max_body_size 10m;           # reject oversized GetFeatureInfo payloads

    location / {
        proxy_next_upstream error timeout;   # never retry on http_500 / http_404
        proxy_next_upstream_tries 1;
        proxy_connect_timeout 5s;
        proxy_pass http://mapproxy_backend;
    }
}

4. Enable ModSecurity and calibrate OGC rule exclusions

ModSecurity inspection must be calibrated or it will break legitimate OGC traffic: XML namespaces, JSON coordinate arrays, and comma-and-decimal BBOX values routinely trip CRS XSS and SQLi signatures. Keep the engine fully on for authentication and metadata paths, and scope narrow exclusions only to the WMS/WMTS endpoints that need them. The mechanism mirrors the policy-as-code posture described in Implementing RBAC for Multi-Tenant GIS Portals — every exclusion is explicit, commented, and version-controlled rather than a blanket SecRuleEngine Off.

# /etc/nginx/modsec/main.conf — libmodsecurity v3
SecRuleEngine On
SecRequestBodyAccess On
SecResponseBodyAccess Off

# Drop CRS 941100 (XSS) for WMS GetMap — coordinate arrays look like script payloads
SecRule REQUEST_URI "@beginsWith /wms/" \
    "id:10001,phase:2,pass,nolog,ctl:ruleRemoveById=941100"

# Drop CRS 942100 (SQLi) only when BBOX is a pure numeric/comma/decimal list
SecRule ARGS:BBOX "@rx ^[\d\.\-,]+$" \
    "id:10002,phase:2,pass,nolog,ctl:ruleRemoveById=942100"

# Allow XML namespaces in GetFeatureInfo responses
SecRule RESPONSE_CONTENT_TYPE "@contains xml" \
    "id:10003,phase:3,pass,nolog,ctl:ruleRemoveById=941160"

Wire the policy into the Nginx location blocks that proxy to MapProxy:

    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/main.conf;

5. Reload and confirm the stack is live

sudo nginx -t                         # validate Nginx + ModSecurity config parse
sudo systemctl reload nginx
# confirm ModSecurity loaded with the expected engine mode
grep -i "ModSecurity" /var/log/nginx/error.log | tail -n 3

Verification

Confirm the edge passes clean OGC requests, preserves headers, and blocks abuse. A capabilities request should return 200 and stream through untouched:

curl -v "https://tiles.example.gov/wms/?SERVICE=WMS&REQUEST=GetCapabilities" \
     -H "X-Debug-Mode: true"

Confirm a numeric BBOX survives the SQLi exclusion while an injection attempt is still blocked:

# Legitimate request — expect HTTP 200
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://tiles.example.gov/wms/?SERVICE=WMS&REQUEST=GetMap&BBOX=-13.5,52.1,-13.0,52.4"

# Injection attempt in a non-excluded arg — expect HTTP 403
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://tiles.example.gov/wms/?SERVICE=WMS&LAYERS=base';DROP+TABLE+parcels;--"

Confirm the rate limiter trips after the configured burst, and that the audit log records each decision:

# Fire 100 rapid requests; expect a mix of 200 and 429 once burst is exceeded
seq 100 | xargs -I{} -P20 curl -s -o /dev/null -w "%{http_code}\n" \
  "https://tiles.example.gov/tiles/1/0/0.png" | sort | uniq -c

# Each blocked request leaves a correlatable rule_id in the audit log
sudo tail -n 20 /var/log/modsec_audit.log

Healthy output shows GetCapabilities returning 200, the numeric-BBOX GetMap returning 200, the injection attempt returning 403, and the burst test producing 429s only after the first 60 requests.

Troubleshooting matrix

Symptom Likely cause Fix
Legitimate GetMap returns 403 with a rule hit in /var/log/modsec_audit.log CRS XSS/SQLi rule matching coordinate arrays or BBOX decimals Correlate the rule_id in the audit log with the offending arg; add a scoped ctl:ruleRemoveById for that endpoint only (Step 4)
Intermittent 502 Bad Gateway under load MapProxy WSGI worker pool saturated Raise gunicorn --workers; watch upstream sockets with ss -tunap; verify keepalive is set on the upstream
Large GetCapabilities truncated or upstream sent too big header proxy_buffer_size smaller than the capability document headers Increase proxy_buffer_size / proxy_buffers to exceed the largest expected response header
WMS GetFeatureInfo stalls or buffers slowly proxy_buffering on holding the streamed response in memory Set proxy_buffering off on the /wms/ location and raise proxy_read_timeout
Clients hit 429 during normal browsing Rate-limit zone too tight for legitimate tile fan-out Raise rate / burst on limit_req_zone; key on $binary_remote_addr so shared NAT clients are not over-penalized
ModSecurity changes appear to have no effect Engine in DetectionOnly or rules file not included Confirm SecRuleEngine On and that modsecurity_rules_file points at the active config; reload after every edit
Silent failures masking MapProxy errors proxy_next_upstream retrying on http_500/http_404 Restrict to proxy_next_upstream error timeout so application errors surface

For directive-level detail, consult the Nginx Proxy Module documentation and validate coordinate-parameter handling against the OGC Web Map Service standard. Coordinate this edge hardening with the database-tier limits in Optimizing PostgreSQL PostGIS Connection Limits so a request that clears the WAF still cannot exhaust the spatial connection pool.