Security for Developers

APIs and Servers: SSRF, File Uploads, Rate Limits and Misconfiguration

Server-side request forgery, unsafe file uploads, missing rate limits, verbose errors, CORS mistakes and the OWASP API Top 10, each with a small example and a fix.

Lesson 0715 min read#security#api#ssrf#uploads#owasp

On this page

  1. Words used in this lesson
  2. Where this sits in the OWASP Top 10
  3. SSRF: the server fetches a URL for a stranger
  4. File uploads
  5. Rate limiting
  6. Errors: say little to the user, a lot to your log
  7. CORS: the mistake that looks like a fix
  8. The OWASP API Security Top 10 (2023)
  9. Try it
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: your server does things for strangers: it fetches URLs, saves files, answers any number of requests and prints errors. Each of those is a place to get hurt. Decide what a stranger may ask your server to do, limit it, and show them as little as possible when it fails.

Lesson 6 was about the code you pull in. This lesson is about the code you run: your API and your server. We take five small, real problems and fix each one: SSRF (the server fetches a URL for an attacker), unsafe file uploads, no rate limit, verbose error messages, and CORS set up wrongly. Then we look at the OWASP API Security Top 10 as a checklist. The last lesson, Lesson 8, turns all eight lessons into a review checklist, tools and interview answers.

If you want the building-blocks view of an API, see Backend Lesson 2 (REST design), Backend Lesson 3 (validation at the edge), Backend Lesson 4 (error handling), Backend Lesson 5 (logging) and Backend Lesson 6 (auth on the server). Attacks on access control are in Lesson 4. Cookies, CSRF and headers are in Lesson 2.

What I ran / checked. Everything ran on Node v22.22.0 on Linux, in a scratch folder outside this repository, using only Node's built-in modules (node:http, node:net, node:fs, node:crypto, and the built-in fetch). The servers listened on 127.0.0.1 only and I attacked only my own toy code with curl. Run for real: an SSRF server with a "private" service on 127.0.0.1:4001 and a "partner" site on port 4002; four versions of the fetch endpoint; an IP block-list built with net.BlockList; a simulated DNS-rebinding test with a fake lookup function; an upload server with an unsafe and a safe handler; a fixed-window rate limiter; verbose and generic error handlers; and two CORS handlers. All outputs below are copied from those runs (I shortened the long stack traces). Not run: a real DNS-rebinding attack (that needs a DNS server I control; I simulated the two different answers instead), cloud metadata services (no cloud here), a shared rate-limit store such as Redis, and a browser (CORS is enforced by browsers, so I used curl to look at the headers and explain what a browser would do). Sources I read: the OWASP Top 10:2025 introduction and A01:2025 Broken Access Control page (to check where SSRF sits now); the OWASP Cheat Sheet Series pages for Server-Side Request Forgery Prevention, File Upload, REST Security, Error Handling and Logging; and the Node.js "Security Best Practices" guide (denial of service and server timeouts). The OWASP API Security Top 10 (2023) list and its numbering come from a search-result summary of apisecurity.io, because I could not open owasp.org pages in my sandbox (the host would not resolve). The two lists agree with the other results I saw, but check the official page before you quote a number in a document. The "goldbergyoni/nodebestpractices" security section was only skimmed (its "common security best practices" page), not read in full.

Words used in this lesson

  • SSRF (server-side request forgery) means an attacker makes your server send a request to a place the attacker chose, often a place the attacker cannot reach directly.
  • Internal service is a service that only runs inside your network or on the same machine (an admin page, a database UI, a cloud metadata service).
  • Cloud metadata service is a special address (169.254.169.254 on AWS, Azure and GCP) that a cloud machine can ask for its own settings and temporary credentials.
  • Allow-list (allowlist) is a list of what is permitted. Everything else is refused. A deny-list is a list of what is forbidden. It is weaker, because you must think of every bad case.
  • Redirect is a reply (301, 302...) that says "go to this other URL".
  • DNS rebinding is a trick where a name points to a safe address when you check it, and to a different address when you connect to it.
  • Private address is an IP address that is not reachable from the public internet (127.0.0.1, 10.x.x.x, 192.168.x.x, 172.16-31.x.x, 169.254.x.x).
  • Path traversal means using ../ in a file name to climb out of the folder you meant.
  • Magic bytes are the first few bytes of a file that tell what type it is (a PNG starts with 89 50 4E 47).
  • Rate limiting means refusing a client that sends too many requests in a time window.
  • 429 Too Many Requests is the status code for that. Retry-After is the header that says how many seconds to wait.
  • Stack trace is the list of function calls that led to an error, with file names and line numbers.
  • CORS (cross-origin resource sharing) is a set of response headers that tell the browser which other websites may read this site's responses.
  • Origin is scheme + host + port of a page, for example https://app.example.com.
  • BOLA (broken object level authorization) is the API name for IDOR: using someone else's object ID and getting their data (Lesson 4).

Where this sits in the OWASP Top 10

