// ───────────────────────────────────────────────────────────────────────────── // A01:2025 BROKEN ACCESS CONTROL (2021: A01 same; absorbs A10 SSRF) // ───────────────────────────────────────────────────────────────────────────── // WHAT : Users act outside their intended permissions — read/write/delete // data or call functions they were never authorized for. // HAPPENS : IDOR / BOLA (change /orders/123 -> /orders/124); missing // function-level auth (admin route, no role check); path traversal // (../../etc/passwd); CORS misconfig (reflected Origin + credentials); // SSRF (fetch(userUrl) -> 169.254.169.254 metadata, internal hosts); // privilege escalation via tampered JWT/cookie claims; mass assignment. // SPOT : any handler that takes an id from the URL/body and loads the record // without comparing owner to session user; routes with auth but no // authZ; string path joins; fetch/axios with user-controlled URL; // Access-Control-Allow-Origin echoing the request Origin. // FIX : deny-by-default authZ middleware; ownership check on every object // access (query WHERE owner_id = session.user); RBAC/ABAC centrally; // path.resolve + startsWith(root); SSRF: host allowlist + block // private/link-local ranges + resolve DNS before connect + no // redirects; CORS: explicit origin allowlist, never '*' with creds. // SAY : CWE-284 Improper Access Control, CWE-639 IDOR, CWE-22 Path // Traversal, CWE-918 SSRF, CWE-862 Missing Authorization. Pitch: // "AuthN says who you are; authZ says what you may touch. Enforce // authZ server-side, per object, deny by default." // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); const path = require('node:path'); const net = require('node:net'); // ── 1. IDOR / BOLA (Broken Object Level Authorization) // VULNERABLE: // app.get('/orders/:id', async (req, res) => { // const order = await db.orders.findById(req.params.id); // no owner check // res.json(order); // }); // FIXED: the owner is part of the lookup, not a later if-statement someone forgets. const orders = new Map([ [123, { id: 123, ownerId: 'u1', total: 10 }], [124, { id: 124, ownerId: 'u2', total: 99 }], ]); function getOrderForUser(orderId, sessionUserId) { const order = orders.get(orderId); if (!order || order.ownerId !== sessionUserId) return null; // 404, not 403: don't leak existence return order; } assert.equal(getOrderForUser(123, 'u1').total, 10); assert.equal(getOrderForUser(124, 'u1'), null); assert.equal(getOrderForUser(999, 'u1'), null); // ── 2. Missing function-level authorization (deny by default) // VULNERABLE: // app.delete('/admin/users/:id', requireLogin, deleteUser); // logged in != admin // FIXED: every route declares the role it needs; unknown route => denied. const routePolicy = { 'GET /orders': ['user', 'admin'], 'DELETE /admin/users': ['admin'], }; function authorize(routeKey, user) { const allowed = routePolicy[routeKey]; if (!allowed || !user || !Array.isArray(user.roles)) return false; // deny by default return user.roles.some((r) => allowed.includes(r)); } assert.equal(authorize('DELETE /admin/users', { roles: ['user'] }), false); assert.equal(authorize('DELETE /admin/users', { roles: ['admin'] }), true); assert.equal(authorize('GET /unknown', { roles: ['admin'] }), false); assert.equal(authorize('GET /orders', null), false); // ── 3. Path traversal // VULNERABLE: // res.sendFile('/srv/uploads/' + req.query.name); // name = '../../etc/passwd' // FIXED: resolve, then prove the result is still inside the root. function safeJoin(root, userPath) { const base = path.resolve(root); const full = path.resolve(base, userPath); if (full !== base && !full.startsWith(base + path.sep)) return null; return full; } assert.equal(safeJoin('/srv/uploads', 'a.txt'), '/srv/uploads/a.txt'); assert.equal(safeJoin('/srv/uploads', '../../etc/passwd'), null); assert.equal(safeJoin('/srv/uploads', '/etc/passwd'), null); assert.equal(safeJoin('/srv/uploads', 'sub/../../x'), null); assert.equal(safeJoin('/srv/uploads', 'sub/../ok.txt'), '/srv/uploads/ok.txt'); // ── 4. CORS misconfiguration // VULNERABLE: // res.set('Access-Control-Allow-Origin', req.headers.origin); // reflects anything // res.set('Access-Control-Allow-Credentials', 'true'); // + cookies = account takeover // FIXED: exact-match allowlist; null origin and subdomain-suffix tricks rejected. const allowedOrigins = new Set(['https://app.example.com']); function corsHeaders(origin) { if (!allowedOrigins.has(origin)) return {}; return { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Credentials': 'true', 'Vary': 'Origin', }; } assert.deepEqual(corsHeaders('https://evil.com'), {}); assert.deepEqual(corsHeaders('https://app.example.com.evil.com'), {}); assert.deepEqual(corsHeaders('null'), {}); assert.equal(corsHeaders('https://app.example.com')['Access-Control-Allow-Origin'], 'https://app.example.com'); // ── 5. SSRF (Server-Side Request Forgery) — moved into A01 in 2025 // VULNERABLE: // app.get('/preview', async (req, res) => { // const r = await fetch(req.query.url); // http://169.254.169.254/latest/meta-data/iam/ // res.send(await r.text()); // or http://localhost:6379/, file:///etc/passwd // }); // FIXED: scheme + host allowlist, block private/link-local/loopback, no redirects. // (In production also resolve DNS and re-check the IP to defeat DNS rebinding.) function isPrivateIp(ip) { if (net.isIPv4(ip)) { const [a, b] = ip.split('.').map(Number); return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); } if (net.isIPv6(ip)) { const l = ip.toLowerCase(); return l === '::1' || l === '::' || l.startsWith('fc') || l.startsWith('fd') || l.startsWith('fe80') || l.startsWith('::ffff:'); } return false; } const allowedHosts = new Set(['api.partner.com', 'cdn.example.com']); function isAllowedUrl(input) { let u; try { u = new URL(input); } catch { return false; } if (u.protocol !== 'https:') return false; if (u.username || u.password) return false; const host = u.hostname.replace(/^\[|\]$/g, ''); if (host === 'localhost' || host.endsWith('.localhost')) return false; if (net.isIP(host) && isPrivateIp(host)) return false; return allowedHosts.has(host); } assert.equal(isAllowedUrl('https://api.partner.com/v1/x'), true); assert.equal(isAllowedUrl('http://169.254.169.254/latest/meta-data/'), false); assert.equal(isAllowedUrl('https://169.254.169.254/'), false); assert.equal(isAllowedUrl('https://10.0.0.5/admin'), false); assert.equal(isAllowedUrl('https://localhost:6379/'), false); assert.equal(isAllowedUrl('https://[::1]/'), false); assert.equal(isAllowedUrl('file:///etc/passwd'), false); assert.equal(isAllowedUrl('https://api.partner.com@evil.com/'), false); assert.equal(isAllowedUrl('https://evil.com/'), false); // fetch(url, { redirect: 'error' }) — never follow redirects from allowlisted hosts to internal ones. console.log('ok: owasp/a01-broken-access-control.js');