Frontend Labs
⋯

65. OWASP Top 10:2025, implemented in JavaScript

Each risk below has the vulnerable pattern, the fix, and how to test it.

A01 Broken Access Control

The most common serious bug is IDOR (insecure direct object reference): the API trusts an ID from the client.

// ✘ Vulnerable: any signed-in user can refund ANY order by changing the id
app.post("/api/refunds", requireSession, async (req, res) => {
  const order = await db.orders.get(req.body.orderId);
  await refund(order);
});

// ✔ Support Desk: scope every lookup by the session's user, and answer
//   "not found" for "not yours" so IDs can't be enumerated.
const order = orders.get(String(orderId));
if (!order || order.owner !== s.userId) throw new HttpError(404, "Order not found");

Also in A01:

  • Client-side-only authorization. Hiding the "Delete user" button for non-admins is UX. The endpoint must check the role itself.
  • CORS misconfiguration. Access-Control-Allow-Origin: <reflected Origin> together with Allow-Credentials: true lets any website read your users' data. Use an explicit allowlist (§28, §62).
  • SSRF (merged into A01 in 2025). A Node or edge endpoint that fetches a user-supplied URL can be pointed at http://169.254.169.254/ (cloud metadata) or internal services. Allowlist hosts, resolve and reject private IP ranges, and don't follow redirects blindly.

Test it with two users in two browser contexts (§77). Support Desk's suite asserts that Alice gets a 404 when she refunds Bob's order.

A02 Security Misconfiguration

Most of the browser's defenses are off until a header turns them on. Support Desk sends this on every response:

const SECURITY_HEADERS = {
  "Content-Security-Policy": [
    "default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data:",
    "connect-src 'self'", "base-uri 'none'", "form-action 'self'", "frame-ancestors 'none'",
    "object-src 'none'", "require-trusted-types-for 'script'", "trusted-types 'none'",
  ].join("; "),
  "X-Content-Type-Options": "nosniff",                  // no MIME sniffing a JSON file into a script
  "Referrer-Policy": "strict-origin-when-cross-origin", // no full URLs (with tokens) leaking in Referer
  "Cross-Origin-Opener-Policy": "same-origin",          // other windows can't script-reach yours (XS-Leaks, §51)
  "Cross-Origin-Resource-Policy": "same-origin",
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
  // in production, over HTTPS:  "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload"
};

frame-ancestors 'none' is the modern clickjacking defense; it replaces X-Frame-Options. Other common misconfigurations:

  • Source maps in production expose your original source. That's fine for open-source apps, but upload them to your error tracker instead of serving them if the code is private.
  • Debug and test routes. Support Desk's /__test__/orders data factory (§76) is mounted only when NODE_ENV=test. Verify that in a test, or with a smoke check against the production build.
  • Verbose errors. See A10.

Rolling out a CSP on an existing app. Start with Content-Security-Policy-Report-Only and a reporting endpoint (A09). Watch what would break for a week, fix or allowlist it, then enforce. Prefer nonces or hashes with 'strict-dynamic' over host allowlists. Allowlisted CDNs often host JSONP or old Angular builds that attackers can abuse to bypass the policy.

A03 Software Supply Chain Failures

New in 2025, and the JavaScript ecosystem supplied the case studies:

War story: polyfill.io (June 2024). A popular CDN for browser polyfills changed hands. The new owner began serving modified scripts that redirected some mobile visitors to scam sites. More than 100,000 websites loaded it with a plain <script src>, so they all ran whatever the domain decided to send.

War story: npm, September 2025. A maintainer of chalk, debug and about 16 other packages, with billions of weekly downloads between them, was phished through a fake "npm support" email. The published versions contained browser code that hooked fetch and wallet APIs to swap cryptocurrency addresses. Days later, the Shai-Hulud worm spread through npm on its own: it stole npm and GitHub tokens in postinstall scripts and used them to publish infected versions of the victims' packages. A second wave followed in November 2025.

Defenses, in order of effort:

npm ci                                  # install exactly the lockfile; never `npm install` in CI
npm config set ignore-scripts true      # no install scripts by default; allow per package when needed
npm audit signatures                    # verify registry signatures and provenance attestations
npm audit --omit=dev                    # known CVEs in what you ship
  • Pin and review. Use a lockfile, and Renovate or Dependabot with a minimum release age, so brand-new versions (where most malicious publishes are caught) wait a few days before you adopt them.
  • Fewer dependencies. Support Desk and Browser Lab have zero runtime dependencies. Each dependency adds code you run with full privileges.
  • Self-host third-party scripts, or pin them with Subresource Integrity, so a changed file is refused:
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"
        integrity="sha384-…" crossorigin="anonymous"></script>