In the OWASP Top 10:2025, SSRF is no longer its own entry. In 2021 it was A10. The 2025 introduction says SSRF has been rolled into A01:2025 Broken Access Control, and the A01 page lists CWE-918 (SSRF) among its weaknesses. The thinking is simple: the server was allowed to ask for something it should not have been allowed to ask for. File uploads and verbose errors fit under A02:2025 Security Misconfiguration and the new A10:2025 Mishandling of Exceptional Conditions (improper error handling, failing open). Missing rate limits are an Insecure Design problem (A06). CORS mistakes are a Security Misconfiguration. For APIs there is a separate list, the OWASP API Security Top 10 (2023), where SSRF is API7:2023. The full list comes at the end of this lesson.

1. SSRF: the server fetches a URL for a stranger

The idea

Many apps have a feature that fetches a URL the user gives: "import a profile picture from a link", "send a webhook to my URL", "show a link preview". The user's browser does not fetch it. Your server does. And your server can reach things the user cannot: an admin page on localhost, a database UI on the office network, and, on a cloud machine, the metadata address 169.254.169.254 that hands out temporary credentials. OWASP's cheat sheet says SSRF abuses an application to talk to the internal or external network or the machine itself.

A small vulnerable server

This is a toy. It has three servers in one file. The "private" service listens only on 127.0.0.1:4001 (stand-in for an internal admin service). A "partner" site on port 4002 is a place we do trust. The public app on port 4000 has an endpoint /fetch that fetches whatever URL it is given.

JavaScript
// ssrf-server.js (toy code, run only on your own machine)const http = require("node:http");// The "private" service. Pretend only the server can reach it.http.createServer((req, res) => {  res.end("INTERNAL ONLY: admin-token=abc123\n");}).listen(4001, "127.0.0.1");// A "partner" site we trust. It has one path that redirects to the private service.http.createServer((req, res) => {  if (req.url === "/redirect") {    res.writeHead(302, { Location: "http://127.0.0.1:4001/" });    return res.end();  }  res.end("hello from partner\n");}).listen(4002, "127.0.0.1");// Our public apphttp.createServer(async (req, res) => {  const u = new URL(req.url, "http://x");  const target = u.searchParams.get("url");  if (u.pathname === "/fetch") {                       // VULNERABLE    const r = await fetch(target);    return res.end(await r.text());  }  // ...fix versions come below}).listen(4000, "127.0.0.1");

The attacker (here, me with curl) asks the public app to fetch the private one:

Shell
curl -s "localhost:4000/fetch?url=http://127.0.0.1:4001/"
TEXT
INTERNAL ONLY: admin-token=abc123

The attacker never talked to port 4001. The app did, and handed the secret over. In real life the private service would be on another machine or a closed port, so the attacker could not reach it any other way.

Fix 1: an allow-list of hosts

If the feature only needs to talk to a few known hosts, say so in code. Refuse the rest. This is the best case, and the OWASP cheat sheet calls it out: when you know the places the app must talk to, use an allow-list.

JavaScript
const ALLOWED = new Set(["localhost:4002"]);          // allowed host:portif (u.pathname === "/fetch-v2") {  const t = new URL(target);                           // parse first, then compare  if (!ALLOWED.has(t.host)) { res.statusCode = 403; return res.end("host not allowed\n"); }  const r = await fetch(t);  return res.end(await r.text());}

Three requests:

TEXT
$ curl -s -w "[%{http_code}]\n" "localhost:4000/fetch-v2?url=http://127.0.0.1:4001/"host not allowed[403]$ curl -s "localhost:4000/fetch-v2?url=http://localhost:4002/"hello from partner$ curl -s "localhost:4000/fetch-v2?url=http://localhost:4002/redirect"INTERNAL ONLY: admin-token=abc123

The first two work as hoped. The third one leaks. The partner host is on the allow-list, but it answered with 302 Location: http://127.0.0.1:4001/, and fetch follows redirects by default, so it went on to the private service. The check ran on the first URL only.

A host check runs before the request. A redirect changes the target after it. Either do not follow redirects, or check every hop again. The OWASP cheat sheet says to turn off redirect-following in your HTTP client for this reason.

Fix 2: do not follow redirects

