Skip to content

Lesson 1 of 9 · 20m

The Hostile Client: Trust Boundaries in Web Apps

Hidden fields, cookies, and request bodies are all forgeable. Replay a request with curl and learn what that means in code.

Your checkout flow works perfectly in QA, and then someone buys a $900 laptop for $3. The price was never on your server — it was in a hidden form field, and hidden means "invisible in the rendered page," not "secret." Every byte of an HTTP request is just text the client chose to send, and any user can choose differently.

This is the mental model under every bug in this course: the trust boundary is the network edge of your server, and everything past it is attacker input. Cookies, headers, JSON bodies, URL segments, file uploads, the Referer header your analytics quietly logs — all forgeable. Client-side validation is a UX feature, not a control.

Replaying reality

You don't need Burp Suite to start (though Burp Community Edition is the standard interception proxy — current releases are the 2025.x line). curl is enough. Watch a form submit in DevTools' Network tab, right-click → Copy as cURL, and replay it:

curl -i 'https://shop.example.test/api/orders' \
  -H 'Cookie: session=…' \
  -H 'Content-Type: application/json' \
  --data '{"product_id": 42, "price": 3.00, "qty": 1}'

If the server accepts price from the body, the bug is already written. The fix is boring and total: the server looks up the price from product_id and ignores the client's number entirely.

// Vulnerable: trusts two attacker-controlled values
const price = req.body.price;

// Fixed: everything sensitive is derived server-side
const product = await db.query(
  'SELECT price FROM products WHERE id = $1',
  [req.body.product_id],
);
if (!product.rowCount) return res.status(404).end();
const total = product.rows[0].price * qty;

The same shape of bug shows up as IDOR (GET /api/invoices/1042 — try 1041) and mass assignment (PATCH /me accepting {"is_admin": true} because the ORM happily binds every key in the body). Both are fixed the same way: never bind or authorize off a client-supplied value without a server-side check. For object access, that means an explicit ownership/permission lookup per object ID — WHERE id = $1 AND org_id = $2 — not "the UI only links to your own invoices."

Try this

Pick any form on an app you own. Submit it once normally, capture the request in DevTools, and replay it with one field changed to something the UI "can't" send — a different user id, a negative quantity, a role string. If the server accepts it, you just found the class of bug that fills bug-bounty leaderboards.

Pitfalls

  • Hidden inputs and disabled fields are fully editable in DevTools or a proxy.
  • "Only our app calls this API" — your API is on the public internet. It gets called by everything.
  • JWT claims are client input. {"role":"admin"} inside a token you didn't sign correctly is worth exactly nothing.
  • Validation answers "is this well-formed," not "is this allowed." You need both, on the server.

Every later module is this lesson wearing a costume: something the client sent was trusted on the wrong side of the boundary.