Preventing WKT/WKB Injection in Spatial Queries

A geometry payload is untrusted input, and treating a WKT or WKB string as safe because it “looks like coordinates” is how spatial injection happens. This guide sits beneath security boundaries in spatial QA and shows how to stop it at the boundary: why concatenating geometry into SQL is exploitable, how to bind geometry as a parameter instead, how to validate untrusted WKT server-side before it reaches a query, and how to gate against resource-exhaustion payloads with size and vertex limits. The QA angle is that these defenses are testable — a suite can prove the injection surface is closed with the same rigor it proves a topology rule.

Why concatenated geometry is exploitable

The vulnerability is identical in shape to classic SQL injection: when a WKT string arrives from a user or an upstream feed and is concatenated into a query, a crafted payload can break out of the geometry literal and append arbitrary SQL. A value like POLYGON((0 0,...))'); DROP TABLE parcels;-- closes the string and injects a statement. Even without a breakout, a hostile geometry can be a resource-exhaustion vector: a WKT with millions of vertices or deeply nested GEOMETRYCOLLECTIONs can exhaust memory in the parser. Both are prevented by never building SQL from geometry text and by bounding what the parser will accept.

Concatenated versus parameterised geometry input Two horizontal paths from an untrusted WKT string to the database. The upper path concatenates the string into SQL text, so the parser receives attacker-controlled syntax and the statement structure is under the caller's control; it is marked as exploitable. The lower path binds the string as a parameter, so the statement is parsed and its structure fixed before the value arrives, and the value can only ever be data; it is marked as safe from structural injection. A note beneath both paths records that parameterisation does not bound size or vertex count, so a separate resource guard is still required. Untrusted WKT request body · upload Concatenated — exploitable f"... ST_GeomFromText('{wkt}')" payload becomes SQL text parser reads attacker syntax statement structure is theirs Parameterised — structurally safe ST_GeomFromText(%s, %s) value rides the bind channel structure fixed at parse time value can only be data PostGIS executes Binding fixes the structural hole and nothing else — a 40 MB single-ring polygon is still a valid parameter, so a size and vertex bound is a separate, mandatory guard.

Defense reference

Threat Defense Layer
SQL breakout via WKT Parameterized binding, never concatenation Application
Malformed WKT crashing parser ST_GeomFromText in a guarded transaction Database
Vertex-count exhaustion ST_NPoints limit before insert Database
Payload-size exhaustion Byte-length cap on the input Application
Nested collection bomb Reject GEOMETRYCOLLECTION depth Application

Step-by-step implementation

The pattern targets psycopg2, PostGIS 3.x and Shapely 2.x, binding geometry safely and bounding untrusted input.

Step 1 — Bind geometry as a parameter, never concatenate

Pass the WKT as a bound parameter so the driver escapes it; the database parses it with ST_GeomFromText, and a breakout attempt becomes an invalid-geometry error, not executed SQL.

import psycopg2

def insert_parcel(conn, wkt: str, srid: int = 3857):
    with conn.cursor() as cur:
        cur.execute(
            "INSERT INTO parcels (geom) VALUES (ST_GeomFromText(%s, %s))",
            (wkt, srid),          # bound parameters — the driver escapes them
        )

Step 2 — Validate and bound untrusted WKT before it reaches SQL

Parse with Shapely first so unparseable or oversized payloads are rejected at the application boundary, never reaching the database.

from shapely import from_wkt, get_num_points

MAX_BYTES = 1_000_000
MAX_VERTICES = 100_000

def sanitize_wkt(wkt: str) -> str:
    if len(wkt.encode()) > MAX_BYTES:
        raise ValueError("geometry payload too large")
    geom = from_wkt(wkt)                       # raises on malformed WKT
    if get_num_points(geom) > MAX_VERTICES:
        raise ValueError("geometry has too many vertices")
    if geom.geom_type == "GeometryCollection":
        raise ValueError("nested collections not accepted")
    return wkt

Step 3 — Guard the database side too

Enforce the vertex bound in SQL as defense in depth, so a payload that bypasses the application layer is still rejected.

ALTER TABLE parcels ADD CONSTRAINT geom_vertex_cap
  CHECK (ST_NPoints(geom) <= 100000);

Step 4 — Never echo the payload into logs

A rejected payload must be logged by id and reason, never by raw coordinates — both to avoid leaking location PII and to avoid log-injection, the same rule the security boundaries work sets for all untrusted geometry.

The four properties a geometry gate must check

Binding is necessary and nowhere near sufficient. A geometry that arrives as a bound parameter is still parsed, still allocated, and still handed to GEOS — so the gate in front of it has four separate jobs, each catching a class the others cannot see. Running them in this order matters, because each is cheaper than the one after it and rejects inputs the next would have to allocate memory to evaluate.

Size, before parsing. Check the raw byte length of the payload against a hard cap chosen from what your application legitimately accepts. This is the only check that can be made before any allocation, and it is the one that stops the simplest denial-of-service: a multi-megabyte WKT string that costs nothing to send and a great deal to parse. A cap expressed in bytes is not a substitute for a vertex cap, but it runs first because it is free.