JavaScript
if (u.pathname === "/fetch-v3") {  const t = new URL(target);  if (!ALLOWED.has(t.host)) { res.statusCode = 403; return res.end("host not allowed\n"); }  const r = await fetch(t, { redirect: "manual" });    // do NOT follow  res.statusCode = r.status === 0 ? 502 : r.status;  return res.end(`upstream answered ${r.status}, not following redirects\n`);}
TEXT
$ curl -s -w "[%{http_code}]\n" "localhost:4000/fetch-v3?url=http://localhost:4002/redirect"upstream answered 302, not following redirects[302]$ curl -s -w "[%{http_code}]\n" "localhost:4000/fetch-v3?url=http://localhost:4002/"upstream answered 200, not following redirects[200]

(In a real endpoint you would send back the body of a 200. I print only the status to keep the example short.) If you must follow redirects, do it by hand: read the Location header, run the whole check again on it, and cap the number of hops.

When there is no short list: check the IP address

Sometimes users really may give any public URL (link previews, webhooks). An allow-list is not possible. Then you must block the dangerous destinations. You cannot do that by looking at the text of the URL, because the same address can be written in many forms. Node's URL parser turns some of them into the same thing, which helps:

TEXT
http://2130706433:4001/       hostname = 127.0.0.1http://127.1:4001/            hostname = 127.0.0.1http://0x7f.0.0.1:4001/       hostname = 127.0.0.1http://user@127.0.0.1:4001/   hostname = 127.0.0.1http://[::ffff:7f00:1]:4001/  hostname = [::ffff:7f00:1]http://localhost:4001/        hostname = localhost

But a name like localhost, or any name an attacker controls, can point at a private IP. So the rule is: resolve the name, then check the IP addresses, not the text. Here is a small block-list using Node's net.BlockList:

JavaScript
// ipcheck.jsconst net = require("node:net");const blocked = new net.BlockList();for (const [ip, prefix] of [  ["127.0.0.0", 8], ["10.0.0.0", 8], ["172.16.0.0", 12], ["192.168.0.0", 16],  ["169.254.0.0", 16], ["0.0.0.0", 8], ["100.64.0.0", 10],]) blocked.addSubnet(ip, prefix, "ipv4");blocked.addAddress("::1", "ipv6");blocked.addSubnet("fc00::", 7, "ipv6");    // unique localblocked.addSubnet("fe80::", 10, "ipv6");   // link localfunction isBlocked(ip) {  if (ip.toLowerCase().startsWith("::ffff:")) {        // IPv4 written inside IPv6    const rest = ip.slice(7);    return net.isIPv4(rest) ? isBlocked(rest) : true;  // odd hex form: refuse it  }  return blocked.check(ip, net.isIPv6(ip) ? "ipv6" : "ipv4");}

I tested it with a list of addresses:

TEXT
127.0.0.1          BLOCKED10.1.2.3           BLOCKED169.254.169.254    BLOCKED192.168.1.5        BLOCKED::1                BLOCKED::ffff:127.0.0.1   BLOCKED::ffff:7f00:1      BLOCKED93.184.216.34      allowed8.8.8.8            allowed

Note the story of the last two ::ffff: lines. My first version only handled ::ffff:127.0.0.1. When I tested ::ffff:7f00:1 (the same address written in hex) it said allowed. That is a real hole, and I only found it because I tested odd forms. Always put your block-list under a test with ugly inputs. 169.254.169.254 is in the list because it is the metadata address for AWS, Azure and GCP that OWASP's cheat sheet lists. For AWS the cheat sheet also says to move to IMDSv2 and turn off IMDSv1, as an extra layer, because it makes this kind of SSRF harder to turn into stolen credentials.

DNS rebinding: check now, connect later

Here is the trap the simple fix misses. A typical "safe" function does:

  1. resolve the name to an IP,
  2. check the IP is public,
  3. call fetch(url) (which resolves the name again and connects).

An attacker who controls the DNS for evil.example can answer step 1 with a public IP, and step 3 with 127.0.0.1. That is DNS rebinding. I could not run a real DNS server, so I simulated it with a fake lookup function that gives a public address the first time and 127.0.0.1 every time after:

JavaScript
let calls = 0;const evilDns = (host, opts, cb) => {  calls++;  const ip = calls === 1 ? "93.184.216.34" : "127.0.0.1";  console.log(`  [dns] lookup #${calls} for ${host} -> ${ip}`);  cb(null, [{ address: ip, family: 4 }]);};

Version A checks first and connects later. Version B checks inside the lookup that the connection itself uses:

JavaScript
const safeLookup = (host, opts, cb) =>  evilDns(host, opts, (err, addrs) => {    if (err) return cb(err);    const bad = addrs.find((a) => isBlocked(a.address));    if (bad) return cb(new Error("blocked address " + bad.address));    cb(null, addrs);  });http.get({ host: "rebind.test", port: 4001, path: "/", lookup: safeLookup, agent: false }, ...);
TEXT
A) check first, connect later (two lookups):  [dns] lookup #1 for rebind.test -> 93.184.216.34  check passed (address was public). now fetching...  [dns] lookup #2 for rebind.test -> 127.0.0.1  result: INTERNAL ONLY: admin-token=abc123B) validate inside the lookup used for the connection:  [dns] lookup #2 for rebind.test -> 127.0.0.1  result: ERROR: blocked address 127.0.0.1

A leaked. B refused. The OWASP cheat sheet says the same in words: an allow-list of domain names alone does not stop DNS rebinding; validate the IPs the name resolves to, and make the HTTP client connect only to a validated address. (One detail from my run: I had to pass agent: false. Node's default HTTP agent reuses an open connection to the same host and port, so my second test silently reused the first test's connection and never did a new lookup. Connection reuse can hide this kind of bug in a test.)

What the simple fix does not cover

Be honest about the limits. A host allow-list plus "no redirects" plus an IP check at connect time is a good start. It still does not cover:

  • Redirects, if you turn them back on, unless you re-check every hop.
  • DNS rebinding, unless the check happens in the connection step (as in B) or you connect to a validated IP directly.
  • Other protocols. SSRF is not only HTTP. The OWASP cheat sheet lists file://, gopher://, ftp:// and others. Accept only http: and https: and check new URL(x).protocol.
  • Your own proxies and libraries. If your HTTP library or an image resizer fetches URLs inside, the check must be there too.
  • The blind case. Even if the response is not shown to the attacker, the request itself can still hit an internal service (for example a "reset" endpoint). The block must stop the request, not only hide the answer.
  • Network layer. Code checks can have bugs. OWASP recommends defence in depth: also put the fetching code in a network segment that cannot reach internal services (egress rules).

