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.

Three shape properties that decide what a single request costs Image dimensions, extent area, and the scale implied by their ratio, each with the resource it consumes and the check that bounds it. PROPERTY CONSUMES BOUND IT WITH image dimensions width x height heap, allocated up front grows with the pixel count a hard maximum per service extent area bbox width x height features fetched and drawn database time, then CPU a maximum area per layer implied scale extent divided by pixels everything, at once a nation drawn into a thumbnail scale denominators per layer

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.

What a shape rejection should tell the caller Three rejection types with the fields each response carries so the client can adjust its request without contacting support. dimensions error: width_too_large max: 4096 sent: 20000 the client can retry smaller extent error: extent_too_large max area, and area sent hint: use the tile endpoint points at the supported path scale error: scale_mismatch layer min denominator scale implied by request explains an empty map

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.

What the caller actually wanted, and where to send them Three underlying needs paired with the supported route the rejection message should name. WHAT THEY WANTED SUPPORTED ROUTE the whole dataset asked for it as one request a scheduled bulk export, or a published snapshot cheaper for both sides than a live extraction a large print A0 at 300 dpi a print path: generous size, one at a time per tenant slow is acceptable; unbounded is not many tiles, quickly the tile endpoint, with a documented rate and a key

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.

Up one level: API Gateway, Rate Limiting & Quota Enforcement.