Trust Boundaries: Everything the Client Sends Is Hostile

A catalogue of the things developers accidentally trust, and what each one lets an attacker do.

"Never trust user input" is understood by everyone and applied by almost nobody, because the trusted values rarely look like input. They look like infrastructure.

The things that are attacker-controlled

Looks likeActually isConsequence if trusted
X-Forwarded-Fora client-settable headerrate limit bypass, forged audit logs
Hosta client-settable headerpassword-reset links to the attacker's domain
Referera client-settable headerbypassed CSRF checks
User-Agenta client-settable headerbypassed device pinning
X-User-Id from "the gateway"anything, if not strippedauthenticate as anyone
The URL pathattacker-chosenBOLA, cross-tenant reads
A filename on uploadattacker-chosenpath traversal, overwrite
A price in the request bodyattacker-chosenbuy things for zero
A JWT payload, unverifiedattacker-authoredtotal bypass
A third party's responsetheir compromise, your probleminjection, SSRF chains

The rule

A value is trustworthy only if you verified it, or if something you trust verified it and the path in between cannot be bypassed. Both halves matter — a gateway that verifies is worthless if the service is reachable around it.

The three that catch experienced people

  • Price and total in the body. Recalculate server-side from ids. Never accept an amount the client computed.
  • Origin versus Referer. Origin is set by the browser and page scripts cannot change it — usable for CSRF checks. Referer is stripped by privacy settings and proxies, so a check that allows a missing Referer is trivially bypassed.
  • Third-party responses. Data from a vendor's API is untrusted input. Validate its shape, bound its size, and never render it unescaped.

Example

Example · bash
# Ten minutes against your own staging environment.

# 1. Forge the client IP — bypasses IP rate limits and poisons audit logs
curl https://staging.dfg.com/api/login -H 'X-Forwarded-For: 1.2.3.4' \
  -d '{"email":"[email protected]","password":"wrong"}'

# 2. Forge the identity — works if any service reads a header
curl https://staging.dfg.com/api/orders -H 'X-User-Id: 1'

# 3. Poison the Host header — check what URL the reset email contains
curl https://staging.dfg.com/auth/forgot -H 'Host: evil.com' \
  -d '{"email":"[email protected]"}'

# 4. Set your own price
curl -X POST https://staging.dfg.com/api/orders \
  -H "$AUTH" -d '{"itemId":42,"quantity":1,"price":0.01}'

# 5. Claim a role you do not have
curl -X PATCH https://staging.dfg.com/api/users/me \
  -H "$AUTH" -d '{"role":"admin"}'

# Each of these has caused a real, public incident somewhere.

When to use it

  • A rate limiter is bypassed by rotating X-Forwarded-For until trusted proxies are configured to only believe the real proxy.
  • A password reset email is sent containing a link to an attacker's domain because the URL was built from the Host header.
  • An order is placed for one cent because the checkout endpoint accepted a client-supplied price instead of recalculating it.

More examples

Prices and totals: recompute, never accept

Note that couponCode is accepted but computeDiscount validates it. Accepting a discount amount rather than a code is the same bug in a different field.

Example · javascript
// ❌ The client tells you what it costs
app.post('/api/orders', auth, async (req, res) => {
  const { items, total } = req.body;
  await charge(req.user.id, total);              // {"total": 0.01}
  await db.orders.create({ userId: req.user.id, items, total });
  res.status(201).json({ ok: true });
});

// ✅ The client tells you WHAT. The server decides HOW MUCH.
const orderSchema = z.object({
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().min(1).max(100),
  })).min(1).max(50),
  couponCode: z.string().max(32).optional(),
  // NOTE: no price, no total, no discount, no currency. Not accepted.
}).strict();

app.post('/api/orders', auth, validate(orderSchema), async (req, res) => {
  const products = await db.products.findMany(req.body.items.map((i) => i.productId));
  if (products.length !== req.body.items.length) {
    return res.status(400).json({ error: 'unknown_product' });
  }

  // Every number comes from OUR database.
  let subtotal = 0;
  for (const line of req.body.items) {
    const product = products.find((p) => p.id === line.productId);
    if (!product.active) return res.status(409).json({ error: 'product_unavailable' });
    if (product.stock < line.quantity) return res.status(409).json({ error: 'insufficient_stock' });
    subtotal += product.priceCents * line.quantity;
  }

  // Coupons are validated, not applied as sent.
  const discount = req.body.couponCode
    ? await computeDiscount(req.body.couponCode, req.user.id, subtotal)
    : 0;

  const total = subtotal - discount + computeTax(subtotal - discount, req.user);

  await charge(req.user.id, total);
  res.status(201).json({ orderId: await createOrder(req.user.id, req.body.items, total) });
});