Do not use a deny-list of strings like "block URLs that contain 127.0.0.1 or localhost". People write the address as 127.1, 2130706433, [::1], or use a name that resolves to it. Check the resolved IP, with a library, and prefer an allow-list when you can.

2. File uploads

Why it is risky

An upload gives a stranger a way to put bytes on your disk, with a name they chose. OWASP's File Upload cheat sheet lists the main defences: allow only the extensions the feature needs; do not trust the Content-Type header because the client can fake it; give the file a name you generate; set a name length limit and a size limit; and keep the files outside the web root, or on a different server.

An unsafe handler

JavaScript
// upload-server.js (toy)const UPLOAD_DIR = path.resolve(__dirname, "uploads");if (req.url === "/upload-unsafe") {  const name = req.headers["x-filename"];        // the client controls this  const body = await readBody(req, 10_000_000);  const dest = path.join(UPLOAD_DIR, name);      // BAD: trusts the name  fs.writeFileSync(dest, body);  return res.end("saved to " + dest + "\n");}

A normal upload, and then one with a name that climbs out with ../:

TEXT
$ curl -s -H "x-filename: hello.txt" --data-binary "hi" localhost:4100/upload-unsafesaved to /tmp/sec-lab-7/uploads/hello.txt$ curl -s -H "x-filename: ../victim.txt" --data-binary "overwritten by attacker" localhost:4100/upload-unsafesaved to /tmp/sec-lab-7/victim.txt$ cat victim.txtoverwritten by attacker

victim.txt is outside the uploads/ folder, and the attacker replaced it. This is path traversal. On a real server the attacker aims at a config file, a script that runs on a schedule, or a file the web server will execute. path.join does not protect you: it just joins the parts and tidies up the ...

A safer handler

JavaScript
const MAX_BYTES = 1024;                                   // tiny, for the democonst ALLOWED = { ".png": "image/png", ".txt": "text/plain" };const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);if (req.url === "/upload-safe") {  const body = await readBody(req, MAX_BYTES);            // stop reading at the limit  const ext = path.extname(String(name)).toLowerCase();  if (!(ext in ALLOWED)) { res.statusCode = 415; return res.end("type not allowed\n"); }  if (ext === ".png" && !body.subarray(0, 4).equals(PNG_MAGIC)) {    res.statusCode = 415; return res.end("not really a PNG\n");  }  const id = crypto.randomUUID() + ext;                   // WE choose the name  const dest = path.resolve(UPLOAD_DIR, id);  if (!dest.startsWith(UPLOAD_DIR + path.sep)) {          // belt and braces    res.statusCode = 400; return res.end("bad path\n");  }  fs.writeFileSync(dest, body, { flag: "wx" });           // "wx": never overwrite  return res.end("saved as " + id + "\n");}

readBody counts the bytes as they arrive and fails with 413 when the size is over the limit. Here is what happened:

TEXT
$ curl -s -w "[%{http_code}]\n" -H "x-filename: ../victim.txt" --data-binary "x" localhost:4100/upload-safesaved as 601881f8-e543-4c5d-bb1d-45347d8cd53a.txt[200]$ curl -s -w "[%{http_code}]\n" -H "x-filename: shell.js" --data-binary "x" localhost:4100/upload-safetype not allowed[415]$ curl -s -w "[%{http_code}]\n" -H "x-filename: a.png" --data-binary "not an image" localhost:4100/upload-safenot really a PNG[415]$ printf '\x89PNG\r\n\x1a\nrest' | curl -s -w "[%{http_code}]\n" -H "x-filename: a.png" --data-binary @- localhost:4100/upload-safesaved as 48c14e96-9620-440b-af3c-203eacc87219.png[200]$ head -c 5000 /dev/zero | tr '\0' 'a' | curl -s -w "[%{http_code}]\n" -H "x-filename: big.txt" --data-binary @- localhost:4100/upload-safetoo big[413]

Read these results carefully:

  • The ../victim.txt name was accepted but harmless: its extension is .txt, which is allowed, and the file was saved under a random name. The attacker's name was never used for a path. That is the real fix. The startsWith check is a second layer in case a future change brings a name back into the path. (Note the + path.sep: without it, a folder named uploads-evil would pass a plain startsWith("…/uploads") check.)
  • shell.js was refused because .js is not on the list. Allow-list, not deny-list.
  • A text file named a.png was refused because its first bytes were not the PNG magic bytes.
  • A 5,000-byte upload was cut off with 413 because the limit is 1,024. The server stops reading at the limit instead of loading the whole body into memory first.
  • I also sent a plain text body with Content-Type: image/png and a .txt name. It was saved, since the server never trusted the header. The header is just text the client writes.

Magic bytes are a filter, not proof. A file can start like a PNG and still hold other data after it (a "polyglot"). For images that you show to other users, re-encode them (load and save again with an image library), serve user files from a separate domain or a storage service, and send Content-Disposition: attachment and X-Content-Type-Options: nosniff (Lesson 2). If you accept SVG or HTML, you accept a way to run script in your users' browsers (XSS). Scan for malware if files are shared between users.

Also remember the limits that are not in the code: set a maximum request size in your reverse proxy, a timeout for slow uploads, and a quota per user so one person cannot fill the disk. The Node.js security guide lists timeouts (headersTimeout, requestTimeout) because slow or huge requests can keep a server busy until it stops answering.

3. Rate limiting

Without a limit, a client can try ten thousand passwords, scrape your whole API, or send so many expensive requests that others get nothing. A rate limiter counts requests per client and refuses extra ones. The OWASP REST Security cheat sheet says to return 429 Too Many Requests when requests come in too fast. The Retry-After header tells a well-behaved client when to try again.

Here is a tiny fixed-window limiter: 5 requests per 10 seconds per IP address, kept in memory.

JavaScript
const LIMIT = 5, WINDOW_MS = 10_000;const hits = new Map();                       // ip -> { count, resetAt }function rateLimit(ip, res) {  const now = Date.now();  let h = hits.get(ip);  if (!h || now >= h.resetAt) { h = { count: 0, resetAt: now + WINDOW_MS }; hits.set(ip, h); }  h.count++;  res.setHeader("X-RateLimit-Limit", LIMIT);  res.setHeader("X-RateLimit-Remaining", Math.max(0, LIMIT - h.count));  if (h.count > LIMIT) {    res.setHeader("Retry-After", Math.ceil((h.resetAt - now) / 1000));   // seconds    res.statusCode = 429;    res.end("Too Many Requests\n");    return false;  }  return true;}

Seven quick requests:

TEXT
req 1 -> 200req 2 -> 200req 3 -> 200req 4 -> 200req 5 -> 200req 6 -> 429req 7 -> 429

And the headers of a refused request:

TEXT
HTTP/1.1 429 Too Many RequestsX-RateLimit-Limit: 5X-RateLimit-Remaining: 0Retry-After: 10

What this toy does not do, and what a real one needs:

  • It lives in one process. Two server instances each keep their own count, so the real limit is doubled. Production limiters keep the counters in a shared store (like Redis) or in the gateway or CDN in front.
  • Fixed windows allow a burst at the edge. Five requests at second 9 and five more at second 11 are 10 requests in 3 seconds. Sliding windows or token buckets smooth this.
  • "Per IP" is not always the right key. Many users share one IP (an office, a mobile network). Many attackers use many IPs. Use the user ID or API key after login, and the IP before it. Behind a proxy, remoteAddress is the proxy, so read the forwarded header only from a proxy you trust, or every user shares one bucket.
  • Different routes need different limits. Login and password reset need strict limits (Lesson 3). A cheap read route can be looser. Expensive routes (search, export, anything that calls a paid API) need limits too: this is API4:2023 Unrestricted Resource Consumption.
  • Limit the size, not only the count: body size, page size (?limit=1000000), number of items in a batch call, query depth in GraphQL.

4. Errors: say little to the user, a lot to your log

When something breaks, the easy thing is to send the error back. This is what that looked like on my toy server, which pretended its database was down:

TEXT
$ curl -s -w "[%{http_code}]\n" localhost:4200/error-badError: connect ECONNREFUSED 10.0.3.7:5432 (user=app_rw, db=orders_prod)    at brokenDbCall (/tmp/sec-lab-7/api-server.js:28:9)    at Server.<anonymous> (/tmp/sec-lab-7/api-server.js:44:11)    ...

A stranger now knows: an internal IP address and the database port, the database type (5432 is PostgreSQL), the user name and database name, and the exact file paths of your code. Each is a piece of a later attack. The OWASP Error Handling cheat sheet says to catch errors with one global handler and return a generic response, and to write the detail in the log.

The fix: make an error ID, log the whole error with that ID, and return only the ID.

JavaScript
const id = crypto.randomBytes(4).toString("hex");console.error(`[error ${id}]`, e.stack);              // full detail stays in OUR logres.statusCode = 500;res.setHeader("Content-Type", "application/json");res.end(JSON.stringify({ error: "Something went wrong", errorId: id }) + "\n");
TEXT
$ curl -s -w "[%{http_code}]\n" localhost:4200/error-good{"error":"Something went wrong","errorId":"ee2b1619"}[500]

And the server log, which only your team can read:

TEXT
[error ee2b1619] Error: connect ECONNREFUSED 10.0.3.7:5432 (user=app_rw, db=orders_prod)    at brokenDbCall (/tmp/sec-lab-7/api-server.js:28:9)

