Build1 distinct publisher3 min readUpdated
Storing 2.7 million features in three tables was the right call. Handing the tile server one view per layer put a user-editable string into DDL, and an empty layer was the only alarm.
The Engineer · Build desk
Compiled by The EngineerSomething wrong?How this is made
The generic schema did not create the injection surface. It relocated it. A table per layer puts DDL behind every "new layer" click and produces a migration story that never ends [22]; three tables with a `layer_id` and a JSON attribute column give one schema, one set of indexes, and layers that are only rows in a metadata table [1]. For roughly 2.7 million features that is the right trade [3]. But the tile server still wants physical objects. Martin discovers spatial tables and views in a schema and publishes each as an MVT endpoint [5], and in this deployment it was told to publish views and not tables [6]. So something has to turn a metadata row into a view at runtime, on save. Here that something is a Django `post_save` signal on the `Layer` model [7], and the string it interpolated into `CREATE OR REPLACE VIEW` was the layer's display name, taken straight from a `CharField` with no validation on it [10][11].
The exploit matters less than the empty layer. A layer named `1` cannot be created as an unquoted identifier, Postgres refused, the exception was caught, logged and swallowed, and that layer sat for months serving no tiles while nobody noticed [13][14]. The author's own summary of it is exact: a user-supplied string that breaks SQL syntax is the same string that could complete it [15]. Look at the detection path, though. Nothing alerted. The tell was an absence, and it was found by accident [13]. When a code path manufactures database objects out of user input, the clumsy failure and the hostile success arrive through the same line, so the failure needs to be loud.
The counts are 1,820,288 points, 697,009 lines and 171,830 polygons [2], which is 2,689,127 rows across the three tables [4], published today through 106 views [8]. The number that costs money is 23: the layers whose names contain capital letters [17], about 22 percent of the published views [21]. Every existing view was created unquoted, so the catalog holds them folded to lower case [17]. Start quoting identifiers correctly and `T53_Traffic_Cameras` becomes a second object beside `t53_traffic_cameras`, the old one keeps existing, and Martin keeps publishing both [18]. The safe API is not a drop-in. The naming function now has to reproduce what Postgres was doing implicitly, folding only ASCII A through Z, because four layers have Cyrillic names and `str.lower()` would push them away from the names already in the catalog [19][20].
That is the bill for pushing per-tenant structure into metadata rows. The view name is derived state that no column owns: computed once from an editable field at creation, re-derived at every lookup, and its correctness defined by 106 rows already written into the system catalog rather than by anything in the model. `psycopg2.sql` closes the injection [16]. It does not give the name a home.
Follow any of these and your For You feed starts watching them — no settings page required.
build
A Tile Cache With No Invalidation Hook Belongs at Zero, With a Comment Saying Why1 distinct publisher
build
Your "Index Only Scan" Did 2,847 Heap Fetches: Covering Indexes Are a Vacuum Problem1 distinct publisher
build
A NetworkPolicy in another repo broke invoicing while every dashboard reported success1 distinct publisher
build
The optional EntityManager is the bug: moving the transaction boundary into AsyncLocalStorage1 distinct publisher
Ranked by verification strength, evidence, and original report placement.
A Django post_save signal on the Layer model creates one view per layer, selecting id, feature_attrs, geom and layer_id from the feature table joined to project_layer, project_layergroup and project_project, with WHERE f.layer_id = <id>.
There are 106 of these per-layer views now.
A user creates a layer in the browser and five seconds later, one reload_interval, it is a live tile endpoint with no migration, deploy or restart.
The GIS stores features in three tables (points, lines, polygons) with a layer_id foreign key and a JSON column for attributes, rather than a table per layer; the author describes this as one schema, one set of indexes, and layers that are just rows in a metadata table.
Row counts: project_pointfeature 1,820,288 rows, project_linefeature 697,009 rows, project_polygonfeature 171,830 rows.
The system is described as a web GIS with about 2.7 million features: 1.8 million points, 697,000 lines, 172,000 polygons.
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 24, 2026
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Detailed first-party code and counts, no external corroboration
The source shows the offending code, the Martin configuration, the generated SQL, the Postgres error text, the psycopg2.sql rewrite and the normalizer implementation, plus specific row and object counts — unusually concrete for a practitioner post-mortem. It remains a single self-reporting publisher with no independent review, no privilege inventory, and an internal inconsistency between 106 views and 102 layers diffed, which caps confidence in the numbers.
One disclosed deployment, no third-party uptake
Adoption evidence is confined to a single production system: Martin auto-publishing 106 views over PostGIS with ~2.7 million features. That is real production usage rather than a demo, but there is no second deployment, no benchmark, no downstream users of the described pattern, and no signal that the one-view-per-layer approach is used elsewhere.
Slightly understated relative to the evidence shown
The headline framing matches the evidence: the post claims a design trade-off produced an injection surface and it shows the code that does so. If anything the author undersells the severity — a DDL path executing arbitrary user-supplied SQL on save with the application role's privileges is described as one of 'four sharp edges' in my own code, with no privilege analysis or disclosure framing, while the harder-to-spot case-folding trap is given the emphasis. Generalisation is not overreached: no claim is made about other systems.
Low distortion pressure; reputational self-disclosure
The author is disclosing a flaw in their own code on a developer blogging platform, which cuts against exaggeration and against vendor promotion: no product is sold, no employer is named, and Martin, PostGIS and psycopg2 are open-source tools mentioned functionally rather than pitched. The residual incentive is reputational — post-mortems earn credibility and engagement, which favours a tidy narrative arc and may encourage understating unresolved gaps such as actual database privileges or exposure.
Mechanism well grounded, scope and impact unverified
High confidence in the technical mechanism and the fix, because the code, configuration and Postgres semantics are shown and independently checkable against documented behaviour. Lower confidence in scope and consequence: one publisher, one deployment, no privilege or exposure data, no exploitation evidence, and unreconciled object counts within the post itself.