Skip to content

security · web

SQL injection, XSS, CSRF, and mass assignment, by hand

SQL injection, XSS, CSRF, and mass assignment, each reconstructed without a framework, because the mechanism is the part worth being able to rebuild.

Published

Framework defaults hide most of this. The point of writing it out by hand is being able to reconstruct the mechanism once the framework is taken away.

SQL injection

Injection happens because a query is assembled by concatenating untrusted input directly into a string the database then parses as code. The database cannot distinguish data a user typed from SQL a developer wrote once the two are concatenated together.

// vulnerable: string concatenation
$query = "SELECT * FROM users WHERE email = '$email' AND password = '$password'";

A payload against this query needs to close the quote, inject a boolean that is always true, then comment out the remainder so the trailing quote does not cause a syntax error:

email:    ' OR '1'='1' --
password: anything

The database executes:

SELECT * FROM users WHERE email = '' OR '1'='1' --' AND password = 'anything'

-- starts a SQL comment, so everything after it, including the password check, is discarded. The query returns every row in users, and the application logs in as whichever row comes back first.

Defense 1 — parameterized queries. The query and the data travel to the database on separate channels, so the database never parses user input as SQL syntax at all:

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? AND password_hash = ?');
$stmt->execute([$email, $passwordHash]);

Defense 2 — an ORM query interface, the same principle supplied by the framework:

User.where(email: email, password_hash: password_hash)

Why parameterization is not the complete story. It protects values. It does nothing for identifiers — table names, column names, sort directions — because a placeholder cannot appear there:

# still injectable: a user-controlled column name interpolated into SQL
User.order("#{params[:sort_column]} ASC")

The fix for a dynamic identifier is an allowlist, not parameterization:

SORTABLE = %w[created_at name email].freeze
User.order("#{SORTABLE.fetch(params[:sort_column], 'created_at')} ASC")

"We use an ORM" is not, on its own, a security property, if a raw query or a stored procedure sits anywhere downstream and builds SQL by concatenation internally.

Cross-site scripting

XSS happens when untrusted input is written into a page in a context where the browser executes it rather than displays it as text. Three contexts, three escaping rules:

ContextExample sinkEscaping needed
HTML body<div><%= comment %></div>HTML-entity encode < > & " '
HTML attribute<input value="<%= name %>">Attribute encoding, and never let the attribute be href/src with a user-controlled scheme
JavaScript context<script>var x = "<%= value %>";</script>Never interpolate directly; pass via a data-* attribute or a JSON payload instead

A common, easy-to-miss version of this: a small CRUD feature — a table, a create form submitted over AJAX — appends the new row to the DOM by echoing back whatever the form just submitted. If that echo happens through innerHTML rather than a text-safe insertion, and the name field is never escaped, an attacker submits <img src=x onerror=alert(1)> as a name and it executes for every future viewer of the table, not just the person who submitted it. That is stored XSS, and it is a common failure mode in otherwise-working CRUD features precisely because the feature "works" in every manual test that used a normal name.

The fix in every case is the same: use the templating engine's default auto-escaping and never build HTML by string concatenation or unescaped innerHTML. Use textContent, or a templating layer that escapes by default, when inserting user data on the client.

CSRF

CSRF exploits the fact that a browser attaches cookies automatically to any request to a domain, including a request triggered by a different site the user happens to have open. The boundary that governs which cross-origin requests a browser will make in the first place is the same-origin policy, worked through in CORS: what it is, why it exists, and how it works. If a state-changing endpoint trusts the cookie alone, any page on the internet can forge a request that performs the action as the logged-in user.

Token refresh cadence. The standard pattern is a per-session token, embedded in every form and validated on every state-changing request, refreshed on login and after a privilege change such as a password change — not on every single request. Refreshing on every request breaks multi-tab use: a token embedded five minutes ago becomes invalid the moment a different tab makes a request and rotates it. Refreshing too rarely has a different failure mode: a long-lived token sitting in a page left open for hours stays a valid forgery target for that entire window if it ever leaks.

Where SameSite cookies do, and do not, make the token redundant. SameSite=Strict or Lax stops the browser from attaching the session cookie to most cross-site requests, which blocks the classic forged-form attack without any token at all. It does not cover subdomain-to-subdomain attacks, browsers that do not enforce SameSite yet, or an explicit SameSite=None cookie required for a legitimate cross-site embed. In practice, SameSite=Lax plus a CSRF token is defense in depth, not redundancy, which is why most frameworks still ship both by default.

Mass assignment

The vulnerability is not that a framework lets you assign parameters to a model. It is trusting a hash of arbitrary client-submitted keys to update any column on that model, including columns the client was never meant to touch.

# vulnerable: whatever keys the client sends get assigned
@user.update(params[:user])

A client that knows, or guesses, a column name adds user[is_admin]=true to the payload and the update succeeds, because nothing checked which keys were allowed. The fix is an explicit allowlist of assignable attributes:

@user.update(params.require(:user).permit(:name, :email))

"Use strong parameters" is the product name of the fix. The principle — never trust a client-submitted key to decide which server-side field gets written — is what has to survive if the framework did not exist.

Related notes

Contact

Available now. Permanent senior or staff platform roles, in Munich or remote within the EU.

Email is the fastest way to reach me.