
The common web vulnerabilities are still injection, XSS, CSRF, and missing object checks. The patch is the control, not the CVE number.
A CVSS 10.0 in a product you do not run is a news item. The same class in your ORM escape hatch is a ticket. This page groups the classes so you can find the deeper guide on each one.
The usual mistake is patching a named CVE and leaving the concatenated ORDER BY that made that CVE possible.
Start with the class that matches your stack, then follow the sibling pages for the test that proves the miss is closed.
CVE-2026-24908 is a CVSS 10.0 in OpenEMR’s Patient REST API. NVD published the record on 25 February 2026. AISLE’s writeup, dated 28 April 2026, said the product sits in front of more than 100,000 providers. The _sort query parameter was concatenated into ORDER BY. That is injection. The same AISLE set also listed stored XSS and object-id skips. Four different bugs. Four different first patches.
A patch is a typed boundary, a header the browser sets, a scoped query, or a bind. The four long guides already exist. Use this page to pick the right one.
This page is a map, not a lab
The OWASP Top 10:2025 introduction keeps Broken Access Control at A01. The contributed data said 3.73 percent of tested apps had at least one of the 40 CWEs in that bucket. Injection sits at A05, down from A03 in 2021, and OWASP still says it has the greatest number of CVEs among the 38 CWEs in the category. XSS lives in that injection bucket. CSRF is CWE-352. IDOR is CWE-639, which OWASP files under Broken Access Control. Four names, two Top 10 rows, four different first patches.
MITRE’s 2025 CWE Top 25 still ranks CWE-79 first. Rankings do not pick your ticket. The sink does.
SINK FIRST PATCH HTML / JS in the browser encode for that sink, then CSP cookie on a foreign POST Sec-Fetch-Site same-origin or none client-named object id org_id and id in the same WHERE SQL / shell / ORM hatch bind the value, allowlist the name
OpenEMR 8.0.0 shipped on 11 February 2026. AISLE dated the public writeup 28 April 2026 and counted 38 CVEs from Q1 2026 work. The useful fact for this map is the split: one product, one quarter, and the findings landed in injection, XSS, and object-id skips at the same time. CSRF did not need a new CVE to stay real. SameSite defaults from 2020 never closed GET mutations or sibling hosts.
XSS: encode for the HTML sink
CWE-79 is untrusted data becoming the page. Stored, reflected, and DOM are textbook labels for the same miss. The useful split is the sink: HTML body, attribute, JavaScript string, CSS, or URL. Each one has its own encoding. A comment that says “we escape” next to innerHTML is still the bug.
Firefox 148 shipped setHTML in February 2026. The name looks like a safe cousin of innerHTML. It is a sanitizing writer, not a free pass. HN thread on that release. The comment this page quotes is the naming problem, not a how-to.
The first patch on a React or Express HTML route:
// Express 5: encode for the HTML body sink
const escapeHtml = (value) =>
String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
app.get("/profile", (req, res) => {
const bio = escapeHtml(req.user.bio);
res.type("html").send(`<p>${bio}</p>`);
});
Identifiers stay escapeHtml and bio so they match if you copy this into a test. If the product must accept markup, that is a sanitizer job on the XSS page, not a second innerHTML. HttpOnly does not close XSS. Script in your origin can still fire any request the user is allowed to make and read anything the page can already see, including a CSRF token.
CSRF: read Sec-Fetch-Site
CWE-352 is the server treating a cookie it minted as the user, on a request the user did not mean. Chrome treated cookies with no SameSite as Lax starting with the Chrome 80 rollout in February 2020. That closed the old foreign POST-with-cookies shape for most people. It did not close GET state changes, sibling-subdomain requests, or clients that never send Fetch Metadata.
MDN marks Sec-Fetch-Site Baseline widely available since March 2023. The browser sets it. Frontend JavaScript cannot. The first patch on a cookie-authenticated POST is: allow same-origin and none, treat same-site like cross-site unless you have listed every hostname on the eTLD+1, and fail closed when the header is missing. csurf 1.11.0 last published on 19 January 2020. Express TC deprecated it on 16 May 2025. Do not install it.
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
function csrfAllowed(req) {
const site = req.get("sec-fetch-site");
if (site === "same-origin" || site === "none") return true;
if (!site) return false;
return SAFE.has(req.method);
}
app.use((req, res, next) => {
if (SAFE.has(req.method)) return next();
if (csrfAllowed(req)) return next();
res.status(403).send("Forbidden");
});
Bearer tokens in Authorization are not auto-attached, so they fall out of this ticket. Cookies do not. SameSite is a site boundary, not an origin lock. __Host-session is the cookie prefix that refuses Domain. Tokens stay for login and for old clients. The long CSRF page has the Origin fallback, Gitpod CVE-2024-21583, and the curl proof. Here the work is the header check only.
IDOR: scope the row in SQL
CWE-639 is authorization bypass through a user-controlled key. Authentication answered who. IDOR is which row. A UUID does not change that. OWASP API1:2023 says object ids can be integers, UUIDs, or strings, and that comparing the session user id to the parameter is not enough when the object is an invoice, a file, or a thread.
More than 64 million McHire applicants sat behind a login that still answered any inbox id. Public reporting on that case clustered in June 2025. Suno.com’s October 2025 disclosure was a valid user and the wrong song id. AISLE’s OpenEMR set in April 2026 listed more object-id skips in the same quarter as the CVSS 10.0 sort bug. The shared miss is the query that only asked for the id.
// scoped read: org and invoice together
const invoice = await db.query(
`SELECT id, total_cents, status
FROM invoices
WHERE org_id = $1 AND id = $2`,
[req.session.orgId, req.params.invoiceId],
);
if (!invoice.rowCount) {
return res.status(404).send("Not found");
}
Identifiers stay orgId and invoiceId. Return 404, not 403, so a known id is not an existence oracle. Strip owner_id, org_id, and role from PATCH bodies. Load-then-compare is how writes get missed: you fetched the row unscoped, then asked a policy function, and a new route skipped the function. Put the tenant in SQL. The long IDOR page has Postgres RLS, mass assignment, and the two-account replay. The map ends once the WHERE is scoped.
Injection: bind values, allowlist names
CWE-89 is SQL. CWE-78 is OS command. CWE-94 is code. The shared shape is a string or object you did not mint, parsed as grammar by a system that was supposed to treat it as data. OpenEMR’s CVSS 10.0 was an identifier in ORDER BY, the bind you cannot do. A prepared statement would not have saved a concatenated column name. An allowlist would.
const SORTABLE = new Set(["created_at", "last_name", "uuid"]);
function orderBy(sortKey) {
const key = String(sortKey || "created_at");
if (!SORTABLE.has(key)) {
throw new Error("unsupported sort");
}
return `ORDER BY ${key} ASC`;
}
const rows = await db.query(
`SELECT id, last_name FROM patients WHERE org_id = $1 ${orderBy(req.query.sort)}`,
[req.session.orgId],
);
Identifiers stay SORTABLE, orderBy, and sortKey. Bind org_id. Allowlist the column. Never concatenate req.query.sort into SQL. On the ORM side, the hatches that turn the stack back into a string builder are $queryRawUnsafe, Sequelize.literal, whereRaw, and TypeORM query(). Grep those before you trust the model. Prefer execFile over exec, then -- so a leading-dash filename cannot become a flag. The long injection page has OpenEMR’s hatch, Express 5 query shape, and the CI greps. Bind plus allowlist is the whole first patch.
Which ticket to open first
One request can trip more than one row. A stored XSS in an admin queue is also a CSRF plus an object-id problem once the script runs in a privileged origin. Open the sink ticket first. Then walk the others.
| What you saw | Ticket | First patch |
|---|---|---|
Markup in a bio, search, or innerHTML | XSS, CWE-79 | escapeHtml at that sink |
| Cookie session, foreign or sibling POST | CSRF, CWE-352 | csrfAllowed on mutations |
| Logged-in user, someone else’s invoice id | IDOR, CWE-639 | org_id and id in one WHERE |
| User text in SQL, shell, or ORM hatch | Injection, CWE-89/78 | bind plus SORTABLE |
If the session cookie is HttpOnly, XSS is still worth the ticket. Script in the origin can still rewrite the DOM and call any route the user can call. If you only have a Bearer header and no cookies, CSRF is the wrong ticket. Lock CORS. If the query string is the grammar, that is injection, not IDOR. If the query string is only the id and the query ran, that is IDOR, not injection.
Prove the four on your own app
You are not walking an exploit. You are proving your own handlers encode, refuse a cross-site POST, scope the row, and bind the value.
- Pick a profile or comment field you already render. Put an ampersand and a less-than in your own account. View source. You want entities, not raw markup. A raw
<means the sink skippedescapeHtml. - In DevTools, copy as cURL a state-changing request you already make. Keep your Cookie header. Add
Sec-Fetch-Site: cross-site. Replay against your origin. Expect 403. A 200 meanscsrfAllowednever ran. - With two of your own test accounts, replay A’s session against B’s
invoiceId. Expect 404. A 200 with B’s total is the missingorg_id. - Grep for
$queryRawUnsafe,Sequelize.literal,whereRaw, TypeORMquery(, and string-builtORDER BY. Each hit needs a bind or an allowlist. OpenEMR’s miss was the allowlist.
curl -sS -D - -o /dev/null -X POST "https://your-app.example/account/email" \
-H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
-H "Sec-Fetch-Site: cross-site" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "email=you@your-app.example"
# Expect: HTTP/2 403
Run the same copy with the Sec-Fetch-Site line deleted. If you fail closed, that is also 403. A first-party job that must pass should send Sec-Fetch-Site: none on purpose. Then grep routers for app.get handlers that write to the database. Lax will send the cookie on those.
Questions we keep getting
Why not put every control on this page?
Because the long guides already do, and a map that copies them will rot in two places. This page names the sink, shows the first patch, and sends you to XSS, CSRF, IDOR, or injection. If you need Trusted Types, Gitpod cookie toss, Postgres RLS, or Express 5 query shape, open that guide.
Is a WAF the first patch?
No. A WAF is a tripwire in front of a sink you have not closed. Encode, header-check, scope, and bind on the origin you own. Then a WAF can watch for the cases you missed. It cannot replace those four.
Does OWASP 2025 change the order I patch?
A01 is still Broken Access Control, so IDOR stays a first-week ticket. A05 is still Injection, and XSS still lives there. CSRF is not its own Top 10 row. It is still a cookie-authenticated mutation. Rankings do not change the sink test above.



