1. Courses
  2. Cybersecurity
  3. Application Security for Developers
  4. Stop 2

40 minIntermediate

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 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.

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.

Before: one string SQL text search term one string parser term can change the query After: placeholders text: $1, $2 values 1. parse 2. bind query plan fixed runs as data
Before, the term is pasted into the SQL text, so the parser treats its quotes and keywords as part of the query. After, the database parses the text with placeholders first; the values arrive separately and can only ever fill a slot.

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?

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:

TypeScript
// 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 , 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.

TypeScript
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
});

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 : 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 . 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?

Cross-site scripting: where React protects you, and where it doesn't

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 , the text is saved first, such as a shop note or a product review, and runs for every person who later views it. In , 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 href and src. React escapes the text of the attribute, but a link whose value starts with the javascript: 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, usually https: and mailto:.
  • 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 , 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 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.

Partner app saves note Postgres stored as typed loads Back office page JSX text raw HTML shown as plain characters the lasting fix browser runs it as the staff user CSP inline script blocked, reported the second layer
Stored XSS: the note is saved exactly as typed. Rendered as JSX text it is harmless; rendered as raw HTML it runs with the staff member's session. CSP can block and report the script, but the fix is in how the page renders the note.

Quick check

Which of these renders a partner shop's free-text note safely in a React page?

Command injection: avoid the shell

The third interpreter is the operating system . 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:

TypeScript
// 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?

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 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 exec calls 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.

  1. 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.

    Terminal
    git grep -nE "query\([[:space:]]*\`|queryRawUnsafe|executeRawUnsafe|exec\(|innerHTML|dangerouslySetInnerHTML" -- src
  2. Parameterise 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 });
  3. Validate the request shape at the edge

    Validation narrows input; it does not replace the placeholders.

    TypeScript
    export 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 }));
  4. 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.

    SQL
    CREATE 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 tables
  5. Render 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
      }
    }
  6. 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.

    Output
    Content-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"
  7. 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.

    TypeScript
    it('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%");
    });
    Output
    postgresql.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 dangerouslySetInnerHTML or 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.

JavaScript exerciseRuns in your browser with tests
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: [] };
}
Ctrl + Enter runs
Show a solution
JavaScript
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 + '%'] };
}
Pass the exercise to finish this task.

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 &amp;, < with &lt;, > with &gt;, " with &quot; and ' with &#39;. Turn non-string values into text first. Order matters for one of them.

JavaScript exerciseRuns in your browser with tests
function escapeHtml(s) {
  // Make s safe between tags and inside a quoted attribute
  return String(s);
}
Ctrl + Enter runs
Show a solution
JavaScript
function escapeHtml(s) {
  return String(s)
    .replace(/&/g, '&amp;')   // first, so we don't escape our own escapes
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}
Pass the exercise to finish this task.

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.

TypeScript
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 execFile with 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.

TypeScript
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?

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 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?

Flashcards

Open my review

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 execFile with 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

