We've reached one of the oldest and most persistent families of vulnerabilities. A03 – Injection occurs when user-controlled data mixes with an instruction that an interpreter is about to execute —SQL, operating-system commands, LDAP, NoSQL queries— and the interpreter ends up executing part of that data as if it were instructions. In the 2021 Top Ten this category grew: it absorbed the old Cross-Site Scripting (XSS), which is technically an injection in the browser. Here we cover server-side injection; XSS gets its own in-depth treatment in lesson 03-04, so we only mention it here as a close relative.

The root cause is always the same: failing to separate code from data. In BazarNube we'll see a SQL injection in the product search of the Node/PostgreSQL API, and a command injection in a utility of the legacy Java module. The impact ranges from reading the entire database to running commands on the server.

Legal and ethical notice: the payloads shown are illustrative and use fake data. Practice only on your own systems or with explicit authorization. Launching injections against other people's systems is a crime.

Contents

  1. What injection is and why it happens
  2. SQL injection (SQLi) in the BazarNube API
  3. Parameterized queries and ORMs
  4. Operating-system command injection
  5. LDAP and NoSQL injection
  6. Input validation and least privilege
  7. XSS: the browser's injection (deferred to 03-04)
  8. Common mistakes, exercises and solutions

  1. What injection is and why it happens

An interpreter receives a string and decides what is instruction and what is data. If we build that string by concatenating user input, the user can introduce metacharacters (', ;, --, $) that break the intended structure and change the meaning of the instruction.

flowchart LR
  A[User input] --> B{Concatenated into the instruction?}
  B -- Yes --> C[The interpreter runs data as code]
  B -- No, passed as a parameter --> D[The data never changes the structure]
  C --> E[Injection]
  D --> F[Safe]

The underlying defense is to separate code from data: the developer defines the structure of the instruction and the engine treats user input always as a value, never as syntax.

  1. SQL injection in BazarNube

The API's product search was written by concatenating the search term:

// routes/search.js — VULNERABLE to SQLi
router.get('/api/products/search', async (req, res) => {
  const q = req.query.q;
  const sql = "SELECT id, name, price FROM products WHERE name LIKE '%" + q + "%'";
  const result = await db.query(sql); // direct concatenation
  res.json(result.rows);
});

How it's exploited (illustrative)

With a normal search ?q=tshirt, the query is correct. But an attacker sends:

?q=x%' UNION SELECT id, email, pwd FROM users --

The resulting query becomes:

SELECT id, name, price FROM products WHERE name LIKE '%x%'
UNION SELECT id, email, pwd FROM users --%'

The UNION adds the emails and password hashes from the users table to the results, and -- comments out the rest. The product search ends up leaking credentials. Variants of this technique allow reading any table, deleting data (; DROP TABLE ...) or extracting information blindly (blind SQLi) by measuring response times.

Fix: parameterized queries

// routes/search.js — SECURE with a parameterized query
router.get('/api/products/search', async (req, res) => {
  const q = req.query.q ?? '';
  const sql = 'SELECT id, name, price FROM products WHERE name LIKE $1';
  const result = await db.query(sql, ['%' + q + '%']); // q is passed as a PARAMETER
  res.json(result.rows);
});

The difference is essential: $1 is a placeholder. The PostgreSQL driver sends the query structure and the values separately; the engine never interprets the content of q as SQL. Even if q contains ' UNION SELECT ..., it will be searched literally within the product name. Code and data stay separate.

  1. Parameterized queries and ORMs

Approach Example Security
String concatenation "... WHERE id=" + id Vulnerable
Parameterized query query(sql, [id]) Safe
ORM / query builder Product.findByPk(id) Safe (uses parameters under the hood)

An ORM like Sequelize or Prisma generates parameterized queries automatically, which reduces the risk. Watch out: that protection is lost if you use the ORM's "escape hatch" for raw SQL and concatenate input:

// DANGER: even with an ORM, this is vulnerable again
sequelize.query("SELECT * FROM products WHERE name = '" + name + "'");
// Correct: use replacements/bind
sequelize.query('SELECT * FROM products WHERE name = ?', { replacements: [name] });

  1. Operating-system command injection

The legacy Java module has a utility that generates thumbnails by invoking an external tool. It was built by concatenating the file name:

// ThumbnailService.java — VULNERABLE to command injection
public void generate(String filename) throws IOException {
    // filename comes from a user request
    String cmd = "convert /uploads/" + filename + " -resize 100x100 /thumbs/" + filename;
    Runtime.getRuntime().exec(cmd);   // runs a string in the shell
}

If filename is photo.png; rm -rf /data, the shell runs two commands. The attacker achieves command execution on the server. Fix: don't invoke a shell and pass the arguments separately, plus validate the name.

// ThumbnailService.java — SECURE
public void generate(String filename) throws IOException, InterruptedException {
    if (!filename.matches("[A-Za-z0-9_.-]{1,64}")) {   // strict allowlist
        throw new IllegalArgumentException("Invalid file name");
    }
    ProcessBuilder pb = new ProcessBuilder(
        "convert", "/uploads/" + filename, "-resize", "100x100", "/thumbs/" + filename);
    // ProcessBuilder does NOT use a shell: each argument is passed as-is, without interpreting ; | &
    pb.start().waitFor();
}

ProcessBuilder with separate arguments prevents the shell from interpreting metacharacters, and the allowlist (matches) rejects any name that isn't alphanumeric. A double barrier.

  1. LDAP and NoSQL injection

The same principle (separate code from data) applies to other interpreters:

  • LDAP: building filters by concatenating input allows injecting *)(uid=* to bypass authentication. Solution: escape according to RFC 4515 or use APIs that parameterize the filter.
  • NoSQL (MongoDB): sending objects instead of strings allows operators. If login does find({user, pass}) with pass taken straight from the JSON, an attacker sends {"pass": {"$ne": null}} and gets "any password other than null."
// NoSQL — VULNERABLE: accepts objects from the client
db.users.findOne({ user: req.body.user, pass: req.body.pass });
// SECURE: force primitive types before querying
const user = String(req.body.user), pass = String(req.body.pass);
db.users.findOne({ user, pass }); // (and also compare the hash, see A02)

Forcing the types (String(...)) prevents the client from slipping in operators like $ne or $gt.

  1. Input validation and least privilege

Parameterization is the primary defense, but it's reinforced with defense in depth:

  1. Allowlist input validation: accept only what's expected (type, length, format). Don't rely on "denylists" of forbidden characters; there's always a way around them.
  2. Least privilege in the database: the account the BazarNube API uses doesn't need to be a superuser or be able to DROP TABLE. If it only reads/writes certain tables, a SQLi has far less reach.
  3. Escaping as a last resort: when you can't parameterize (e.g. a dynamic column name), validate against an allowlist of permitted values, never concatenate directly.
  4. Limit results and errors: don't return raw SQL errors to the client (they make exploitation easier; see A05).
Layer What it adds
Parameterized query / ORM Eliminates injection at the source
Allowlist validation Rejects malformed input before it reaches the engine
Least privilege in the DB Reduces the impact if something fails
Generic errors Give the attacker no hints

  1. XSS: the browser's injection

Cross-Site Scripting is also injection, but the interpreter is the browser (it runs injected JavaScript) instead of the database. Because of its importance and its own techniques (contextual output encoding, CSP, React escaping), we cover it in its own lesson: 03-04 – Cross-Site Scripting (XSS) in Depth. Here we only note that it shares the same root: mixing user data with code that another engine is going to run.

Common Mistakes and Tips

  • Concatenating input into queries. Even if it "looks like a number," always parameterize.
  • Relying on character denylists. They're bypassable; use allowlists and parameterization.
  • Escaping SQL by hand. It's fragile; leave escaping to the driver via parameters.
  • Using the ORM and then falling back to a concatenated raw query. It cancels all protection.
  • Passing input to a shell. Avoid Runtime.exec(string)/child_process.exec; use APIs with separate arguments.
  • A DB account with full permissions. Apply least privilege: limit tables and operations.
  • Tip: enable a SAST/DAST scanner in CI (we'll see it with ZAP in M6) to detect dangerous concatenations.

Exercises

Exercise 1. Rewrite this BazarNube endpoint securely:

router.get('/api/orders', async (req, res) => {
  const status = req.query.status;
  const rows = await db.query(
    "SELECT * FROM orders WHERE user_id = " + req.session.userId +
    " AND status = '" + status + "'");
  res.json(rows.rows);
});

Exercise 2. The Java module's login uses "... WHERE user='" + u + "' AND pass='" + p + "'". Show a payload that bypasses authentication and explain why parameterization prevents it.

Exercise 3. Why is ProcessBuilder("convert", filename, ...) safer than Runtime.exec("convert " + filename)? Is it still advisable to validate filename?

Solutions

Solution 1. Parameterize both values (including user_id, even though it comes from the session, for consistency):

router.get('/api/orders', async (req, res) => {
  const rows = await db.query(
    'SELECT * FROM orders WHERE user_id = $1 AND status = $2',
    [req.session.userId, req.query.status]);
  res.json(rows.rows);
});

Solution 2. With u = admin' -- the query becomes ... WHERE user='admin' --' AND pass='...': the -- comments out the password check and the attacker gets in as admin. With parameters, admin' -- would be searched literally as a username (which doesn't exist), because the engine never interprets that string as SQL syntax.

Solution 3. ProcessBuilder with separate arguments does not launch a shell, so metacharacters like ;, | or & are passed as literal text to convert, not interpreted as command separators. Even so, it's advisable to validate filename with an allowlist: it prevents malicious paths (../../etc/passwd) and inputs that break the tool. Defense in depth.

Conclusion

Injection is defeated by separating code from data: parameterized queries or an ORM for SQL, APIs with separate arguments for OS commands, type coercion for NoSQL, and proper escaping for LDAP, all reinforced with allowlist validation and least privilege. It's a mechanical and highly effective defense: applied well, it almost entirely eliminates the category.

Backlog entry — A03: parameterized the product search and the order listing; replaced Runtime.exec with ProcessBuilder plus validation in ThumbnailService; type coercion in the NoSQL login; created a DB account with minimal permissions for the API.

We mentioned that XSS is the injection that runs in the browser. It's so relevant to BazarNube's React front end that we devote the entire next lesson to it: Cross-Site Scripting (XSS) in Depth.

OWASP Course: Guidelines and Standards for Web Application Security

Module 1: Introduction to OWASP

Module 2: Main OWASP Projects

Module 3: OWASP Top Ten 2021 in Depth

Module 4: OWASP ASVS (Application Security Verification Standard)

Module 5: OWASP SAMM (Software Assurance Maturity Model)

Module 6: OWASP ZAP (Zed Attack Proxy)

Module 7: Best Practices and Recommendations

Module 8: Practical Exercises and Case Studies

Module 9: Assessment and Certification

© Copyright 2026. All rights reserved