Testing web apps in the lab: the proxy, permissions and injection
Lesson 2 of 3
2Testing web apps in the lab: the proxy, permissions and injection
The big pictureThe browser shows you the buttons the app lets you press. But the server does not see buttons; it sees requests, and any request can be changed. Web testing means standing between the browser and the server, seeing every request, and asking: "what if I change this number? What if I send this request with another account?". That is how most of the flaws that leaked millions of records were found.
What the picture shows: The proxy catches every request before it reaches the server, so you read it, change it, resend it and compare the reply. Almost every web flaw is found with this loop.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
Picture a restaurant where you order on a slip the waiter carries to the kitchen. The front (the browser) shows only your dishes. But what if you stop the waiter and write on the slip "and table 7's order too" (IDOR)? Or "and give me the storeroom key" (injection)? A good kitchen checks every slip: is this your table? Is this a dish on the menu? The proxy is stopping the waiter and reading the slip.
Tools: Burp Suite Community or ZAP as an intercepting proxy with its certificate installed in the lab browser. The steps: map the app (every page, API endpoint and parameter), then authentication (lockout, password reset, MFA), then sessions (the Secure, HttpOnly and SameSite flags, expiry), then permissions with two accounts and two roles: replay A's request with B's session (horizontal IDOR) and an admin request with a user session (vertical). Injection (SQL, commands, templates): the cure is the parameterised query that keeps data apart from the command. SSRF: the server fetches a URL you control, so you make it ask for 169.254.169.254 (cloud metadata) or internal services; the cure is an allow-list and IMDSv2. The OWASP Top 10:2025 starts with A01 Broken Access Control. Practice: PortSwigger Web Security Academy and OWASP Juice Shop.
Before You Start
🌐Web application security
You know the OWASP Top 10:2025.
🎯The testing method
Permission, scope and the report.
The Core Idea
1The proxy and the app map
Install the proxy and its certificate in a browser kept for the lab only. Browse the app as an ordinary user, and every click records a request. Then read the map: every API endpoint and its parameters, which parameters are IDs (id, user, order), and which end up in a query, a URL or a file. That is your test list.
2Permissions: always two accounts
The most serious and widespread web flaws need no trick at all: the server asks "who are you?" but forgets to ask "is this yours?". The test is simple and orderly: create two accounts A and B (and an admin role if there is one), capture A's requests, and replay them with B's session. If B sees A's data, that is an IDOR. The fix is always on the server: check ownership for every object, and deny by default.
This is the two-account test in miniature: bob asks for alice's order. The broken version shows it; the fixed one returns 403.
orders = {501: {"owner": "alice", "total": 420}, 502: {"owner": "bob", "total": 99}}
roles = {"alice": "user", "bob": "user", "sara": "admin"}
def get_order_broken(session_user, order_id):
return orders.get(order_id) # authenticated, but never asks "is it yours?"
def get_order_fixed(session_user, order_id):
order = orders.get(order_id)
if order is None:
return 404
if order["owner"] != session_user and roles.get(session_user) != "admin":
return 403 # deny by default
return order
for fn in (get_order_broken, get_order_fixed):
print(fn.__name__)
for user, oid in (("alice", 501), ("bob", 501), ("sara", 501), ("bob", 999)):
print(f" {user:<5} asks for order {oid}: {fn(user, oid)}")3Injection: data is not commands
Injection happens when a user's text is glued inside a command (an SQL query, a system command, a template), so it becomes part of the command. The cure is not "cleaning" input by stripping quotes, since there is always a way around, but the parameterised query: the command is sent alone and the data separately, so it can never turn into a command whatever the user types.
In the broken version the user's text became part of the condition and everyone showed up. In the fixed one it stayed text searched for literally, matching nothing.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE staff (name TEXT, salary INT, dept TEXT)")
db.executemany("INSERT INTO staff VALUES (?, ?, ?)",
[("Huda", 18000, "HR"), ("Omar", 22000, "IT"), ("CEO", 95000, "Board")])
def search_broken(dept):
return db.execute(f"SELECT name, salary FROM staff WHERE dept = '{dept}'").fetchall()
def search_fixed(dept):
return db.execute("SELECT name, salary FROM staff WHERE dept = ?", (dept,)).fetchall()
typed = "HR' OR '1'='1" # the classic lab test string
print("broken, normal :", search_broken("HR"))
print("broken, crafted:", search_broken(typed))
print("fixed, crafted:", search_fixed(typed))4SSRF and business logic
SSRF: a feature like "enter your picture URL and the server will fetch it". If the server does not limit the destination, it requests whatever you like from inside the network: internal admin panels, or the cloud metadata address 169.254.169.254 which may hand out temporary keys. The fix: an allow-list of domains, blocking internal addresses after DNS resolution, and IMDSv2 on AWS. Business logic flaws are ones no tool finds: a coupon used twice, a negative quantity in the basket, skipping the payment step. You find them by asking "what if I did the steps in a different order?".
Checking after name resolution matters: an allowed domain may briefly point to an internal address.
import ipaddress
from urllib.parse import urlparse
ALLOWED_HOSTS = {"images.partner-cdn.com"}
def safe_to_fetch(url, resolved_ip):
host = urlparse(url).hostname or ""
if urlparse(url).scheme != "https" or host not in ALLOWED_HOSTS:
return "refused: host not on the allow-list"
ip = ipaddress.ip_address(resolved_ip)
if ip.is_private or ip.is_loopback or ip.is_link_local:
return "refused: resolves to an internal address"
return "ok"
print(safe_to_fetch("https://images.partner-cdn.com/a.png", "151.101.2.10"))
print(safe_to_fetch("http://169.254.169.254/latest/meta-data/", "169.254.169.254"))
print(safe_to_fetch("https://images.partner-cdn.com/a.png", "10.0.3.7")) # DNS points insideWorked example
The task: in the Juice Shop lab you want an orderly two-hour plan for testing the app's permissions. Write it.
The answer: (1) Run Juice Shop locally in Docker and route the lab browser through Burp or ZAP. (2) Create two accounts, A and B. (3) As A, browse everything user-specific: the basket, orders, addresses and reviews, and save the requests. (4) In Repeater, replay every request carrying an ID with B's session: does B see A's basket? (5) Try the admin endpoints from the app map with an ordinary user's session. (6) For each success: the evidence, the impact, the fix (an ownership check on the server), and an automatic test that fails if it returns. (7) Write a one-page summary of your findings.
Myth vs Reality
| Myth | Reality |
|---|---|
| HTTPS protects the app from attack. | HTTPS protects only the road. The altered request arrives encrypted and intact at a server that never checks it. |
| Cleaning input is enough to stop injection. | There is always a way around. The parameterised query stops it at the root. |
| A hidden button cannot be reached. | The server sees requests, not buttons, and anyone can send the request. |
Where you meet it at work
🧑💻Code review
A developer who has tested permissions writes code that checks ownership by habit.
🐞Bug bounties
Most big bounties in recent years were permission flaws.
🏦Financial apps
Business logic testing stops double transfers and negative amounts.
Test your understanding
What is first in the OWASP Top 10:2025?
Broken access control stayed first as the most widespread and damaging.
How do you test for IDOR?
If B sees A's data, the server does not check ownership.
What is the right fix for SQL injection?
Keeping data apart from the command stops it becoming a command.
Why does a tester request 169.254.169.254 when testing SSRF in the cloud?
The defence: an allow-list, blocking internal addresses, and IMDSv2.
Where must the permission check live?
Anything in the browser can be changed or bypassed.
Quick review cards
Check Your Understanding
Why do you need two accounts to test permissions?
Because the question "can B see what belongs to A?" can only be tested with two identities.
What is wrong with fixing injection with a list of banned characters?
Lists get bypassed by encodings and other tricks; the root fix is keeping data apart from the command.
How do you stop a fixed flaw from returning?
With an automatic test in CI that fails if the old behaviour comes back.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Tools2
Done with this lesson?
Sign in to save your progress.