Let's talk
security

Bulk import is a second front door to every write endpoint you have

A bulk-import endpoint takes a list of rows and the name of the thing to create. The name is in the request body, because one endpoint serves several entities. The permission guarding the endpoint is the import permission.

That is a privilege-escalation hole. A role holding only the import permission can create customers without the customer-create permission, and products without the product-create permission, by naming them in the body. The permission model is intact everywhere except in the one place that can write to everything.

The reason it happens is structural rather than careless. Permission checks are usually declared on the route, as a dependency, evaluated before the handler runs. That works when the resource is in the path — the route for creating a customer knows it needs the customer-create permission because the route is about customers. An import route does not know which permission it needs until it has read the body, and by then the declarative gate has already passed.

So the check moved into the handler: the caller must hold the import permission and the target entity’s own create permission, resolved after parsing the body. Two checks, one of them dynamic, and the dynamic one is the one that matters.

The general rule: when the resource a request acts on is named in the body rather than the path, your route-level authorization is structurally incapable of covering it. Anything polymorphic — bulk import, a generic webhook receiver, a batch endpoint, a jobs queue that takes a task type — has this shape.

The other bypass, which is validation

The second thing an import endpoint routinely gets wrong is doing its own validation.

It is very tempting. The import knows it is handling rows of strings from a spreadsheet, and the real create endpoint expects typed input, so someone writes a mapping layer. That layer is now a second definition of what a valid customer is. It will drift, and the direction it drifts is always the same: the import accepts things the API would refuse, because the import was written to be forgiving about messy spreadsheets.

The implementation here refuses to have a second definition. Each row is validated through the target module’s own create schema — the same object the real endpoint uses — and then created through the same construction logic that endpoint calls, including the per-tenant code uniqueness check and the home-branch resolution. Nothing about validation, uniqueness or tenant scoping has an import-specific path.

The test that proves it is worth copying: after an import runs, the created records are fetched back through the ordinary customer and product endpoints. If the import had written anything the real API would not have produced, that read is where it shows up.

The column template offered for download is derived from the same schema’s field list rather than being a hand-maintained header row, so a new required field appears in the template on the day it appears in the API. A hand-written template is a third definition, and it goes stale faster than the other two.

Partial success without losing the batch

An import of three hundred rows where row forty-one is invalid should not fail three hundred rows. It also should not commit row forty-one’s half-finished side effects.

Each row runs inside a nested transaction. A validation failure or a service-level conflict on that row is caught, recorded against the row with the field-level detail — this field is required, this code already exists — and rolled back to the savepoint. The outer transaction is untouched, so the valid rows commit and the job reports a count of each.

This is the part most hand-rolled importers get subtly wrong, because a failed statement in PostgreSQL aborts the whole transaction unless there is a savepoint to roll back to. Catching the exception in application code is not enough. The connection is already poisoned; every subsequent statement fails with a message about the transaction being aborted, and the resulting bug report says “the import stops after the first bad row” no matter how carefully the loop handles errors.

What the file upload did and did not change

The first version accepted rows as structured data — good enough for a test, useless for the person with a spreadsheet. Adding real file upload was deliberately built as a front end onto the same pipeline rather than a second one: parse the file into rows, hand the rows to the existing routine unchanged. Size cap, delimiter detection, blank-row skipping and cell coercion all live in the parser. Nothing about validation, permissions or partial success was re-implemented.

The coercion rules are the fiddly part and they are all about spreadsheet behaviour rather than about the domain. A blank cell becomes absent rather than an empty string. The words true and false become booleans. A whole number that a spreadsheet stored as a float is turned back into an integer string, so that a quantity field still parses. Every one of those exists because a real file broke without it.

The bug that got through everything else was not in any of that. The shared HTTP client on the web side sets a default JSON content type on every request. That default overrides the automatic boundary detection for multipart uploads, so the server received a body it could not parse and reported both the entity and the file as missing. Type checking passed. The build passed. It failed only when a person uploaded a file in a real browser session.

The fix was to unset the header on that one call rather than change the shared client, which was outside the scope of that piece of work. The more useful lesson is about where it was found: an upload path cannot be verified by any check that does not actually send a file. The serialisation of the request is the thing under test, and every static check in the stack operates above it.

Rules

If the target resource is named in the request body, check its permission in the handler. Route declarations cannot gate what the route does not know yet.

An import must call the same validation and the same creation path as the real endpoint. Prove it by reading the imported records back through the ordinary API.

Give every row its own savepoint. Catching the exception is not enough — the transaction is already unusable.

Derive the import template from the schema. A maintained template is a third source of truth and it will be the stalest one.

Exercise an upload with a real file through a real client. Everything above the wire will pass regardless.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch