Get listed

Security vulnerability types: injection, XSS, and IDOR

Three jars for ink, a recipe, and a numbered key.

Most application bugs that matter are injection, XSS, and missing object authorization.

Those three sit at the top of every current list for a reason. A fourth class (CSRF, SSRF, deserialization) shows up when your stack has that sink. Start with the three, then open the deeper page for the one you are fixing this week.

The usual mistake is a ‘types of vulnerabilities’ slide that never names the test.

This page is the three types with a one-sentence control each, and links to the full guides.

CWE-89 is rank 2 on MITRE’s 2025 CWE Top 25. CWE-79 is rank 1. CWE-639 is the user-controlled key behind broken object access.Those are demos. The three types you can still close are injection, XSS, and IDOR.

2025 CWE table and the 2025 OWASP Top 10. Injection sits at A05 in that OWASP list and still includes the XSS CVE pile. Broken access control is A01. Those ranks are enough. A fourth type, stored passwords without a salt, is real and is not this page. The three below are the ones a CRUD handler hits before lunch. Full writeups: injection, XSS, IDOR. Keep the secure coding checklist for everything else.

Why these three, not a catalog

A catalog grows. Command injection, LDAP, SSTI, path traversal, CSRF: each has a page. The three here share one request shape. A client sends displayName and invoiceId. The server talks to SQL, then to HTML, then to a row the client named. Skip the bind and the name becomes grammar. Skip the encode and the name becomes a script. Skip the tenant and the id becomes someone else’s memo.

TypeCWENamed control
InjectionCWE-89parseBody then $1
XSSCWE-79escapeHtml at the sink
IDORCWE-639loadInvoice with org_id

Password hashing is a storage job. It does not ride this request. If you need argon2id and a per-row salt, that is a different handler and a different article.

Injection: bind the value, never the grammar

Injection is untrusted data reaching an interpreter while still carrying structure. SQL is CWE-89. A command string is CWE-78. A Mongo filter that accepted an object where you wanted a string is a parser miss. The shared fix is a typed boundary, then a bind or an argv array. Concatenation is the open.

As of 22 August 2026, HN item 43561417. The parent thread is about a query built as text. Escaping inside a template is not a bind. pg 8 sends $1 and the value apart. That is the control. Identifiers such as a sort column cannot go in a bind. Those take an allowlist. The long page names $queryRawUnsafe, Sequelize.literal, and whereRaw as the hatches that turn an ORM back into a string builder.

const { z } = require("zod");

const parseBody = z.object({
 displayName: z.string().min(1).max(80),
 invoiceId: z.string().uuid(),
});

app.post("/invoices/search", async (req, res) => {
 const parsed = parseBody.safeParse(req.body);
 if (!parsed.success) {
 res.status(400).send("Bad request");
 return;
 }
 if (!req.actor) {
 res.status(401).send("Unauthorized");
 return;
 }
 const { displayName, invoiceId } = parsed.data;
 const { rows } = await pool.query(
 `SELECT id, memo
 FROM invoices
 WHERE id = $1 AND org_id = $2 AND memo = $3`,
 [invoiceId, req.actor.orgId, displayName]
 );
 if (!rows[0]) {
 res.status(404).send("Not found");
 return;
 }
 res.json(rows[0]);
});

parseBody rejects an object where a string belongs. That is the Mongo-operator case before it becomes a filter. $3 is displayName as data. Do not build WHERE memo = '${displayName}'. Do not pass a client order into ORDER BY unless it matches a frozen list of column names.

A sort key is the bind you cannot do. The identifier has to be a member of a set you wrote. Anything else is 400. That is how an _sort query parameter stops being an ORDER BY concat. The long injection page is the hatch list. This page only needs the allowlist.

const SORT = new Set(["created_at", "amount_cents", "id"]);

function sortColumn(raw) {
 if (typeof raw !== "string") return null;
 if (!SORT.has(raw)) return null;
 return raw;
}

sortColumn is the allowlist helper for the identifier. A null is 400. The returned string is one of three literals you compiled in. Never interpolate req.query.sort. Command injection is the same shape on the other side of the process: prefer execFile with an argv array, then -- so a leading-dash filename cannot become a flag. That is the injection sibling. Stay on the SQL bind here unless you actually spawn a process.

XSS: encode at the sink that will parse

XSS is CWE-79. Untrusted markup is parsed as the user’s page. Stored, reflected, and DOM are textbook labels for the same miss. The useful split is the sink: HTML body, attribute, JavaScript string, CSS, URL. Each sink has its own encoding. A single “sanitize” call is how names lie.

HN item 47136906, entuno, 24 February 2026, on the Firefox 148 setHTML thread. The live comment is about a mix of methods where some names look safe and some are not, and about sanitizers with a long hole history. The takeaway I am using is the name problem: innerHTML, v-html, and dangerouslySetInnerHTML advertise the miss. setHTML is a different method. Read the XSS page before you treat any of those as a bind.

function escapeHtml(s) {
 return String(s)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

app.get("/invoices/:invoiceId", async (req, res) => {
 if (!req.actor) {
 res.status(401).send("Unauthorized");
 return;
 }
 const row = await loadInvoice(pool, req.params.invoiceId, req.actor.orgId);
 if (!row) {
 res.status(404).send("Not found");
 return;
 }
 res.set("Content-Type", "text/html; charset=utf-8");
 res.send(`<p>${escapeHtml(row.memo)}</p>`);
});

escapeHtml is the text-node encoder. It is not an attribute encoder. It is not a URL encoder. If the product must accept bold and lists, that is a sanitizer with an allowlist, Trusted Types so a raw string cannot hit innerHTML, and a CSP without unsafe-inline. HttpOnly does not close XSS. Script in your origin can still fire every request the user may fire, including a read of any object the page was about to load.

A React default text child is already encoded. dangerouslySetInnerHTML is the hatch. Vue’s v-html is the hatch. A comment that says we escape, sitting next to either call, is the bug. If you must keep an HTML field, name it memoHtml and keep memo as text. Two fields stop a future renderer from picking the wrong one. The XSS page is the sink list. This page only needs the text-node helper and the rule that a new HTML field is a new review.

IDOR: tenant and id in one predicate

IDOR is CWE-639. The client names an object. The server uses that name. Nobody asks whether this actor may see that row. API1:2023 calls the same miss broken object level authorization. A UUID slows a guesser. It does not authorize the row. Security Stack Exchange answer 272470, Sjoerd, 4 October 2023: unguessable identifiers are not a replacement for proper access control. That sentence is the whole section.

async function loadInvoice(pool, invoiceId, orgId) {
 if (!orgId) return null;
 const { rows } = await pool.query(
 `SELECT id, memo, amount_cents
 FROM invoices
 WHERE id = $1 AND org_id = $2`,
 [invoiceId, orgId]
 );
 return rows[0] || null;
}

loadInvoice is the named fallback. A missing orgId returns null before SQL runs. Zero rows is 404, same as a missing invoice. Do not load by id and then compare owner_id. That second line is the one PATCH forgets. Writes use the same predicate and check rowCount. Strip org_id, owner_id, and role from the body. The actor comes from the session.

One request, three floors

Skip injection and a valid session still concatenates displayName. Skip XSS and a scoped row still prints raw memo. Skip IDOR and a perfect bind still serves org B to org A. The point of this page is the stack, not a glossary.

Same body. Three floors. A green test on one floor does not prove the other two.
POST /invoices/search
Cookie: __Host-session=A
{ displayName, invoiceId }

 parseBody string, max 80, uuid
 |
 loadInvoice id AND org_id = A
 |-- zero rows --> 404
 |
 SQL bind $1 $2 $3 never glue
 |
 escapeHtml memo as text
 |
 200 JSON or <p>encoded memo</p>

Identifiers stay displayName, invoiceId, loadInvoice, parseBody, and escapeHtml across every snippet on this page. If a later example renamed the helper, you would not know which deny you were testing.

Tests that close all three

You are not injecting a production database. You are proving your own helpers returned deny.

  1. POST displayName as an object. Expect 400 from parseBody. A 200 means the handler skipped the parser.
  2. POST a string that looks like SQL in displayName. Expect a bind, then either 404 or a normal memo match, never a syntax error and never extra rows.
  3. Render a memo that contains <em>. View source. Expect escaped text, not a live tag, unless that field is a named HTML sink with a sanitizer you can point at.
  4. As user A, GET user B’s invoiceId from a fixture. Expect 404, not 200 with amount_cents.
curl -sS -D - -o /dev/null -X POST \
 "https://your-app.example/invoices/search" \
 -H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
 -H "Content-Type: application/json" \
 --data '{"displayName":{"$gt":""},"invoiceId":"00000000-0000-4000-8000-000000000001"}'
# Expect: HTTP/2 400

curl -sS -D - -o /tmp/xss-body -X GET \
 "https://your-app.example/invoices/A-id" \
 -H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS"
# Expect: HTTP/2 200 and &lt;em&gt; in the body, not a live em tag

curl -sS -D - -o /tmp/idor-body -X GET \
 "https://your-app.example/invoices/B-id" \
 -H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS"
# Expect: HTTP/2 404

CI runs all three. A green XSS check with no IDOR case is how a build still ships an object bug. Fail the build on any 200 that should have been 400 or 404. That is the whole list. Bind. Encode. Scope.

Questions we keep getting

Is a sanitizer a substitute for escapeHtml?

No. A sanitizer is for the rare field that must accept HTML. Text nodes use escapeHtml. Attributes and URLs use their own encoders. If the product does not need bold, do not parse HTML at all.

Does a UUID remove IDOR?

No. API1 lists UUIDs next to sequential integers. Ids leak through exports, webhooks, and other responses. Put org_id in loadInvoice anyway.

Can I fold all three into “validate input”?

No. A valid UUID still needs org_id. A valid string still needs a bind and an encode. The door is one floor. It does not replace the other two.

Guy Bar-Gil

Guy Bar-Gil / About Author

Guy is a product manager at WhiteSource, where we enable software development teams to integrate open source fearlessly and without compromising agility. Before WhiteSource, Guy worked for the IDF's intelligence division, where he spent time as a combat operator and project manager. Outside of work, you can find Guy reading (everything from fiction to physics), playing and watching sports, traveling the world, and spending time with friends and family. LinkedIn