When a user writes "I got error ee2b1619", you can search the log and find the full story. Good error design:

  • Same message for same problem. For login, say "wrong email or password", not "no such user" (Lesson 3). Otherwise you tell attackers which emails exist.
  • Right status code. 4xx for the client's mistake, 5xx for yours. Do not return 200 with an error inside, and do not "fail open" (let the request through) when a check breaks. OWASP's new A10:2025 Mishandling of Exceptional Conditions is about exactly this.
  • Turn off debug modes in production. Many frameworks print stack traces when a "debug" or "development" setting is on. Check NODE_ENV, and check the response headers for names and versions (X-Powered-By).
  • Log carefully. The OWASP Logging cheat sheet says not to log authentication passwords, session IDs, access tokens, keys, or sensitive personal data. Logs are read by many people and kept for a long time. Do log failed logins, access-control failures and input validation failures, with time, user and request ID, so you can see an attack. And someone has to look at the alerts (Backend Lesson 5).

5. CORS: the mistake that looks like a fix

What CORS is, and is not

Browsers have a rule called the same-origin policy: a page from https://evil.example cannot read responses from https://api.mybank.example. CORS is the way a server says "this other origin may read my responses". It is a relaxation of a safety rule, not a protection. And it is enforced by the browser. curl and a server-side script ignore it. So CORS never protects your API from a script; it only controls which websites can read answers from a user's browser.

Two headers matter most:

  • Access-Control-Allow-Origin: <origin> says which origin may read the response.
  • Access-Control-Allow-Credentials: true says that the browser may send cookies with that request and let the page read the answer.

The classic mistake

A developer fights a CORS error in the browser console and "fixes" it by copying whatever Origin header comes in:

JavaScript
// BADif (origin) {  res.setHeader("Access-Control-Allow-Origin", origin);       // reflects anything  res.setHeader("Access-Control-Allow-Credentials", "true");  // ... and allows cookies}

Now any website can read the logged-in user's data. I tested with curl, pretending to be the browser of Alice (who has a session cookie) visiting https://evil.example:

TEXT
$ curl -s -i -H "Origin: https://evil.example" -H "Cookie: session=alice-secret" localhost:4200/me-badHTTP/1.1 200 OKAccess-Control-Allow-Origin: https://evil.exampleAccess-Control-Allow-Credentials: true{"user":"alice","email":"alice@example.com"}

If Alice opens the evil page, its JavaScript calls fetch("https://api…/me", { credentials: "include" }). The browser adds her cookie, the server answers, and the headers say "evil.example may read this". The evil page now has her email and, in a real app, her tokens, orders and more. This is a real class of bug. The OWASP REST Security cheat sheet says to turn CORS headers off if cross-domain calls are not expected, and otherwise to be as specific as possible about the origins. (Browsers do refuse the literal combination Access-Control-Allow-Origin: * with credentials, which is why people write code that reflects the origin: it gets around that check, and also around the protection.)

The fix: an allow-list of exact origins

JavaScript
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);res.setHeader("Vary", "Origin");                         // caches must key on Originif (origin && ALLOWED_ORIGINS.has(origin)) {  res.setHeader("Access-Control-Allow-Origin", origin);  res.setHeader("Access-Control-Allow-Credentials", "true");}
TEXT
$ curl -s -i -H "Origin: https://evil.example" -H "Cookie: session=alice-secret" localhost:4200/me-goodHTTP/1.1 200 OKVary: Origin{"user":"alice","email":"alice@example.com"}$ curl -s -i -H "Origin: https://app.example.com" -H "Cookie: session=alice-secret" localhost:4200/me-goodHTTP/1.1 200 OKVary: OriginAccess-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Credentials: true{"user":"alice","email":"alice@example.com"}

Look at the first answer. The server still sent the body to the evil origin's request. There is simply no Access-Control-Allow-Origin header, so a browser will not let the evil page read it. CORS does not stop the request from reaching your server, and a request with a cookie still runs (a state-changing request can still do its work). That is the CSRF problem from Lesson 2; SameSite cookies and CSRF tokens are the answer there, not CORS.

Other CORS mistakes to look for:

MistakeWhy it is bad
Reflecting any Origin with credentialsAny site can read logged-in data
Allowing null as an originSandboxed pages and local files send Origin: null, so any attacker can send it
A loose pattern: origin.endsWith("example.com")evilexample.com passes. Compare the whole string, or parse the URL and compare the host exactly
Access-Control-Allow-Origin: * on an API that uses cookies or sees private dataFine for public data, wrong for private data
Forgetting Vary: OriginA cache may serve one origin's headers to another
Thinking CORS is authenticationIt is not. Check the user on the server, every time

The OWASP API Security Top 10 (2023)

APIs have their own Top 10, because API problems look a little different from web page problems: no HTML, many object IDs, many clients. This is the 2023 edition, with each item in one plain line. (Numbers and names as I found them in search-result summaries; see the note.)

