Concept
The beginner framing: three attacks that look unrelated on the surface, tricking a click, poisoning a database query, making a server fetch a URL it shouldn't, all come down to the same root cause: the application trusted something as safe that was actually attacker-influenced, whether that's what a click visually appears to target, what a string is assumed to only contain data, or what a server-side request is assumed to only ever reach.
Clickjacking, trusting that a click lands where it visually appears to
<!-- evil.com -->
<div style="position:relative; width:300px; height:250px;">
<button style="position:absolute; top:100px; left:100px;">
Click here to claim your prize!
</button>
<iframe src="https://victim.com/account/delete"
style="position:absolute; top:0; left:0; width:300px; height:250px;
opacity:0.01; z-index:10;">
</iframe>
</div>The attacker frames a real, logged-in page from victim.com and makes it nearly transparent, precisely positioned over a fake, enticing button of their own. The victim sees "Click here to claim your prize!", their actual click lands on the invisible victim.com iframe underneath, at a coordinate the attacker chose to align exactly with victim.com's real "Delete Account" (or "Authorize," or "Confirm Purchase") button. The click executes using the victim's genuine, already-authenticated session inside that iframe, same-origin policy doesn't stop this, because the attacker never needs to read anything across the frame boundary, only to visually mislead where a click lands.
Step through the frame-and-overlay attack, with and without an anti-framing header:
<!-- evil.com --><div style="position:relative"><button>Click here to win a prize!</button><iframe src="https://victim.com/delete-account"style="opacity:0.01; position:absolute; top:-40px"></iframe></div>
The attacker doesn't need to break same-origin protections at all, they just stack a real, logged-in victim.com page on top of (or under) fake attacker content, nearly transparent, precisely positioned so the victim's real click lands on the hidden frame's actual button.
SQL/command injection, trusting that a string stays data
// Vulnerable: string concatenation builds the query
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
// Attacker submits username: admin' --
// Resulting query:
// SELECT * FROM users WHERE username = 'admin' --' AND password = '...'
// ^^ everything after this is now a COMMENTThe -- sequence starts a SQL comment, everything after it, including the password check, is silently discarded by the database. The application assumed username would only ever contain a name; the database has no such assumption, it just parses and executes whatever valid SQL syntax it receives, and ' is a syntactically significant character that lets attacker input escape the intended data context and inject actual SQL structure. The exact same class of bug, with the exact same root cause, applies to shell command construction (command injection: unescaped input reaching exec()/child_process) and NoSQL query objects (NoSQL injection: unescaped input reaching a query-object builder, e.g. { $where: userInput }).
SSRF, trusting that a server-side request only reaches intended destinations
// A "fetch this image URL and resize it" feature:
app.post("/api/fetch-image", async (req, res) => {
const response = await fetch(req.body.imageUrl); // ❌ fetches WHATEVER URL the client sends
res.send(await resizeImage(await response.arrayBuffer()));
});
// Attacker submits imageUrl: "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
// (a well-known cloud provider internal metadata endpoint, unreachable from the public internet, // but directly reachable from the SERVER itself)Server-Side Request Forgery flips the usual trust direction: instead of tricking a victim's browser, the attacker tricks the server itself into making a request on the attacker's behalf, to an internal address the attacker could never reach directly. Cloud metadata endpoints (like AWS's 169.254.169.254) are a classic high-value target, since they can return temporary cloud credentials to anything that queries them from inside the cloud network, turning a seemingly harmless "fetch this URL" feature into a path to full infrastructure compromise.
Try It
Predict the outcome before checking the solution.
app.post("/api/webhook-test", async (req, res) => {
const { callbackUrl } = req.body;
const result = await fetch(callbackUrl); // pings a URL the user provides to "test their webhook"
res.json({ status: result.status });
});An attacker submits callbackUrl: "http://localhost:6379/" (the default port for Redis, which the company's internal infrastructure happens to run unauthenticated on the same private network as this server). Beyond confirming Redis is reachable, why is this specific pattern, testing HTTP status codes against internal ports, particularly dangerous even without extracting any actual data?
Solution
This is SSRF used for internal network reconnaissance/port-scanning. Even without extracting a single byte of real data, an attacker can iterate through internal IP ranges and ports (localhost:22, localhost:3306, localhost:6379, 10.0.0.5:8080...) and use the response status/timing/error differences (connection refused vs. timeout vs. 200 OK) to map out the internal network topology, which services exist, on which ports, on which internal hosts, entirely from outside, using the target server as an unwitting scanning proxy. This reconnaissance is often the first stage of a larger attack chain, using the mapped internal services as the next targets (e.g., an unauthenticated internal Redis instance found this way could then be directly exploited for further access).
Implement It Yourself
Build a minimal, runnable demonstration of SQL injection via string concatenation vs. parameterization, the exact mechanism, without needing a real database:
// A tiny mock "SQL executor" that mimics the ACTUAL vulnerability:
// string concatenation lets attacker input change query STRUCTURE.
function buildQueryVulnerable(username) {
return `SELECT * FROM users WHERE username = '${username}'`;
}
function buildQueryParameterized(username) {
// parameterized: the value is passed SEPARATELY from the query structure, // the driver sends them to the database as distinct things, never concatenated
return { sql: "SELECT * FROM users WHERE username = ?", params: [username] };
}
const attackerInput = "'; DROP TABLE users; --";
console.log(buildQueryVulnerable(attackerInput));
// SELECT * FROM users WHERE username = ''; DROP TABLE users; --'
// ^ a REAL database parses this as TWO statements, the SELECT, then a DROP TABLE
console.log(buildQueryParameterized(attackerInput));
Run both through an actual SQL engine and the difference is stark: the vulnerable version's query STRING literally changes structure based on input content; the parameterized version's query structure is fixed at write time, the placeholder ? is filled with a value, never with syntax, regardless of what that value contains. This is why parameterized queries (via pg, mysql2, Prisma, or any real driver's parameter binding) fully close this vulnerability class rather than just reducing its likelihood, the attacker's string can never escape the data context, because it's never concatenated into the query text at all.
Under the Hood
Clickjacking's actual browser-level fix, refusing to render the frame at all, is the same X-Frame-Options/frame-ancestors mechanism covered mechanically, with real block/allow behavior, in Content Security Policy (CSP). SSRF and injection are both explicitly named, high-ranked categories in the current OWASP Top 10, SSRF was folded directly into 2025's #1 category (Broken Access Control) rather than remaining standalone, reflecting that it's fundamentally an access-control failure (the server making a request it shouldn't have access-control-permission to make), and Injection remains a top-5 category across every edition. The parameterized-query fix shown above is a specific instance of the exact same "keep data and structure separate" principle behind escaping in XSS, HTML-escaping keeps a string from becoming markup structure; parameterization keeps a string from becoming query structure.
Common Mistakes
1. Trying to fix SQL injection by escaping quotes manually
const escaped = username.replace(/'/g, "\\'"); // ❌ incomplete, database/driver-specific
const query = `SELECT * FROM users WHERE username = '${escaped}'`;Manual quote-escaping is driver- and database-specific (different databases have different escaping rules), and it's easy to miss an edge case (encoding tricks, multi-byte character sequences that defeat naive escaping in some historical database/driver combinations). Parameterized queries eliminate the entire class instead of trying to out-escape it, always prefer them over hand-rolled escaping.
2. Assuming SSRF only matters if the server has "real" credentials to leak
const response = await fetch(req.body.url); // "we don't have secrets, so this URL fetch is fine"Even without credential theft, SSRF enables internal network reconnaissance/port-scanning (shown in Try It above), can be used to reach internal-only admin panels or services with no external auth layer, and can sometimes be escalated to read local files via file:// URL schemes if the fetch implementation doesn't restrict schemes. "We have nothing valuable to steal" is rarely actually true once internal network access is on the table.
3. Relying only on X-Frame-Options and not frame-ancestors
X-Frame-Options: SAMEORIGIN // ❌ alone, older, more limitedX-Frame-Options only supports a single value or SAMEORIGIN/DENY, it can't express "allow framing by these three specific trusted partner domains." CSP's frame-ancestors directive is the modern, more expressive replacement and should be set alongside (or instead of) X-Frame-Options for broader, more precise browser support.
Best Practices
- Always use parameterized queries / prepared statements for any database interaction involving external input, never string-concatenate values into query text, regardless of how "safe" the input source seems.
- Validate and allowlist URL schemes and destinations for any server-side fetch driven by user input, restrict to
https:only, and where feasible, resolve and check the destination isn't a private/internal IP range before fetching (cloud metadata endpoints and internal services are common SSRF targets). - Set
Content-Security-Policy: frame-ancestors(andX-Frame-Optionsfor older browser support) on any page containing sensitive actions, to prevent clickjacking framing outright. - Apply the principle of least privilege to any process making outbound requests, a server that can only reach the specific external hosts it legitimately needs can't be abused for broad internal reconnaissance even if an SSRF bug exists.
- Treat all three of these as instances of one principle: never let attacker-influenced input cross from a "data" context into a "structure/execution/trust" context without an explicit, verified boundary.
Performance Tips
- Parameterized queries are not just safer than string concatenation, many database drivers can also cache and reuse the parsed query PLAN across calls with different parameter values, since the query structure itself doesn't change; string-concatenated queries with varying content typically can't benefit from this.
- URL/destination validation for SSRF defense (DNS resolution + private-IP-range checks) adds a small per-request cost, but it's negligible compared to the fetch itself, and trivial compared to the cost of a successful SSRF-driven compromise.