// The general rule: the client sends IDENTIFIERS and QUANTITIES.
// Everything derived — prices, totals, discounts, permissions, timestamps —
// is computed server-side from data the client cannot reach.

Which headers you may believe, and when

The `if (!origin) return true` line is the single most common CSRF bypass in real code — it turns a check into a suggestion.

Example · javascript
// A header is trustworthy only if a component you trust set it AND the client
// cannot reach your service without passing through that component.

// ── X-Forwarded-For: only with configured trusted proxies ─────────────
app.set('trust proxy', 1);        // exactly the number of hops you run
// ⚠️ never `true` on a publicly reachable app: the client prepends whatever it
//    likes and gets a fresh rate-limit bucket per request.

// ── Host: never trust for URL generation ──────────────────────────────
const APP_URL = process.env.APP_URL;    // configuration, not a header
if (!APP_URL) throw new Error('APP_URL must be set');
const resetLink = `${APP_URL}/reset?token=${token}`;

// And reject unexpected hosts at the edge as a second layer:
const ALLOWED_HOSTS = new Set(['abc.com', 'www.abc.com']);
app.use((req, res, next) =>
  ALLOWED_HOSTS.has(req.hostname) ? next() : res.status(400).send('Bad Host'));

// ── Origin vs Referer for CSRF ────────────────────────────────────────
function originIsAllowed(req) {
  const origin = req.get('origin');
  // Origin is set by the browser and page scripts cannot forge it.
  if (origin) return ALLOWED_ORIGINS.has(origin);

  // ❌ if (!origin) return true;   ← the bypass: just omit the header
  // Referer is stripped by privacy settings, proxies and Referrer-Policy,
  // so it is a weak fallback and must never be the only check.
  return false;                     // no Origin on a mutating request → refuse
}

// ── Identity headers: strip, then set ─────────────────────────────────
app.use((req, res, next) => {
  for (const name of Object.keys(req.headers)) {
    if (/^x-(internal|user|tenant|role)-?/i.test(name)) delete req.headers[name];
  }
  next();
});
// ...and downstream services verify a SIGNATURE rather than reading a header,
// so a missed strip is not a bypass.

// ── Content-Type: never trust it for a security decision ──────────────
// An upload claiming image/png can be anything. Sniff the magic bytes.

Third-party responses are input too

redirect: 'error' plus an explicit size bound turns a compromised vendor from an SSRF pivot into a failed request with a log line.

Example · javascript
// OWASP API10. A vendor being compromised, or simply changing their schema,
// should not become a vulnerability in your service.
import { z } from 'zod';

const partnerSchema = z.object({
  id: z.string().max(64),
  displayName: z.string().max(200),
  status: z.enum(['active', 'suspended', 'closed']),
  balanceCents: z.number().int().min(0).max(1_000_000_000),
  metadata: z.record(z.string().max(500)).optional(),
}).strict();

export async function fetchPartnerAccount(accountId) {
  const res = await fetch(`https://partner.example.com/accounts/${accountId}`, {
    headers: { Authorization: `Bearer ${PARTNER_TOKEN}` },
    signal: AbortSignal.timeout(5000),           // never hang on someone else
    redirect: 'error',                           // a 302 could point anywhere
  });
  if (!res.ok) throw new UpstreamError(res.status);

  // Bound the size BEFORE parsing — a compromised partner can send 10GB.
  const length = Number(res.headers.get('content-length') ?? 0);
  if (length > 1_000_000) throw new UpstreamError('response_too_large');

  const raw = await res.json();

  // Validate as strictly as you would validate a browser request.
  const parsed = partnerSchema.safeParse(raw);
  if (!parsed.success) {
    logger.error({ issues: parsed.error.issues }, 'partner response failed validation');
    throw new UpstreamError('invalid_upstream_response');
  }

  return parsed.data;
}

// And downstream, treat the values as untrusted content:
//   ❌ element.innerHTML = account.displayName        → stored XSS via a partner
//   ✅ element.textContent = account.displayName
//   ❌ db.raw(`... WHERE ref = '${account.id}'`)      → injection via a partner
//   ✅ db('x').where({ ref: account.id })

// redirect: 'error' is the subtle one — following a redirect from a partner
// turns their compromise into an SSRF in your network.

Discussion

  • Be the first to comment on this lesson.