Every value that comes from outside your code (a form field, a URL parameter, a header, an uploaded filename) is untrusted. The entire family of injection attacks comes from one mistake: letting that untrusted data get interpreted as code instead of staying data.
It helps to separate three things people lump together as "sanitizing input":
- Validation: reject input that doesn't fit the expected shape (an email that isn't an email, a quantity that isn't a positive number). Do it at the boundary, but it's a filter, not a security guarantee.
- Sanitization: strip or neutralize dangerous content from input you intend to keep.
- Escaping / parameterizing: the real fix. Keep data and code in separate lanes so the data can never be parsed as a command, no matter what it contains.
The principle below appears three times, in three different interpreters: a SQL database, an HTML renderer, and a filesystem. Same bug, same fix.
SQL Injection
A SQL injection happens when user input is concatenated into a query string and the database parses part of it as SQL:
// DON'T: the input becomes part of the SQL command
db.query(`SELECT * FROM users WHERE email = '${email}'`);
// email = "' OR '1'='1" → returns every user
// email = "'; DROP TABLE users; --" → exactly what it looks like The fix is a parameterized query (a prepared statement). You send the SQL and the values separately; the driver guarantees the values are only ever treated as data:
// DO: the $1 placeholder is filled with `email` as a pure value, never parsed as SQL
db.query("SELECT * FROM users WHERE email = $1", [email]); There's no clever escaping to get right by hand. Parameterized queries are the answer, and every database driver and ORM supports them.
HTML Injection (XSS)
The same bug in the browser is cross-site scripting (XSS) : untrusted input rendered into a page where the browser parses it as HTML and runs any script it contains, in the victim's session, with their cookies.
// DON'T: input is parsed as markup, so a <script> or onerror handler runs
element.innerHTML = `<p>${comment}</p>`;
// comment = "<img src=x onerror=alert(document.cookie)>" → runs in the victim's browser
// DO: render it as text, so the browser shows the characters literally
element.textContent = comment;
Modern frameworks escape by default (React's <p>{comment}</p> is safe), but
every framework also has an escape hatch (dangerouslySetInnerHTML, v-html,
[innerHTML]) that re-opens the hole the moment you feed it untrusted data. A second
layer, a Content-Security-Policy header, limits what scripts can run even if
something slips through:
res.setHeader("Content-Security-Policy", "default-src 'self'"); Directory Traversal
When a filename from the user is used to build a filesystem path, ../ lets the
request climb out of the intended folder: a directory traversal :
GET /files/../../etc/passwd → reads a system file, far outside your uploads folder The fix is to resolve the final path and confirm it's still inside the directory you meant to serve:
import path from "node:path";
const ROOT = "/var/www/files";
app.get("/files/:name", (req, res) => {
const target = path.resolve(ROOT, req.params.name); // collapses any ../ segments
if (!target.startsWith(ROOT + path.sep)) {
return res.status(403).end(); // escaped the root, reject
}
res.sendFile(target);
});
Validating the input for .. is fragile: encodings and edge cases slip past.
Resolving the path and checking the result stays inside the root is robust, because it
verifies the actual outcome. Note that path.resolve collapses ../
segments but does not follow symbolic links. In high-security contexts where symlinks inside the
root could point outside it, use fs.realpath() instead to fully resolve the path
before checking the boundary.
Check your understanding
Why isn't stripping quotes and semicolons enough to stop SQL injection?
A teammate "fixes" SQL injection by stripping quotes and semicolons from input before building the query string. Why isn't that enough?