Episode transcriptThe apostrophe that broke the search · about 12 minutes · tap any line to play from there
  1. NadiaImran, what happens when a partner shop owner called Ravi searches the Partner app for his own shop's name?
  2. ImranHe types Ravi's Corner Store and hits search. I'd hope he finds his invoices.
  3. NadiaFor months he got a page that said something went wrong. Every single time.
  4. ImranBecause of the apostrophe? That sounds like a tiny bug.
  5. NadiaThe bug is tiny. What it reveals is serious. Today is injection. SQL injection, cross-site scripting and command injection, and why they are really one problem with one kind of fix.
  6. NadiaPicture a store manager who sends handwritten notes to the stockroom. Pack the order for, and then a blank. The stockroom worker reads the whole note and does what it says.
  7. ImranAnd if a customer gets to fill in the blank?
  8. NadiaThen the customer can write, Ravi, and also give away everything on shelf three. The worker can't tell where the manager's words end and the customer's words begin. So he follows all of it.
  9. ImranSo the fix is to stop customers writing on the note.
  10. NadiaCustomers need to give their names, so that won't work. The fix is a better form. The instruction is printed and fixed. Pack the order for the customer in box A. Whatever is written in box A is only ever a label, never an instruction.
  11. ImranInstructions in one place, the customer's words in another, and the worker never has to guess.
  12. NadiaThat's the whole lesson in one picture. Code and data in separate channels.
  13. ImranSo where is the note in our software?
  14. NadiaAnywhere your code builds a string and hands it to an interpreter. The database reads SQL. The browser reads HTML and runs JavaScript. A shell reads a command line. Each one has special characters. A quote ends a SQL string. An angle bracket starts an HTML tag.
  15. NadiaInjection is when trusted code and untrusted input are glued into one string. The interpreter can't see which characters came from you and which came from the user.
  16. ImranAnd Ravi's apostrophe closed the SQL string early.
  17. NadiaExactly. The database said syntax error. That's the harmless version. Someone choosing their input with care could change what the query means, for example to remove the filter that keeps each shop to its own invoices.
  18. NadiaIf you can see your screen, the first diagram has two halves. In the top half, the SQL text and the search term merge into one string before they reach the parser.
  19. ImranAnd in the bottom half they stay apart. The query text with placeholders goes in first, marked parse. The values follow on their own line, marked bind.
  20. NadiaThat's a parameterised query. The database parses and plans the text first, with numbered slots, placeholder one and placeholder two. Then the values arrive, and a value can only fill a slot. A value full of quotes is still one string, compared against the customer name.
  21. ImranIn node postgres, that's the query call with a text and a values array.
  22. NadiaRight. And most ORMs do it for you through their normal methods. Prisma's tagged query raw, or TypeORM's query builder with named parameters.
  23. ImranThen why do codebases with an ORM still have injection?
  24. NadiaEscape hatches. Prisma has a method actually called query raw unsafe. TypeORM lets you build a where condition out of a template string. In a review, search for those first.
  25. ImranLet me guess the next trap. The sort dropdown. I'd pass it as placeholder three.
  26. NadiaGood guess, and it's the common mistake. Placeholders carry values, never column names. Postgres would treat it as a fixed value, so the rows wouldn't be sorted by any column at all.
  27. ImranSo I'm back to pasting it into the text?
  28. NadiaThrough an allow-list. The request says total. Your code looks it up in a map it owns, with three entries, newest, oldest and total. Only text from your own map reaches the query. Anything unknown falls back to newest.
  29. NadiaOne small detail. Use a real Map in JavaScript, not a plain object. A plain object also answers to built-in names like to string.
  30. ImranAnd if one query somewhere is still wrong?
  31. NadiaLeast privilege. The partner API logs in as its own database role. It can read invoices and write orders. It can't drop tables or read staff data. If something slips through, the damage stops at what that role can do.
  32. ImranSo tell me about your fix for Ravi's search.
  33. NadiaThe API logs showed about thirty Postgres syntax errors a week from the partner API, every one from a search with an apostrophe. The search pasted the term into the SQL text, and the sort value went straight into the order by clause.
  34. NadiaThe pull request did five things. Placeholders for the shop and the term. A three entry allow-list for the sort. A new database role for the partner API. Tests that search for O'Brien and for Ravi's. And an alert when syntax errors from that role pass five in ten minutes.
  35. ImranAnd then you closed the ticket.
  36. NadiaThen I searched the whole codebase for the same pattern, and found two more string-built queries in back office reports. Fixing only the input from the bug report is the mistake people make most.
  37. ImranCross-site scripting next. Where does that fit?
  38. NadiaIt's 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, as the person who is logged in.
  39. ImranStored and reflected, right?
  40. NadiaStored means the text is saved first, like a shop note, and runs for everyone who views it later. Reflected means it comes from the request itself, often a search term in a link, and is echoed back on the page. Stored is usually worse, because the victim does nothing unusual.
  41. ImranBut we use React. Doesn't React handle this?
  42. NadiaFor text, yes. When you put a note inside JSX, React inserts it as a text node, so angle brackets show up as plain characters on the screen.
  43. NadiaWatch three places. Dangerously set inner HTML, which inserts raw HTML, and the name is a warning. Links and image sources built from untrusted web addresses, because a link with the javascript scheme runs code when clicked. And direct DOM writes, plus HTML built outside React, like emails.
  44. NadiaThe second diagram follows one shop note. It's saved in the Partner app, stored in Postgres exactly as typed, and loaded into a back office page. Then the path splits in two.
  45. ImranThe left branch is JSX text, shown as plain characters, labelled the lasting fix. The right branch is raw HTML, and the browser runs it as the staff user.
  46. NadiaAnd under the right branch there's a dashed arrow labelled CSP. Content Security Policy, a response header that tells the browser which scripts may run. It blocks the injected inline script and sends you a report. That's the second layer.
  47. ImranSo CSP is the fix.
  48. NadiaIt limits the damage. The fix is in how the page renders the note. Start CSP in report only mode, clean up your own inline scripts, then enforce it.
  49. ImranWhat about rich text? Our buyers want bold text and bullet lists in product descriptions.
  50. NadiaThen you need some HTML, so escaping everything breaks the feature. Use a maintained sanitiser such as DOM Purify, with a short allow-list of tags, right before rendering. Never a home-made regular expression. Browsers parse HTML in more ways than any home-made filter expects.
  51. ImranAnd the shell, command injection?
  52. NadiaNode's exec function runs its whole string through a shell. Put an uploaded file's original name in there, and the shell reads its special characters as shell syntax.
  53. NadiaBest fix, no external program at all. A library like sharp resizes images inside your own process. If you really need a program, use exec file or spawn with an array of arguments and no shell. Each item reaches the program as exactly one argument. And use a file name your server generated, not the user's.
  54. ImranWhere does validation fit? I'd have said validate everything and you're done.
  55. NadiaValidate, yes. Types, lengths, fixed choices, unknown fields rejected. In NestJS that's a DTO with class validator, or a zod schema. But O'Brien is a valid name, and a note has to allow quotes. Validation narrows what reaches the query. The placeholder is what fixes it.
  56. ImranAnd how would we notice someone trying?
  57. NadiaSyntax errors in the logs, especially a burst from one account. Flags from a WAF, a web application firewall, treated as a signal of interest, never as protection. CSP reports from pages with no inline scripts of your own. And tests that fail if someone brings string building back.
  58. ImranSo the build section, step by step.
  59. NadiaSearch the code for raw query calls, the unsafe ORM methods, exec calls and inner HTML. Move the invoice query into a small function that returns text and values. Add a DTO for the request. Create the least privilege role in a migration.
  60. NadiaThen render notes as text, rich text through the sanitiser, and links through a check that allows only the schemes you expect. Add CSP in report only mode for a week. Finish with tests for O'Brien and the alert on syntax errors.
  61. ImranMy questions for the next review, then. Does any request value reach a query, a page or a command as part of the text? Where do our sort columns come from? Which database role does this service use, and what could it do if one query were wrong?
  62. NadiaAnd one more. Which test fails if someone reintroduces string concatenation here?
  63. NadiaThe practice has you write that invoice search function yourself, then an escape HTML function, then a review of a thumbnail job. Here's a question from it. Why must the escape function replace the ampersand first? Take a second.
  64. ImranBecause if you replace the angle bracket first, you create a new ampersand. Then the ampersand step escapes your own escape.
  65. NadiaExactly. The page would show the escape codes as text.
  66. NadiaOne from the quiz. Your CSP reports show blocked inline scripts on the shop profile page, and that page has no inline scripts of your own. What now?
  67. ImranSome field on that page is rendered as raw HTML. Find it and fix the encoding. And keep the CSP, because it just caught something.
  68. NadiaRight. Don't loosen the policy to make the reports go away.
  69. ImranMy summary. One, injection is data and code in one string, so separate them. Two, for SQL that means placeholders for values, an allow-list for sort columns, and a small database role. Three, React escapes text, so watch raw HTML, links and anything built outside React.
  70. NadiaI'd add one. Validation and the WAF are layers. The fix is always in how the code builds the string, or better, in never building one.
  71. ImranAnd for the shell, a library first and an argument array second.
  72. NadiaNext time, sessions and tokens. How passwords should be stored, and why a token that never expires is a gift to an attacker.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

The loyalty-points login leak

A login that builds SQL from text.

Open in the Security Lab
Next stopAuthentication, sessions and tokens
Was this lesson helpful?
Questions and discussion

Loading the discussion…