Structure, at parse time. Parse with a library, never with a regular expression, and reject anything that fails to parse rather than attempting to repair it. Repair belongs to trusted internal data; on an untrusted boundary a malformed payload is a rejection, because a repair routine given adversarial input is a much larger attack surface than a parser. Assert the parsed geometry’s type against what the endpoint accepts — an endpoint expecting a Polygon should reject a GeometryCollection outright rather than discovering the mismatch three joins later.

Complexity, after parsing. Vertex count is the quantity that actually predicts cost, because GEOS predicates are superlinear in it. A single valid polygon with two million vertices passes every size heuristic that counts features and then makes an intersection query run for minutes. Count coordinates on the parsed object and reject above a threshold derived from your slowest predicate, not from what looks large.

Extent, before it reaches the query. Assert the geometry’s bounds lie inside the area your service serves. This is a correctness check as much as a security one — it catches degree-versus-metre confusion and axis swaps as a side effect — but its security value is real: a query envelope spanning the entire planet forces a full scan regardless of how good the index is.

Guard Rejects Cost to evaluate Runs before
Byte-length cap Oversized payloads Free — length of a string Any parsing
Parse + type assertion Malformed input, wrong geometry type One parse Any predicate
Vertex-count cap Superlinear predicate cost One coordinate count Any spatial query
Bounds containment Planet-scale envelopes, unit confusion One bounds call Index lookup
Cost-ordered rejection funnel for untrusted geometry A funnel narrowing left to right. The first and widest stage caps byte length and runs before parsing, rejecting oversized payloads at zero cost. The second stage parses and asserts geometry type, rejecting malformed input and wrong types. The third stage caps vertex count, rejecting geometries whose complexity would make predicates superlinear. The fourth and narrowest stage asserts bounds containment within the served extent, rejecting planet-scale envelopes and unit confusion. Surviving geometry passes to the parameterised query. A caption notes that the ordering is by evaluation cost, so the cheapest guard always rejects first. 1 · Byte cap before any parse cost: free 2 · Parse + type reject, never repair cost: one parse 3 · Vertex cap predicate cost driver 4 · Bounds inside served extent Bound param query runs oversized malformed · wrong type too complex out of extent Ordered by evaluation cost, so the cheapest guard always rejects first — and nothing expensive is ever spent on input that was never going to be accepted.

There is a fifth guard that is not a check at all: a statement timeout. Every one of the four gates above is a prediction about cost, and predictions are wrong occasionally. A per-statement timeout on the database session turns the residual case from an outage into a failed request, and it is the only defence that works against a payload whose cost you did not anticipate. Set it on the connection used for untrusted queries, not globally, so that a legitimate long-running maintenance job is unaffected.

Verification pattern

Prove the boundary rejects a breakout payload and an oversized one, as a gate that runs on every push.

import pytest

def test_rejects_sql_breakout():
    with pytest.raises(ValueError):
        sanitize_wkt("POLYGON((0 0,1 0,1 1,0 1,0 0))'); DROP TABLE parcels;--")

def test_rejects_vertex_bomb():
    huge = "LINESTRING(" + ",".join(f"{i} 0" for i in range(200_000)) + ")"
    with pytest.raises(ValueError):
        sanitize_wkt(huge)

A parameterized insert makes the first test’s payload a harmless invalid-geometry string even if it reached the query — but proving the application layer rejects it first is defense in depth.

Where the guards belong in the request path

Placing the four guards is as consequential as writing them. They belong at the trust boundary — the first point where an external value enters your process — and not in the data-access layer where the query is finally assembled. A guard in the repository class runs after the payload has already been logged by request middleware, cached by a framework, and possibly serialised into a background job. By then the untrusted string has been copied into three places the guard will never see.

Guard placement: at the trust boundary, not at the query Two request paths. The upper path places the guard late: the payload flows from the request handler through request logging, a serialisation cache, and a job queue, and only then reaches the guard before the query — three components have already handled the raw untrusted value. The lower path places the guard immediately after the request handler, so everything downstream, including logging, cache and queue, only ever sees geometry that has already been validated and bounded. Late guard — raw payload already copied three times request handler request log cache job queue guard query Early guard — only validated geometry travels onward request handler guard request log cache · queue bound parameter → query Dashed boxes handled the raw untrusted value. Moving the guard left does not add a check — it shrinks the blast radius of the one you already had. The rejected-payload log line records the size, type and vertex count that failed — never the payload itself.

Failure modes and edge cases

  1. f-string SQL. Building the query with an f-string or % formatting reintroduces the breakout; always pass geometry as a bound parameter.
  2. Trusting WKB because it is binary. WKB is not inherently safe — a malformed or oversized WKB blob still exhausts the parser; apply the same size and vertex bounds.
  3. Validation after the query. Parsing the WKT only after it is inserted is too late; sanitize at the boundary before any SQL runs.
  4. Empty and null bypass. A None or empty payload can slip past a length check and null-poison a predicate; reject non-geometry explicitly.
  5. Coordinates in error messages. Echoing the offending WKT into an exception message leaks location data into logs; report id and reason only.

Conclusion

Preventing WKT/WKB injection is the same discipline as any injection defense applied to geometry: bind it as a parameter, validate and bound it at the application boundary before any SQL runs, enforce a vertex cap in the database as defense in depth, and never echo the payload. Because each defense is testable, a suite can prove the spatial injection surface is closed. For the wider security context, return to security boundaries in spatial QA.