IDNameIn plain words
API1:2023Broken Object Level Authorization (BOLA)You send /orders/1002 and get someone else's order because the server never checked that it is yours. (IDOR, Lesson 4.)
API2:2023Broken AuthenticationWeak or missing login: no rate limit on login, weak tokens, tokens that never expire, JWTs accepted without checks. (Lesson 3.)
API3:2023Broken Object Property Level AuthorizationYou may see or change the object, but not every field of it. The API shows secret fields, or lets you set role: "admin" (mass assignment).
API4:2023Unrestricted Resource ConsumptionNo limits on requests, size, time or cost, so one client can use up CPU, memory or your paid services. (Section 3 above.)
API5:2023Broken Function Level AuthorizationA normal user can call an admin function (DELETE /users/5) because only the screen hid the button.
API6:2023Unrestricted Access to Sensitive Business FlowsA flow that is fine once but harmful in bulk: buying all tickets, mass sign-ups, promo code abuse, scraping.
API7:2023Server Side Request ForgeryThe API fetches a URL for an attacker and reaches internal things. (Section 1 above.)
API8:2023Security MisconfigurationDebug on, default passwords, open cloud storage, missing headers, loose CORS, verbose errors. (Sections 4 and 5.)
API9:2023Improper Inventory ManagementYou forgot an old API version (/v1), a test server or a debug endpoint, and it is still online and unprotected.
API10:2023Unsafe Consumption of APIsYou trust data from other APIs and partners too much, so their bad data or a hacked partner hurts you. Validate it like user input.

Notice that three entries (API1, API3, API5) are about authorization at different levels: the object, the field and the function. That is why Lesson 4 says authorization is the number one web risk. The two lists are also close in spirit: the web Top 10 says "A01 Broken Access Control" and the API list says the same thing three times.

A quick API review, using this table: for each endpoint ask, who may call this (API2, API5), which objects and fields (API1, API3), how many times and how big (API4, API6), what does it call outward (API7, API10), how is it deployed and listed (API8, API9).

Try it

  1. Write the three-server SSRF toy from this lesson. Call /fetch with the private URL and see the secret. Then add the host allow-list and call the redirect URL. Does it leak? Fix it with redirect: "manual".
  2. Add a test file for isBlocked with these inputs: 127.0.0.1, ::1, ::ffff:7f00:1, 169.254.169.254, 172.31.255.255, 172.32.0.1, 8.8.8.8. Which should be blocked? (Careful with 172.32.0.1.)
  3. Change fetch to fetch(new URL("file:///etc/hostname")). What happens? Then add a protocol check that only allows http: and https:.
  4. Write the unsafe upload handler, then use ../ to overwrite a file in a scratch folder. Fix it with a generated name. Test a.png with text inside.
  5. Build the rate limiter. Send 8 requests with a for loop. Wait for Retry-After seconds and try again.
  6. Make a route that throws an error. Return the stack trace, then switch to an error ID and find it in the log.
  7. Run the CORS handler that reflects Origin. Send Origin: null and Origin: https://example.com.evil.test to your fixed allow-list version. What do you get?
  8. Take one endpoint of a real project and go through API1 to API10. Write one sentence for each.

Answers

  1. With fetch(t) and the default setting, the redirect leaks. With { redirect: "manual" } you get 302 and nothing else.
  2. Blocked: the first five (172.31.255.255 is inside 172.16.0.0/12). Allowed: 172.32.0.1 (just outside the private range) and 8.8.8.8. If ::ffff:7f00:1 is allowed, your function has the hole I had.
  3. In Node 22.22.0 fetch of a file: URL fails (fetch failed, cause not implemented... yet...), but other libraries and other languages may happily read local files. Always check the protocol yourself: ["http:", "https:"].includes(t.protocol).
  4. With path.join(dir, name) the file lands outside the folder. With crypto.randomUUID() plus the extension it cannot.
  5. After the window passes, requests succeed again.
  6. The ID in the response matches a line in the log.
  7. Neither is in the allow-list, so there is no Access-Control-Allow-Origin header in either answer.
  8. There is no single answer. Look for any "not sure" and turn it into a ticket.

Interview questions

What is SSRF, and why is it dangerous? (Must know)

Server-side request forgery is when an attacker makes my server send a request to a place they chose. It is dangerous because my server can reach things the attacker cannot: internal admin tools, databases on the private network, and on a cloud machine the metadata address 169.254.169.254, which can give out temporary credentials. In my toy, /fetch?url=http://127.0.0.1:4001/ returned a secret from a service that only listens on the local machine. In the OWASP Top 10:2025 it is part of A01 Broken Access Control, and in the API list it is API7.

How do you prevent SSRF? (Must know)

First choice: an allow-list of hosts the feature needs, parsed from the URL and compared exactly. Also accept only http and https, do not follow redirects (or re-check every hop), and do not send the response of the fetch back raw. When users may give any public URL, I resolve the name, check the IP addresses against a block-list of private, loopback and link-local ranges, and make that check part of the connection step, so DNS cannot change between check and use. I also use network rules so the fetching code cannot reach internal services, and on AWS I use IMDSv2.

Why is a host allow-list not enough by itself? (Senior signal)

Three reasons I tested or read. An allowed host can redirect to an internal address, and fetch follows redirects by default, so my allow-list leaked the secret through a 302. A name that I allow can be made to resolve to a private IP by whoever controls its DNS (DNS rebinding). And the same destination can be written in many forms, so string checks on the URL miss things. The fix is to check resolved IPs at connect time and not follow redirects.

Explain DNS rebinding. (Senior signal)

