Injection and access control

What it is

Injection is putting untrusted input into something that interprets it — a SQL query, a shell command, an LDAP filter, a template — so that the input is read as instructions. Broken access control is the system answering “who are you?” correctly and then failing to ask “may you see this?”. Together they are the two classes that hand an attacker data they were never entitled to.

Why it matters

These are the flaws that turn a normal feature into a breach. Injection appears whenever a developer builds a query or a command by joining strings, which is the natural thing to write and almost always the wrong thing. Access control fails at the object level, where a check exists on the list page but not on the detail endpoint, so changing a number in a URL shows someone else’s invoice. Both are cheap to prevent and expensive to discover afterwards.

Core ideas

Never build a query by concatenation

Use parameterised queries or an ORM’s binding. The input is then passed as data and cannot change the structure of the statement.

// Wrong: input becomes part of the query text
db.query(`SELECT * FROM orders WHERE id = '${req.params.id}'`);

// Right: the driver sends the value separately from the statement
db.query("SELECT * FROM orders WHERE id = $1", [req.params.id]);

The same rule applies to shell commands, where the fix is to pass an argument array rather than a command string, and to templates or expression languages, where the fix is usually to pass values rather than to render code.

Ask “may this user see this row?” at the object level

Authorisation is per object, not per route. A list endpoint that filters by tenant does not protect the detail endpoint; each one needs its own check against the resource.

Broken   GET /invoices/1042   -> loads invoice 1042, no owner check
Fixed    GET /invoices/1042   -> load invoice; if invoice.accountId !==
                                 session.accountId -> 404

Return a not-found rather than a forbidden where the existence of the record is itself sensitive.

Deny by default, allow explicitly

New endpoints must fail closed. A framework that serves any action unless a rule says otherwise will leak the first endpoint somebody adds in a hurry.

Validate input at the boundary, encode at the point of use

Validation rejects what should never have arrived; escaping or parameterising protects the place where the value is used. Doing only one of them is a common half-fix — allow-listing an id format does not protect a template that renders it.

Check the authorisation you actually need

Roles are not enough. “Any logged-in user” is a role, not an authorisation: the question is always whether this user may perform this action on this object.

Practice

Find and fix one concatenated query

Search the codebase for query strings built by joining, and convert one of them to a parameterised call.

Done when: the query no longer contains interpolation, and a test with an input containing a quote character returns a normal result rather than an error.

Test one endpoint for object-level authorisation

Take a detail endpoint and try it with an identifier belonging to another account, using your own valid session.

Done when: the response is a not-found or an authorisation error, not another user’s data — or the hole is written up with the fix.

Common mistakes

  • String-built queries — user input changes the statement’s structure; fix: parameterise or use an ORM binding.
  • Authorising the route but not the object — changing an id in a URL exposes another tenant’s record; fix: check ownership on every object fetch.
  • Trusting a client-supplied identifier — the client sends the account id it wants; fix: derive the tenant from the session, never from the request.
  • Allow by default — a newly added endpoint inherits access; fix: deny by default and require an explicit rule.
  • Validating only at the boundary — the value is still rendered raw somewhere downstream; fix: also encode at the point of use.

Go deeper