iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…