Broken Access Control
Broken access control is the #1 OWASP risk. Authorised testing methodologies focus on three patterns: vertical privilege escalation, horizontal access across tenants, and missing function-level checks. Defensive engineering means a centralised policy layer, server-side enforcement, and regression tests.
Patterns + tests + RoE-first methodology
EXAMPLE
// SCENARIO — defensive engineering AND structured testing methodology for an app you're
// AUTHORISED to test. Focus on detection, prevention, and reporting.
// ─── 1) Three access-control bug families ────────────────────
//
// (A) IDOR — Insecure Direct Object Reference
// Endpoint accepts a record id; doesn't verify ownership.
// /invoices/42 → returns ANYONE's invoice 42 if logged in
//
// (B) Privilege escalation (vertical)
// User role bypasses an admin check.
// POST /admin/users/disable → succeeds for non-admins
//
// (C) Function-level missing checks
// UI hides a feature for non-admins but the endpoint accepts requests from anyone.
// /api/grant-role → no auth check on the endpoint itself
// ─── 2) Defensive layer — centralised policy ────────────────
function can(user, action, resource) {
if (user.role === 'admin') return true;
switch (action) {
case 'invoice:read':
case 'invoice:cancel':
return resource.userId === user.id;
case 'invoice:refund':
return user.role === 'finance' && resource.amountCents < 10_000_00;
case 'user:disable':
return user.role === 'admin';
default:
return false;
}
}
async function load(req, res, next) {
req.invoice = await db.invoice.findUnique({ where: { id: Number(req.params.id) } });
if (!req.invoice) return res.sendStatus(404);
next();
}
function allow(action) {
return (req, res, next) => {
if (!can(req.user, action, req.invoice)) return res.sendStatus(404); // 404, not 403
next();
};
}
app.get ('/invoices/:id', requireLogin, load, allow('invoice:read'), handler);
app.post ('/invoices/:id/refund', requireLogin, load, allow('invoice:refund'), handler);
app.post ('/admin/users/disable', requireLogin, allow('user:disable'), handler);
// ─── 3) Authorised testing methodology ──────────────────────
//
// Pre-engagement:
// • Written RoE: in-scope URLs, accounts, time windows, exclusions
// • Test accounts at each role level (admin, user, guest, tenant A, tenant B)
// • Backups taken (in case of accidental destructive action)
// • Stop conditions documented
//
// Walk through the app as each role; record every endpoint you touch.
// For each endpoint, ask:
// 1. What's the URL + method + parameters?
// 2. Which roles SHOULD be able to call it?
// 3. Does my UI even show it for my role?
// 4. Can I call it as a LOWER-privilege role and succeed?
// 5. Can I substitute another user's id and succeed?
// ─── 4) IDOR — testing pattern ──────────────────────────────
// As user Alice (id=1), enumerate your own resources, record IDs.
// Switch to user Bob (id=2). For each ID Alice owned:
// curl -H "Cookie: $BOB_SESSION" /invoices/$ID
// Expect 404. If Bob gets 200 with Alice's data: IDOR bug.
// Test patterns to try (only against accounts you control):
// • Direct path: /invoices/1 → /invoices/2
// • Query string: /invoices?id=1 → /invoices?id=2
// • Body field: POST /invoice with {id: 1} vs {id: 2}
// • Header: X-User-Id: 1 vs 2 (if the app reads it)
// • Cookie: userId=1 vs 2
// • Encoded ids: /invoices/1 → /invoices/MQ== (base64 of '1')
// ─── 5) Privilege escalation — testing pattern ──────────────
// Identify endpoints that admins use:
// • Crawl as admin, log every distinct URL + method
// • Switch to a user account, attempt each endpoint
// • Expect 403 or 404. If 200: vertical escalation.
// Tools:
// • Burp Suite extensions: AuthMatrix (compares responses across roles)
// • Autorize: replays requests as a different user, flags mismatches
// • ZAP: AccessControl scan rule
// ─── 6) Function-level — testing pattern ────────────────────
// The UI may hide a feature; the endpoint may still respond.
// Examples:
// POST /api/users/promote — no UI shown to non-admins, but accepts requests
// PUT /api/settings/critical — only admin UI; endpoint takes any logged-in user
//
// Approach:
// • Read JS bundle for endpoint references
// • Try every method (GET, POST, PUT, DELETE) on every URL
// • Watch for inconsistent response codes
// ─── 7) Mass assignment — escalation via payload ────────────
// User updates own profile via PATCH /api/me { name: 'X' }
// Try: PATCH /api/me { role: 'admin' }
// If the backend trusts the entire body, role gets escalated.
// Defense: explicitly enumerate which fields you accept (allowlist), reject extras.
const UpdateProfileSchema = z.object({
name: z.string().min(1).max(64),
email: z.string().email(),
// NO 'role' field. Other fields ignored.
}).strict();
app.patch('/api/me', requireLogin, (req, res) => {
const parsed = UpdateProfileSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error.issues);
/* ... */
});
// ─── 8) Workflow bypass — testing pattern ──────────────────
// Multi-step processes (cart → review → payment → confirmation).
// Try skipping steps: post directly to /confirmation with payment_id from an old order.
// Or replay a 'paid' webhook from a different account.
//
// Defense: server enforces the full state machine, not the UI.
// ─── 9) Centralised audit logging ──────────────────────────
function audit(action, req, success) {
log.info({
action,
user: req.user?.id,
role: req.user?.role,
path: req.path,
ip: req.ip,
ua: req.get('user-agent'),
success,
}, 'access-control');
}
app.use((req, res, next) => {
res.on('finish', () => {
if (['POST','PUT','PATCH','DELETE'].includes(req.method)) {
audit(`${req.method} ${req.path}`, req, res.statusCode < 400);
}
});
next();
});
// ─── 10) Detection — what to alert on ──────────────────────
// • 404 spike from one IP across many resource IDs → probable IDOR probing
// • 403/404 burst on /admin endpoints from non-admin sessions
// • Same user id appearing in many different sessions → token sharing or theft
// • Sudden role change in audit log without admin actor → escalation success
// • Cross-tenant access attempts (logged 'user X tried to read tenant Y data')
// ─── 11) Regression tests ──────────────────────────────────
import request from 'supertest';
test('cannot read another user invoice', async () => {
const aliceInvoice = await db.invoice.create({ data: { userId: alice.id } });
const res = await request(app)
.get(`/invoices/${aliceInvoice.id}`)
.set('Cookie', sessionFor(bob));
expect(res.status).toBe(404); // not 200, not 403
});
test('non-admin cannot disable users', async () => {
const res = await request(app)
.post('/admin/users/disable')
.set('Cookie', sessionFor(bob))
.send({ userId: alice.id });
expect(res.status).toBe(403);
});
test('mass-assignment refused: cannot escalate role', async () => {
const res = await request(app)
.patch('/api/me')
.set('Cookie', sessionFor(bob))
.send({ role: 'admin' });
expect(res.status).toBe(400);
const updated = await db.user.findUnique({ where: { id: bob.id } });
expect(updated.role).not.toBe('admin');
});
// Run on every PR; access-control regressions get blocked.
// ─── 12) Cross-tenant access in multi-tenant SaaS ──────────
// Every resource should be scoped by tenant id:
// SELECT * FROM invoices WHERE tenant_id = $1 AND id = $2
//
// Test by:
// • Creating tenant A account + tenant B account
// • As A, try to access B's resources by id
// • Expect 404; flag any 200
// ─── 13) Pen-test reporting (RoE-first) ────────────────────
// For each finding:
// 1. Endpoint + method + parameters
// 2. Authentication context used (which test account)
// 3. Expected behaviour vs observed
// 4. Reproduction (curl one-liner)
// 5. Severity:
// • CRITICAL — admin escalation, cross-tenant data access
// • HIGH — IDOR with sensitive data, function-level bypass
// • MEDIUM — IDOR with non-sensitive data, mass assignment that doesn't escalate
// • LOW — info disclosure
// 6. Suggested fix mapped to the codebase
// 7. CWE-285 / CWE-639 / OWASP A01:2021 mapping
//
// Always include time-to-detect from the customer's logs.
// ─── 14) Common bugs / mistakes ──────────────────────────
// • Checking authz only in the controller, not on every related endpoint
// • Returning 403 + 'You don't own this' → reveals existence; use 404 silently
// • Mass assignment of role / permissions fields → strict schema with allowlist
// • Frontend checks only — endpoints must enforce too
// • Implicit trust in 'header X-User-Id' from reverse proxies → spoofable
// • JWT contains 'role' claim that user can re-sign (alg=none) → enforce algorithm allowlist
// • Cross-tenant data leakage via shared queries — ALL queries must include tenant_id
// • Audit log of 'who accessed what' missing → can't investigate breaches
// • Pen-tester exfiltrates real customer data 'for the report' — never; use canaries or synthetic
// • Out-of-scope testing → engagement voided, possible criminal liability
Why it matters
Broken access control needs three checks: per-record ownership (IDOR), role-gated endpoints (vertical privilege), and function-level enforcement at the API (UI hiding is never enough). Centralise authz in a policy layer, return 404 instead of 403, allowlist updatable fields to defeat mass assignment, and lock regressions with cross-user tests. Authorised testing is RoE-first: in-scope only, no real-user data, stop on critical findings.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# OWASP A01. Common bugs: # - Vertical: low-priv user reaches admin endpoint # - Horizontal: user A reads user B # - Mass-assignment: PUT with extra fields (role=admin) # Defence: deny-by-default + explicit policy checks server-side.Try it Yourself »
Discussion
Loading…