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.
Prerequisites
Confirm the following before changing any edge configuration:
- Nginx 1.22+ built with
http_ssl_moduleandhttp_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/, withcrs-setup.confand 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 asgunicornso 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/, plussystemctl reload nginxrights. - A baseline of the largest expected
GetCapabilitiesdocument andGetFeatureInfopayload, 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.
Related
- Parent cluster: Security Boundary Mapping for OGC Services
- Sibling guide: Optimizing PostgreSQL PostGIS Connection Limits
- Fallback Routing Strategies for Tile Servers
- GeoNode vs MapProxy Architecture Comparison
- Up to the parent section: Core Portal Architecture & Security Boundaries