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.
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 |
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.
Failure modes and edge cases
- f-string SQL. Building the query with an f-string or
%formatting reintroduces the breakout; always pass geometry as a bound parameter. - 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.
- Validation after the query. Parsing the WKT only after it is inserted is too late; sanitize at the boundary before any SQL runs.
- Empty and null bypass. A
Noneor empty payload can slip past a length check and null-poison a predicate; reject non-geometry explicitly. - 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.