Shielding GeoServer from Oversized BBOX Requests
This guide bounds OGC requests by their shape rather than their rate: an extent too large for the requested zoom, an image size far beyond any display, or a scale that forces the engine to draw a national dataset into one image. It sits under API Gateway, Rate Limiting & Quota Enforcement, within the Core Portal Architecture & Security Boundaries framework.
Prerequisites
- GeoServer 2.22 or newer with administrative access to the service and layer settings.
- An edge proxy able to inspect and reject query parameters before proxying — the Nginx configuration from securing MapProxy with Nginx and ModSecurity is a suitable place.
- The published extent and the largest legitimate image size for each service, agreed with whoever maintains the map clients.
Why rate limits do not cover this
A rate limit bounds how many requests arrive; it says nothing about how expensive one of them is. A single GetMap asking for a 20000 × 20000 pixel image over a national extent is one request against any budget, and it can occupy a rendering thread for minutes while allocating several gigabytes of heap. Ten such requests arrive comfortably inside any rate limit and take the engine down.
The third row is the one most portals miss. A request for the whole country at 256 × 256 pixels passes a dimension check easily and a bounding-box check if the layer’s extent is national — and it forces the engine to read every feature in the layer in order to draw a handful of pixels. Scale rules, not size rules, are what stop it.
Step-by-step implementation
1. Set the service-level ceilings in GeoServer
GeoServer has native limits for exactly this, and they are off or generous by default. Set them first, because they are the backstop that applies even to requests that reach the engine by another path.
# WMS service settings (Services → WMS), expressed as the underlying properties
# Maximum rendering memory per request, in kilobytes. A request needing more is
# refused rather than allocated — this is the single most valuable setting here.
maxRequestMemory=65536
# Wall-clock ceiling for one rendering request, in seconds.
maxRenderingTime=60
# Maximum number of rendering errors before the request is abandoned.
maxRenderingErrors=100
# Hard ceiling on output image dimensions.
maxOutputWidth=4096
maxOutputHeight=4096
maxRequestMemory is the important one: it is evaluated from the requested dimensions before the image is allocated, so an absurd request fails immediately and cheaply instead of triggering the memory pressure described in exporting GeoServer metrics to Prometheus.
2. Constrain each layer by scale
Per-layer scale rules stop the extent-versus-pixels mismatch, and they double as cartography: a parcel layer drawn at national scale is unreadable as well as expensive.
<!-- A style rule that simply refuses to draw the layer above a scale threshold.
Below the denominator the layer renders; above it, nothing is drawn and the
query is never issued. -->
<FeatureTypeStyle>
<Rule>
<Name>parcels-detail</Name>
<MaxScaleDenominator>25000</MaxScaleDenominator>
<PolygonSymbolizer>
<Fill><CssParameter name="fill">#dfe7ec</CssParameter></Fill>
<Stroke>
<CssParameter name="stroke">#4b5f6b</CssParameter>
<CssParameter name="stroke-width">0.4</CssParameter>
</Stroke>
</PolygonSymbolizer>
</Rule>
</FeatureTypeStyle>
3. Reject the worst shapes at the edge
The engine’s own limits protect the engine; an edge check protects everything in between — the proxy’s worker, the connection slot, the queue position. Compute the extent area and the implied scale in the proxy and refuse before proxying.
# Reject obviously oversized WMS requests before they reach the backend.
map $arg_width $wms_w { default 0; "~^[0-9]{1,5}$" $arg_width; }
map $arg_height $wms_h { default 0; "~^[0-9]{1,5}$" $arg_height; }
server {
listen 443 ssl;
server_name portal.example.gov;
location /geoserver/wms {
# Dimensions must be present, numeric, and within the published maximum.
if ($wms_w = 0) { return 400 '{"error":"invalid_width"}'; }
if ($wms_h = 0) { return 400 '{"error":"invalid_height"}'; }
if ($arg_width ~ "^[0-9]{5,}$") { return 400 '{"error":"width_too_large"}'; }
if ($arg_height ~ "^[0-9]{5,}$") { return 400 '{"error":"height_too_large"}'; }
# BBOX must be four plain numbers — nothing else is a coordinate.
if ($arg_bbox !~ "^-?[0-9.]+,-?[0-9.]+,-?[0-9.]+,-?[0-9.]+$") {
return 400 '{"error":"invalid_bbox"}';
}
proxy_pass http://geoserver_backend;
}
}
For the area and scale arithmetic — which Nginx cannot do natively — either move the check into a small authorisation subrequest, or rely on the per-layer scale rules from step 2. A subrequest is worth it on a portal with many layers of very different densities.
# shape_guard.py — the area and scale check, as an authorisation subrequest
MAX_AREA_DEG2 = 4.0 # per layer in practice; one value shown for brevity
MIN_SCALE_DENOM = 500 # refuse absurdly zoomed-in requests too
def check(bbox: str, width: int, height: int) -> tuple[bool, str]:
try:
minx, miny, maxx, maxy = (float(v) for v in bbox.split(","))
except ValueError:
return False, "invalid_bbox"
if maxx <= minx or maxy <= miny:
return False, "degenerate_bbox"
area = (maxx - minx) * (maxy - miny)
if area > MAX_AREA_DEG2:
return False, "extent_too_large"
# Ground units per pixel; a very large value means a whole region drawn tiny.
units_per_pixel = (maxx - minx) / max(width, 1)
if units_per_pixel > 0.02:
return False, "scale_mismatch"
return True, "ok"
4. Return a rejection the client can act on
A 400 with an empty body produces a support ticket. Name the constraint and the value that violated it, so whoever wrote the client can fix it without asking.
The scale case deserves the clearest message, because from the client’s side a scale-suppressed layer looks exactly like a broken one: the request succeeds, the image is blank, and nothing in the response says why. Returning an explicit rejection rather than an empty image turns an unexplained blank map into a fixable client bug.
Give large requests a supported route
Rejecting an oversized request is only half an answer, because the need behind it is often legitimate: somebody wants the whole layer, or a large print, and the portal has told them no without telling them how. A rejection that names an alternative converts an obstacle into a supported workflow.
Name the route in the rejection body. A message that says “extent too large — use the bulk export at this address” resolves itself; one that says “bad request” becomes a support ticket, and then a request to raise the limit.
Verification
# 1. A normal viewport request still works
curl -s -o /dev/null -w '%{http_code}\n' \
"https://portal.example.gov/geoserver/wms?service=WMS&request=GetMap&layers=parcels&bbox=-1.30,50.90,-1.28,50.92&width=512&height=512&format=image/png&srs=EPSG:4326"
# expect: 200
# 2. An absurd image size is refused at the edge, not by the engine
curl -s -w '\n%{http_code}\n' \
"https://portal.example.gov/geoserver/wms?service=WMS&request=GetMap&layers=parcels&bbox=-1.30,50.90,-1.28,50.92&width=20000&height=20000&format=image/png&srs=EPSG:4326"
# expect: 400 with error width_too_large — and no entry in the GeoServer log
# 3. A national extent at tile dimensions is refused for scale
curl -s -w '\n%{http_code}\n' \
"https://portal.example.gov/geoserver/wms?service=WMS&request=GetMap&layers=parcels&bbox=-8,49,2,61&width=256&height=256&format=image/png&srs=EPSG:4326"
# expect: 400 with error scale_mismatch
# 4. The engine's own memory ceiling is in force as a backstop
curl -s "https://portal.example.gov/geoserver/rest/settings.json" -u "$GS_ADMIN" \
| grep -o '"maxRequestMemory":[0-9]*'
# expect: the configured value, not 0
# 5. Rejections are cheap: no renderer time is consumed
kubectl logs deploy/geoserver -n geoportal --since=5m | grep -c 'GetMap'
# expect: a count matching only the requests that passed the edge checks
Check 5 is the point of the whole exercise. If rejected requests still appear in the engine’s log, the checks are running too late and the expensive part of the exposure is unchanged.
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| Legitimate print exports are refused | Dimension ceiling set from screen sizes, not print sizes | Raise the ceiling for a dedicated print path with its own limits |
| Blank images returned instead of an error | Scale suppression in the style, with no edge check | Add the explicit scale rejection so the client learns why |
| The engine still runs out of memory | maxRequestMemory unset, so dimensions are allocated before checking |
Set it; it is evaluated before allocation |
| Valid requests fail the bbox pattern | Coordinates in scientific notation or with a CRS suffix | Widen the pattern deliberately, keeping it a strict numeric shape |
| Area limit rejects a legitimate regional layer | One global area limit across layers of different extents | Set the maximum area per layer, from its published extent |
| Rejections spike after a client update | The client stopped tiling and now requests one large image | Talk to the client owner; point them at the tile endpoint |
| Requests pass the edge but fail in the engine | A second route to the engine bypasses the proxy | Close the direct route; the engine limits are a backstop, not the control |
FAQ
Will these limits break legitimate large-format printing?
They will if the same ceiling is applied everywhere, which is why print belongs on its own path. A print export is a slow, deliberate, low-volume operation, and it can have generous dimensions with tight concurrency — one at a time per tenant is often right — while the interactive path keeps small dimensions and high concurrency.
Is maxRequestMemory enough on its own?
It is the most valuable single setting and it is not sufficient. It bounds memory, not time: a query over an enormous extent returning few features allocates almost nothing and can still occupy a rendering thread for minutes. Pair it with a rendering time limit and with per-layer scale rules.
How do I choose the maximum extent area per layer?
From the layer’s published extent and its intended use rather than from an abstract figure. A parcel layer used at street level has no legitimate national request; a coastline layer used as context does. Setting one global area limit across layers of very different densities produces either an ineffective limit or a stream of false rejections.
Should the check live at the edge or in the engine?
Both, for different reasons. The engine’s limits are the backstop that applies to any path reaching it, including internal callers and anything that bypasses the proxy. The edge check is what makes rejection cheap — no worker occupied, no connection held, no queue position consumed — and it is the one that matters when the portal is already under load.
Why return an error rather than an empty image for a scale-suppressed layer?
Because an empty image is indistinguishable from a broken layer, and the client cannot tell whether to retry, zoom in, or report a fault. An explicit rejection naming the scale threshold turns a mysterious blank map into a client-side bug with a one-line fix.
Related
- Enforcing Per-Tenant WMS Request Quotas with Nginx — bounding how often a caller may ask, alongside what they may ask for.
- Throttling WFS GetFeature with Envoy Rate Limits — the equivalent shape and cost controls for feature traffic.
- Fallback Routing Strategies for Tile Servers — what happens when the renderer saturates anyway.
Up one level: API Gateway, Rate Limiting & Quota Enforcement.