The attacker controls the DNS of a name. When my code checks the name, it resolves to a public IP and passes. When my HTTP client resolves the name again to connect, the DNS gives 127.0.0.1. I simulated it with a fake lookup that returns a public address first and 127.0.0.1 after. The check-then-fetch version leaked, and the version that validates inside the connection's own lookup refused. The rule: validate the address that the connection will actually use.

What can go wrong with file upload, and how do you handle it? (Must know)

Dangerous names (../../x to overwrite files), dangerous types (a script saved somewhere that gets run, or HTML/SVG that runs in a browser), huge files that fill the disk or memory, and lying metadata. I generate the file name myself, check the extension against an allow-list, check magic bytes, limit the size while reading, store files outside the web root or in object storage, serve them with nosniff and as downloads from another domain, and never trust the Content-Type header, because the client can send anything. For user images I re-encode them.

path.join(uploadDir, userName). What is wrong with it? (Trap)

path.join only joins and tidies the path. If userName is ../victim.txt, the result is outside the upload folder. In my test it overwrote a file one folder up. The fix is not to use the user's name at all. If I must, I use path.resolve and check the result still starts with the upload folder plus a path separator, so that a folder called uploads-evil does not pass as uploads.

How would you add rate limiting, and what are the traps? (Must know)

I count requests per client in a time window and return 429 Too Many Requests with a Retry-After header when the limit is passed. In production I keep the counts in a shared store or at the gateway, because per-process counts break with several instances. I choose the key carefully (user ID or API key when I have one, IP otherwise, and I think about shared IPs and proxies), use stricter limits on login and password reset, and also limit the size of each request and the cost of expensive calls. A fixed window allows a burst at the window edge, so a sliding window or token bucket is smoother.

Why not return the exception message to the client? (Must know)

It tells an attacker about my internals: server paths, library versions, database addresses and user names, sometimes even a query. I return a generic message and an error ID, and write the full error, with the same ID, to the log. Then support can find the details. I also do not log passwords, tokens or personal data, and I make sure debug mode is off in production.

What is CORS, and what is a safe configuration? (Must know)

CORS is a set of headers that let a server say which other origins may read its responses from a browser. It relaxes the same-origin policy, and the browser enforces it; a script or curl ignores it. A safe setup is an allow-list of exact origins, Vary: Origin, credentials only for those origins, and no reflecting of the Origin header. I tested the reflect-everything version with a cookie, and got Alice's data with Access-Control-Allow-Origin: https://evil.example and credentials allowed. CORS is not authentication.

Does CORS stop CSRF? (Trap)

No. CORS controls whether a page can read a response. A cross-site form post or simple request can still reach the server and run, with the user's cookie. To stop CSRF I use SameSite cookies, CSRF tokens, and checks on Origin or Sec-Fetch-Site headers (Lesson 2). A "preflight" request helps for some request types, but I do not treat CORS as my CSRF defence.

What is BOLA, and how is it different from broken function level authorization? (Senior signal)

BOLA (API1) is when I can reach another user's object by changing an ID, like /orders/1002: the server checked I am logged in but not that the order is mine. Broken function level authorization (API5) is when a normal user can call a function meant for admins, like DELETE /users/5. The first is about "which data", the second about "which action". Both are fixed by a server-side check on every request, with deny by default (Lesson 4).

You have a new public API. Walk me through a security review. (Senior signal)

I go through the API Top 10 per endpoint. Authentication: how are tokens issued and expired? Object and field checks: can user A read B's data, or set fields like role? Function checks: are admin routes separate and checked? Limits: request rate, body size, page size, expensive calls. Outbound calls: any URL fetching (SSRF) and any trust in partner data. Configuration: debug off, errors generic, CORS allow-list, HTTPS only, headers set. Inventory: are old versions and test endpoints removed? And I check that there are logs and alerts for failures, and tests for each of these.

Key takeaways

  • Your server can reach places a stranger cannot. If a stranger can choose the URL, they can borrow that reach: SSRF. In OWASP Top 10:2025 it is under A01; in the API list it is API7.
  • Use a host allow-list, accept only http(s), do not follow redirects, and check resolved IPs at connect time (against DNS rebinding). Test your block-list with ugly forms like ::ffff:7f00:1.
  • Do not trust file names, extensions or Content-Type. Generate the name, allow-list the type, check magic bytes, limit the size, and use path.resolve plus a prefix check with path.sep.
  • Rate limit with 429 and Retry-After. A per-process in-memory limiter is a toy; use a shared store, pick the key with care, and limit size and cost as well as count.
  • Send users a generic error with an ID. Keep the stack trace in your log. Do not log secrets.
  • CORS is a browser rule, not a firewall. Never reflect Origin with credentials. Use exact-match allow-lists. It does not replace authentication or CSRF defences.
  • The OWASP API Security Top 10 (2023) is a good review list: three of ten are about authorization (object, property, function).

What's next

Lesson 8 is the last in the series. It turns lessons 0 to 7 into things you can use on Monday: a code review checklist, the difference between SAST, DAST, SCA and secret scanning with a free tool for each, a GitHub Actions workflow that runs a dependency audit and a scanner, what to do in the first hour of an incident, and a large set of interview questions and answers. If you want to practise first, take one API from a real project and write one line for each of the ten API risks above.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series