<!-- compute: curl -s URL | openssl dgst -sha384 -binary | openssl base64 -A -->
  • Publish with provenance (npm publish --provenance from CI, or npm trusted publishing) if you maintain packages, and require 2FA.

A04 Cryptographic Failures

// ✘ predictable: Math.random() is not a CSPRNG
const token = Math.random().toString(36).slice(2);
// ✔ unpredictable
const token = crypto.randomUUID();                          // or:
const bytes = crypto.getRandomValues(new Uint8Array(32));   // 256 bits
  • Hash passwords on the server with scrypt, Argon2id or bcrypt, never SHA-256. Support Desk uses crypto.scryptSync with a per-user salt and compares with timingSafeEqual.
  • Nothing in your bundle is secret. API keys in front-end code are public keys. Put them behind an edge function (§62).
  • Use Web Crypto's AES-GCM and ECDSA (§46), not hand-rolled algorithms, and HTTPS with HSTS everywhere.

A05 Injection: XSS, prototype pollution, NoSQL

DOM XSS happens when attacker-influenced strings reach a sink that parses HTML or code: innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, new Function, setTimeout("string"), and URLs assigned to location, href or src (javascript: URLs).

Trusted Types make those sinks refuse plain strings, page-wide. They only accept objects created by a policy you named in the CSP. This was verified in Chromium with Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html:

el.innerHTML = "<img src=x onerror=alert(1)>";   // TypeError: requires 'TrustedHTML' assignment

// The one reviewed place in the app that may produce HTML:
const policy = trustedTypes.createPolicy("app-html", {
  createHTML: (input) => DOMPurify.sanitize(input),   // or escape it, or el.setHTML()
});
el.innerHTML = policy.createHTML(userBio);           // ✔ goes through the sanitizer

trustedTypes.createPolicy("sneaky", { createHTML: (s) => s });  // TypeError: not in the CSP allowlist
setTimeout("doThing()");                                         // TypeError: requires 'TrustedScript'

Support Desk goes further with trusted-types 'none': no policies at all. The app builds every element with createElement and textContent, so there is no HTML to trust. Where HTML is unavoidable (user Markdown, rich text, model output), use Element.setHTML() from the Sanitizer API (Firefox 148+, Chrome 146+) or DOMPurify, inside a single policy.

Framework escape hatches are sinks too: dangerouslySetInnerHTML, v-html, Svelte's {@html}, Angular's bypassSecurityTrustHtml. Search for them in code review.

Prototype pollution is injection into the object model. A naive deep merge on attacker-controlled JSON (verified):

function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === "object" && source[key] !== null) {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);
    } else target[key] = source[key];
  }
  return target;
}
merge({}, JSON.parse('{"theme":"dark","__proto__":{"isAdmin":true}}'));
({}).isAdmin;     // true: EVERY object in the process now "is admin"

JSON.parse creates an own property called __proto__. The merge then reads target["__proto__"], which is Object.prototype, and writes into it. In Node this has turned into remote code execution through gadgets like child_process options. The fix is to iterate own keys only, skip the dangerous ones, and use null-prototype objects or Map for dictionaries:

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
    const value = source[key];
    if (typeof value === "object" && value !== null && !Array.isArray(value)) {
      if (!Object.hasOwn(target, key)) target[key] = Object.create(null);
      safeMerge(target[key], value);
    } else target[key] = value;
  }
  return target;
}

Support Desk's component registry uses Object.hasOwn(registry, component) rather than registry[component], so an agent asking for the component "constructor" or "__proto__" gets nothing.

NoSQL and SQL injection in Node. db.users.findOne({ email, password }) with a JSON body lets an attacker send {"password": {"$ne": null}}. Validate types (typeof password === "string") or use a schema validator (Zod, Valibot, Ajv) on every request body. For SQL, use parameters every time: sql`SELECT * FROM orders WHERE id = ${id}` with a tagged-template driver, or $1 placeholders. Never build queries with string concatenation.

ReDoS. A regex with nested quantifiers, like /^(a+)+$/ on "aaaa…!", backtracks exponentially and blocks the event loop (§12). RegExp.escape (§55) protects patterns built from user input. Lint for vulnerable regexes, and cap input length before matching.

