Injection and unsafe input
SQL injection, cross-site scripting and command injection look like three different bugs. They share one cause: data and code mixed in one string that something then parses. This lesson teaches you to spot that pattern in a code review and replace it with fixes that keep working after the next refactor.
By the end you can
- Explain the one root cause behind SQL injection, XSS and command injection
- Rewrite a string-built query with node-postgres placeholders, an ORM query builder and an allow-list for sort columns
- Say where React protects you from XSS, where it does not, and what to do with rich text and links
- Run external programs without a shell, and use input validation as a second layer
- Prove a fix with tests and notice injection attempts in logs, WAF signals and CSP reports
The everyday picture
Picture a store manager who sends handwritten notes to the stockroom. A note says "Pack the order for: Ravi's Corner Store". The stockroom worker reads the whole note and does what it says. One day a customer fills in the blank on a copy of the form, and the note reads "Pack the order for: Ravi. Also give away everything on shelf 3." The worker can't tell where the manager's words end and the customer's begin, so he follows all of it.
The fix is a better form. The instruction is printed and fixed: "Pack the order for the customer in box A". Whatever is written inside box A is only ever a name on a label, never an instruction, however it is worded. Injection fixes work the same way. You keep the instructions (the code) and the customer's words (the data) in separate boxes, so the reader never has to guess which is which.
One root cause: data and code in one string
An interpreter reads text and turns it into actions. Your database parses SQL. The browser parses HTML and runs JavaScript. A shell parses a command line. Each one has special characters that change meaning: a quote ends a SQL string, an angle bracket starts an HTML tag, a semicolon or a space separates shell words.
Injection happens when your code builds one string out of trusted code and untrusted input, then hands that string to an interpreter. The interpreter can't see which characters came from you and which came from the user. If the input contains characters that are special to that interpreter, they change what the code means.
The harmless version shows up all the time. A customer called O'Brien searches for his own name, the apostrophe closes the SQL string early, and the database rejects the query with a syntax error. That error is a gift: it tells you the input is being parsed as code. Someone who chooses their input with care can do much more than cause an error, because the same gap lets them change the query's meaning, for example to remove the filter that keeps each shop to its own rows.
OWASP lists injection in the Top 10 of both 2021 (as A03, where cross-site scripting was folded into it) and 2025. The lasting fixes all follow one idea: send code and data through separate channels, so data can never be parsed as code. Escaping special characters by hand, or deleting the dangerous ones, is the weaker fallback for when no separate channel exists.
Quick check
Searching the Partner app for "Ravi's" returns a 500 error, and the logs show a Postgres syntax error. What does that tell a reviewer?
A syntax error caused by a quote means the term became part of the SQL text. The same path lets a crafted term change the query. Rejecting apostrophes would break real names like O'Brien and leave the cause in place.
SQL injection: the fix that lasts
Here is the "before", the kind of code you will meet in reviews. It is short and it works for most searches, which is why it survives:
// Before: the term and the sort are pasted into the SQL text
const sql = `SELECT id, invoice_no, total_cents FROM invoice
WHERE shop_id = ${shopId} AND customer_name ILIKE '%${term}%'
ORDER BY ${sort}`;
const { rows } = await pool.query(sql);Three inputs flow into the text: the shop ID, the search term and the sort. Each one is a way to change the query. The lasting fix is a parameterised query, also called a prepared statement. In node-postgres you pass the text with $1, $2 placeholders and an array of values. The driver sends them to Postgres separately: the server parses and plans the text first, then binds the values into the slots. A value can contain quotes, percent signs or SQL keywords and it will still be one string compared against customer_name.
const SORTS = new Map([
['newest', 'issued_on DESC'],
['oldest', 'issued_on ASC'],
['total', 'total_cents DESC'],
]);
const orderBy = SORTS.get(sort) ?? SORTS.get('newest'); // identifiers come from our code
const { rows } = await pool.query({
text: `SELECT id, invoice_no, customer_name, total_cents, issued_on
FROM invoice
WHERE shop_id = $1 AND customer_name ILIKE $2
ORDER BY ${orderBy}
LIMIT 50`,
values: [shopId, `%${term}%`], // data travels separately
});// $queryRaw is a tagged template: each ${...} becomes a bound parameter
const rows = await prisma.$queryRaw`
SELECT id, invoice_no, total_cents FROM invoice
WHERE shop_id = ${shopId} AND customer_name ILIKE ${'%' + term + '%'}`;
// $queryRawUnsafe(someString) does NOT do this. Treat it like pool.query(sql).const rows = await dataSource
.getRepository(Invoice)
.createQueryBuilder('invoice')
.where('invoice.shopId = :shopId', { shopId }) // named parameter
.andWhere('invoice.customerName ILIKE :term', { term: `%${term}%` })
.orderBy(sortColumn, sortDir) // both from an allow-list, never from the request
.getMany();Notice what the placeholders can't do. A placeholder carries a value. It can't stand for a column name, a table name or a keyword such as DESC. That is why the sort is the part most often left string-built. The fix is an allow-list: the request says total, your code looks it up in a map it owns, and only text from your map reaches the query. An unknown value falls back to a default. Use a Map or Object.hasOwn for the lookup; a plain object lookup also finds built-in keys such as toString.
ORMs and query builders help because their normal methods parameterise for you. Each one also has an escape hatch that takes a raw string: Prisma's $queryRawUnsafe, TypeORM's query() with a built string, or a .where() condition assembled with template literals. In a review, search for those first. The same goes for database functions: dynamic SQL in PL/pgSQL should use EXECUTE … USING for values and format() with %I for identifiers. Calling a stored procedure does not make string-built SQL inside it safe.
Two smaller notes. The term's own % and _ still act as wildcards inside ILIKE. That is a correctness question, not injection; escape them if exact matching matters. You met the same habit from the data side in SQL you'll actually use.
Finally, assume one query somewhere will still be wrong, and limit the damage. This is least privilege. The Partner API should log in as its own database role that can read invoices and write orders, with no rights to drop tables, read staff data or touch other schemas. If an injection slips through, the attacker gets that role's rights, not the database owner's. Row-level security from Broken access control adds one more wall between shops.
Quick check
A developer parameterises the term and shop ID, then writes ORDER BY $3 with the sort value in the values array. What happens?
Placeholders carry values, never identifiers. $3 becomes a constant such as the text 'total', and sorting by a constant leaves the order undefined. It is safe but wrong. Map the sort through an allow-list and put the column text from your own map into the query.
Cross-site scripting: where React protects you, and where it doesn't
XSS is injection into HTML. A user's text reaches the browser as markup, and the browser runs any script in it with your site's permissions. That script can act as the logged-in user: read what they see, submit forms, change settings.
There are two common shapes. In stored XSS, the text is saved first, such as a shop note or a product review, and runs for every person who later views it. In reflected XSS, the text comes from the request itself, often a search term in a link, and is echoed back on the page. Stored XSS is usually worse, because the victim does nothing unusual. A staff member opens a shop's profile in the back office and the script runs in a session with staff rights. A third shape, DOM-based XSS, happens entirely in front-end code that copies a value from the address bar into the page.
React helps a lot by default. When you write {shop.note} inside JSX, React puts it into the page as a text node, so angle brackets show up as characters and never become tags. That covers most of a typical app. Reviews should focus on the places where React can't help:
dangerouslySetInnerHTML, which inserts a string as raw HTML. The name is a warning. Every use needs a reason and a sanitiser.- URLs in
hrefandsrc. React escapes the text of the attribute, but a link whose value starts with thejavascript:scheme runs code when clicked. Recent React versions warn about or block such URLs; don't rely on that. Parse the URL and allow only the schemes you expect, usuallyhttps:andmailto:. - Direct DOM access through a ref, such as setting
innerHTML, and HTML built outside React: emails, PDF templates and server-rendered error pages.
Where you produce output yourself, encode for the context the value lands in. Between HTML tags, escape &, <, >, and the two quote characters. Inside an attribute, also put the attribute in quotes. Inside a URL, use encodeURIComponent for one part and a scheme allow-list for a whole link. Inside a script block or a style, avoid placing user data at all; put it in a data attribute or a JSON response instead. This is output encoding, and it belongs at the point of output, because only there do you know the context.
Rich text is the hard case. When the back office lets buyers format product descriptions with bold text, lists and links, you need to keep some HTML. Don't write your own filter with regular expressions; browsers parse HTML in more ways than any home-made filter expects. Use a maintained sanitiser such as DOMPurify, with an allow-list of tags and attributes, and run it right before rendering. Sanitising on save as well is fine, but the render-time pass is the one that protects you from old data and other writers.
Add a second layer for the bug you haven't found yet. A Content Security Policy header can tell the browser to run only scripts from your own origin, or only inline scripts that carry a per-response random value called a nonce. An injected inline script then fails to run, and the browser can send you a report. CSP limits the damage; it does not fix the encoding bug. Start in report-only mode, clean up your own inline scripts, then enforce.
Quick check
Which of these renders a partner shop's free-text note safely in a React page?
JSX text is put into the page as a text node, so tags in the note appear as characters. The first two insert the note as HTML. The link escapes the text but would still follow a javascript: URL, so it needs a scheme check.
Command injection: avoid the shell
The third interpreter is the operating system shell. Node's exec runs its whole argument through a shell, so the shell's special characters in a file name or an ID are read as shell syntax:
// Before: the uploaded file's original name goes through a shell
exec(`convert ${upload.originalName} -resize 200x200 thumb.jpg`);The best fix is to need no external program at all. A library such as sharp resizes images inside your process, with no command line to inject into. When you do need a program, use execFile or spawn with an array of arguments and no shell: true. Each array item reaches the program as exactly one argument, whatever characters it contains. Use a file name your server generated, such as a random UUID, instead of the user's. A value starting with a dash can still be read as an option by the program itself, so for programs that support it, put -- before user-influenced arguments. Run the job with a short timeout and as a user that can touch only its working folder.
Quick check
Why is execFile('convert', [input, '-resize', '200x200', output]) safer than exec with the same values in one string?
With an argument array there is no shell to split words or read special characters. Nothing is escaped, because nothing needs to be. Permissions are unchanged; you still limit them separately.
Validation, detection and tests
Input validation is useful and it is your second layer. Check the shape of every request at the edge: types, lengths, formats and fixed choices. In NestJS that is a DTO with class-validator decorators, as in APIs done right; elsewhere a zod schema does the same job. Validation turns the sort parameter into one of three known words before your service sees it, caps a search term at 80 characters, and rejects fields the endpoint doesn't expect.
Validation can't be the main fix, though. "O'Brien" is a valid name. A shop note must be allowed to contain quotes and angle brackets, because people type them. A block-list of "bad characters" breaks real users and still misses the next context. The separate channel fixes the cause; validation narrows what can reach it.
Then make sure you would notice an attempt or a regression:
- Errors in logs. Postgres syntax errors (SQLSTATE 42601) from an API's role are either a bug or someone probing. Log the role and the error code, and alert on a burst from one account or address.
- WAF signals. A WAF flags requests that look like injection attempts. Treat those flags as a signal of interest in an endpoint, not as protection; a WAF rule can be worked around and does nothing for the flaw.
- CSP reports. A report of a blocked inline script on a page that has none of your own is an XSS bug to hunt down.
- Tests. Unit tests that search for O'Brien, assert that the term never appears in the SQL text, and check that an unknown sort falls back. Static analysis rules that flag string-built queries and
execcalls catch the pattern in new code; you will wire those into CI in Security in the pipeline and in AI features.
Build it
Harden the invoice search and the shop pages of the practice Partner app in your sandbox, then prove the fixes with tests.
Find every string-built call
Search for raw query calls with template literals, the unsafe ORM methods and shell calls. Review each hit; most will be fine, a few won't.
Terminalgit grep -nE "query\([[:space:]]*\`|queryRawUnsafe|executeRawUnsafe|exec\(|innerHTML|dangerouslySetInnerHTML" -- srcParameterise the query and allow-list the sort
Move the query into a small pure function that returns
{ text, values }. It is easy to test, and the practice exercise below asks you to write it.TypeScript// invoices.repository.ts const { text, values } = buildInvoiceSearch(dto.term, user.shopId, dto.sort); const { rows } = await this.pool.query({ text, values });Validate the request shape at the edge
Validation narrows input; it does not replace the placeholders.
TypeScriptexport class InvoiceSearchQuery { @IsString() @MaxLength(80) term!: string; // free text: O'Brien must pass @IsOptional() @IsIn(['newest', 'oldest', 'total']) sort?: string; } // main.ts: drop and reject fields no DTO declares app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));TypeScriptconst InvoiceSearchQuery = z.object({ term: z.string().max(80), sort: z.enum(['newest', 'oldest', 'total']).default('newest'), }).strict(); // unknown keys are an error const query = InvoiceSearchQuery.parse(req.query);Give the API a least-privilege role
Create the role once in a migration. The password comes from your secret store, never from the file.
SQLCREATE ROLE partner_api LOGIN; REVOKE CREATE ON SCHEMA public FROM PUBLIC; -- already the default since Postgres 15 GRANT USAGE ON SCHEMA public TO partner_api; GRANT SELECT ON invoice, partner_shop, article TO partner_api; GRANT SELECT, INSERT, UPDATE ON "order", order_line TO partner_api; -- no DELETE, no DDL, nothing on staff or payment tablesRender safely in React
Text as JSX text, rich text through DOMPurify with an allow-list, links through a scheme check.
TypeScript<p className="shop-note">{shop.note}</p> // escaped by React const clean = DOMPurify.sanitize(product.descriptionHtml, { ALLOWED_TAGS: ['p', 'b', 'i', 'ul', 'ol', 'li', 'a'], ALLOWED_ATTR: ['href'], }); <div dangerouslySetInnerHTML={{ __html: clean }} /> function safeHref(url: string): string | undefined { try { const u = new URL(url); return u.protocol === 'https:' || u.protocol === 'mailto:' ? u.href : undefined; } catch { return undefined; // not a full URL: render no link } }Add a Content Security Policy
Send it from the back office in report-only mode for a week, fix your own inline scripts, then switch the header name to enforce it.
OutputContent-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-{random per response}'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; report-to csp Reporting-Endpoints: csp="https://backoffice.mangrove.example/csp-reports"Prove it with tests and an alert
Tests that would fail if someone brought the old pattern back, and an alert that tells you when syntax errors start.
TypeScriptit('finds a customer with an apostrophe', async () => { const res = await api.get('/v1/invoices').query({ term: "O'Brien" }).set(asShop(42)); expect(res.status).toBe(200); }); it('never puts the term in the SQL text', () => { const q = buildInvoiceSearch("Ravi's", 42, 'newest'); expect(q.text).not.toContain("Ravi's"); expect(q.values).toContain("%Ravi's%"); });Outputpostgresql.conf: log_line_prefix = '%m [%p] user=%u sqlstate=%e ' alert: count of sqlstate=42601 with user=partner_api > 5 in 10 minutes then: notify on-call with the endpoint and account from the API logs
Bring it to your team
Ask these in your next code review or security review.
- Does any value from the request reach a query, a page or a command as part of the text, instead of as a bound value or a text node?
- Where do sort columns, table names and directions come from, and is there an allow-list with a default?
- Which database role does this service use, and what could it do if one query were wrong?
- Where do we use
dangerouslySetInnerHTMLor user-supplied URLs, and what sanitises or checks each one? - Do we run any external program with user input, and could a library do the job instead?
- Which test fails if someone reintroduces string concatenation here, and who sees the syntax-error alert?
Red flags
- "We sanitise the input" offered as the fix for SQL injection.
- A WAF rule added and the ticket closed, with the code unchanged.
- The app connects to Postgres as the database owner or a superuser.
- A home-made regular expression that strips script tags from rich text.
- Syntax errors in production logs that nobody looks at.
Practice
Write the safe invoice search
Rewrite buildInvoiceSearch(term, shopId, sort) so it returns { text, values } for node-postgres. The text must contain shop_id = $1 and customer_name ILIKE $2, and never the term itself. values must be [shopId, '%' + term + '%']. Map the sort through an allow-list: newest gives ORDER BY issued_on DESC, oldest gives ORDER BY issued_on ASC, total gives ORDER BY total_cents DESC, and anything else (including built-in names such as toString) falls back to newest.
function buildInvoiceSearch(term, shopId, sort) {
// Before: the term and the sort are pasted into the SQL text.
// Rewrite it with $1 and $2 placeholders and a sort allow-list.
const text = 'SELECT id, invoice_no, customer_name, total_cents, issued_on FROM invoice ' +
'WHERE shop_id = ' + shopId + " AND customer_name ILIKE '%" + term + "%' " +
'ORDER BY ' + sort;
return { text, values: [] };
}
test('the term goes into values, never into the SQL text', () => {
const q = buildInvoiceSearch("O'Brien", 42, 'newest');
expect(q.text).not.toContain("O'Brien");
expect(q.values).toEqual([42, "%O'Brien%"]);
});
test('shop id is $1 and the term is $2', () => {
const q = buildInvoiceSearch('Ravi', 7, 'oldest');
expect(q.text).toContain('shop_id = $1');
expect(q.text).toContain('customer_name ILIKE $2');
expect(q.text).toContain('ORDER BY issued_on ASC');
});
test('total sorts by total_cents, highest first', () => {
expect(buildInvoiceSearch('a', 1, 'total').text).toContain('ORDER BY total_cents DESC');
});
test('an unknown sort falls back to newest and is not copied', () => {
const q = buildInvoiceSearch('a', 1, 'customer_name ASC');
expect(q.text).toContain('ORDER BY issued_on DESC');
expect(q.text).not.toContain('ORDER BY customer_name');
});
test('built-in object keys are not on the allow-list', () => {
expect(buildInvoiceSearch('a', 1, 'toString').text).toContain('ORDER BY issued_on DESC');
});
Show a solution
const SORTS = new Map([
['newest', 'issued_on DESC'],
['oldest', 'issued_on ASC'],
['total', 'total_cents DESC'],
]);
function buildInvoiceSearch(term, shopId, sort) {
const orderBy = SORTS.get(sort) ?? SORTS.get('newest'); // only our own text
const text =
'SELECT id, invoice_no, customer_name, total_cents, issued_on FROM invoice ' +
'WHERE shop_id = $1 AND customer_name ILIKE $2 ' +
'ORDER BY ' + orderBy;
return { text, values: [shopId, '%' + term + '%'] };
}Encode text for HTML
Some of the back office's emails are built as HTML strings outside React. Write escapeHtml(s) so that any value is safe between HTML tags and inside a double- or single-quoted attribute. Replace & with &, < with <, > with >, " with " and ' with '. Turn non-string values into text first. Order matters for one of them.
function escapeHtml(s) {
// Make s safe between tags and inside a quoted attribute
return String(s);
}
test('escapes angle brackets', () => {
expect(escapeHtml('<b>Ravi</b>')).toBe('<b>Ravi</b>');
});
test('escapes the ampersand exactly once', () => {
expect(escapeHtml('Salt & Pepper <3')).toBe('Salt & Pepper <3');
});
test('escapes both quote characters', () => {
expect(escapeHtml(`O'Brien said "hi"`)).toBe('O'Brien said "hi"');
});
test('turns numbers into text and leaves plain text alone', () => {
expect(escapeHtml(42)).toBe('42');
expect(escapeHtml('Corner Store')).toBe('Corner Store');
});
Show a solution
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&') // first, so we don't escape our own escapes
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}Review the thumbnail job
A pull request adds product photo thumbnails to the back office. Partner shops upload photos, and a job runs this line for each upload. Write a short review comment: name the flaw, give the lasting fix, and say which test and which limit you would add.
exec(`convert uploads/${upload.originalName} -resize 300x300 thumbs/${upload.id}.jpg`);- You name command injection: the original file name reaches a shell as part of the command line
- Your fix removes the shell: an image library, or
execFilewith an argument array - You replace the user's file name with one the server generates
- You add a test with an awkward file name (spaces, quotes, a leading dash) and a limit such as a timeout or a low-privilege user
Show a model answer
This passes the uploader's original file name through a shell, because exec runs the whole string with /bin/sh. Any shell-special characters in the name would be read as shell syntax, so a shop could make the server run commands. Please remove the shell entirely: store the upload under a server-generated name and resize it in process with sharp.
const input = path.join(UPLOAD_DIR, `${randomUUID()}.upload`); // our name, not theirs
await sharp(input).resize(300, 300).jpeg().toFile(path.join(THUMB_DIR, `${upload.id}.jpg`));If we must keep ImageMagick, use execFile('convert', [input, '-resize', '300x300', output], { timeout: 10_000 }) with the generated name. Add a test that uploads a file called -my "best" photo.jpg and checks a thumbnail appears, and run the worker as a user that can only read uploads and write thumbnails.
Check yourself
A teammate's fix for the invoice search bug removes every apostrophe from the term before it is pasted into the SQL. What is the best review response?
Stripping characters treats one symptom of one input. Real names lose their apostrophes, and the other pasted values still change the query. Placeholders separate data from code for every value, and the sort needs its allow-list. A keyword block-list is the same weak idea with more false alarms.
Buyers format product descriptions in the back office with a rich-text editor, and the product page shows them with dangerouslySetInnerHTML. What is the lasting fix?
The feature needs some HTML, so escaping everything would show tags as text and break it. Home-made regular expressions miss the many other ways HTML can run script. Staff accounts can be taken over, so staff input still needs handling. A maintained sanitiser keeps the formatting and removes the rest.
The back office's CSP reports start showing blocked inline scripts on the shop profile page, a page with no inline scripts of your own. What does that tell you, and what do you do?
A script you didn't write, on a page that renders user data, points to an XSS bug. CSP did its job as the second layer, but the flaw is still there and other browsers or a looser page might run it. Loosening the policy removes the layer that caught it. Extensions do cause noisy reports, so check, but don't assume.
Flashcards
Key takeaways
- Injection happens when data and code share one string that an interpreter parses; the lasting fix sends them through separate channels.
- For SQL: placeholders for every value, an allow-list for identifiers such as sort columns, and a least-privilege database role to limit the damage.
- React escapes JSX text; review every
dangerouslySetInnerHTML, untrusted URL and direct DOM write, sanitise rich text with a maintained library, and add CSP as a second layer. - Avoid the shell: use a library, or
execFilewith an argument array and a generated file name. - Validation narrows input but is not the fix. Prove fixes with tests and watch syntax errors, WAF flags and CSP reports.
Sources and further reading
- A03:2021 Injection, OWASP Top 10, 2021
- SQL Injection Prevention Cheat Sheet, OWASP Cheat Sheet Series
- Cross Site Scripting Prevention Cheat Sheet, OWASP Cheat Sheet Series
- OS Command Injection Defense Cheat Sheet, OWASP Cheat Sheet Series
- Queries: parameterized queries and prepared statements, node-postgres documentation
- Content Security Policy (CSP), MDN Web Docs
- DOMPurify, Cure53