A06 Insecure Design

These are bugs that no input sanitizer can catch because the logic is wrong:

  • State machines. Can an order be refunded twice? Support Desk's agent says "already in progress". A real server should also reject the second POST /api/refunds with a 409, and the user's double-click should be harmless (idempotency keys).
  • Limits on business flows. Gift-card checks, OTP attempts, invites and "send SMS" are all brute-force or cost targets. Rate-limit per account and per IP.
  • Human approval for irreversible agent actions (§60, §61) is a design decision, not a filter.

Threat-model each feature before you build it: who can call it, with what input, what is the worst outcome, and which control prevents it.

A07 Authentication Failures

Covered in depth in §67. In short: hash passwords properly, rate-limit failed logins (Support Desk counts failures per IP + email), return the same error for "no such user" and "wrong password", rotate the session ID at login (which defeats session fixation), and prefer passkeys.

A08 Software or Data Integrity Failures

  • SRI for third-party scripts (A03 above).
  • Check postMessage senders. Any window can post to yours:
// ✘ trusts everyone
addEventListener("message", (e) => applySettings(e.data));
// ✔ trusts one origin, and validates the shape anyway
addEventListener("message", (e) => {
  if (e.origin !== "https://billing.example") return;
  if (e.data?.type !== "settings" || typeof e.data.theme !== "string") return;
  applySettings(e.data);
});
// and when sending: never "*" if the data is sensitive
popup.postMessage(token, "https://billing.example");
  • JWT verification pins the algorithm. Accepting the token's own alg header enabled the classic alg: none and RS256→HS256 confusion attacks. §62's verifier rejects anything that isn't HS256, and its test sends an alg: none token to confirm.
  • Don't trust client-computed values. Prices, totals and discounts sent from the browser must be recomputed on the server.

A09 Security Logging and Alerting Failures

You can't respond to what you can't see. Support Desk writes one JSON line per security event, which a log pipeline can alert on:

function log(event, fields = {}) {
  console.log(JSON.stringify({ t: new Date().toISOString(), event, ...fields }));
}
log("login.failed", { email: String(email).slice(0, 100), ip });   // never log the password
log("refund.requested", { user: s.userId, order: order.id });

On the client, collect what only the browser sees:

// CSP violations: an attempted XSS often shows up here first
// Server header:  Content-Security-Policy: …; report-to csp
//                 Reporting-Endpoints: csp="https://app.example/reports"
addEventListener("securitypolicyviolation", (e) =>
  navigator.sendBeacon("/reports/csp", JSON.stringify({ directive: e.effectiveDirective, blocked: e.blockedURI, source: e.sourceFile })));

// Uncaught errors and rejections (Browser Lab's Debugging station shows these live, §83)
addEventListener("error", (e) => report("error", e.error));
addEventListener("unhandledrejection", (e) => report("rejection", e.reason));

Alert on spikes: failed logins per minute, 403/404 bursts from one account (IDOR probing), and new CSP violation sources.

A10 Mishandling of Exceptional Conditions

New in 2025. When something unexpected happens, the code should fail closed and say little:

// ✘ fail open: an exception in the permission check grants access
async function canEdit(user, doc) {
  try { return (await acl.lookup(user, doc)).canEdit; }
  catch { return true; }                    // "don't block users when the ACL service is down"
}

// ✔ fail closed, log the details, return a generic error with a request id
} catch (err) {
  const status = err instanceof HttpError ? err.status : 500;
  const requestId = randomBytes(6).toString("hex");
  if (status === 500) log("error", { requestId, path, message: err.message, stack: err.stack });
  send(res, status, { error: status === 500 ? "Something went wrong" : err.message, requestId });
}

Support Desk's central handler is the code above. Its test sends malformed JSON and asserts that the response is exactly { error: "Malformed JSON", requestId }, with no stack trace. Also in A10:

  • Unhandled rejections crash Node (since v15). Handle them, log them, and let a supervisor restart the process rather than limping on in an unknown state.
  • Partial failures. If step 2 of 3 fails, roll back (database transactions) or compensate. Never leave money moved and the order unmarked.
  • Resource exhaustion is an exceptional condition too: bound body sizes (Support Desk's readJSON throws 413 above 4 KB), timeouts and queue lengths.

Generated from real-life-JS-guide.md · 8 Oct 2026 16:36