// ───────────────────────────────────────────────────────────────────────────── // A06:2025 INSECURE DESIGN (2021: A04 same name) // ───────────────────────────────────────────────────────────────────────────── // WHAT : The design itself permits abuse — no amount of correct // implementation fixes a missing control. (Implementation bug = // "you coded it wrong"; design flaw = "you never planned for it".) // HAPPENS : no rate limit on password reset / OTP / login (design didn't call // for one); business logic flaws: negative quantity -> refund, // coupon reused N times, TOCTOU race on balance (double spend), // skipping a step in a multi-step flow; trusting client-side // validation; no abuse cases considered ("what if a bot does this // 10k times?"); unlimited free tier; security questions as 2FA. // SPOT : requirements with no "abuse" / "misuse" cases; no threat model // for new features; invariants enforced only in the UI; read-then- // write without a lock/transaction; monetary ops without idempotency // keys; features that are "too helpful" (account enumeration). // FIX : threat modeling (STRIDE: Spoofing, Tampering, Repudiation, Info // disclosure, DoS, Elevation of privilege); abuse cases in stories; // secure design patterns & reference architectures; invariants // enforced server-side in one place; rate limits / quotas / token // bucket; trust boundaries drawn explicitly; least privilege; // defense in depth; secure defaults; segregation of tenants. // SAY : CWE-840 Business Logic Errors, CWE-799 Improper Control of // Interaction Frequency, CWE-367 TOCTOU, CWE-20 Improper Input // Validation (as design). Shift-left, threat model, attack tree, // abuse case, trust boundary, invariant. Pitch: "Ask 'how would I // abuse this?' before writing it; encode the answer as invariants // the server enforces." // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── 1. Business logic flaw: order/coupon flow // VULNERABLE: // function checkout(cart, coupon) { // let total = cart.reduce((s, i) => s + i.price * i.qty, 0); // qty = -5 => negative total => refund // if (coupon) total -= coupon.amount; // coupon reused forever, total < 0 // charge(user, total); // } // FIXED: invariants enforced at the boundary; coupon is single-use via atomic state. const products = new Map([['sku1', { price: 2500 }], ['sku2', { price: 999 }]]); // server prices, in cents const coupons = new Map([['SAVE10', { amount: 1000, usedBy: new Set(), maxUses: 1 }]]); function checkout(userId, cart, couponCode) { if (!Array.isArray(cart) || cart.length === 0 || cart.length > 50) throw new Error('invalid cart'); let total = 0; for (const { sku, qty } of cart) { if (!Number.isInteger(qty) || qty < 1 || qty > 99) throw new Error('invalid qty'); // invariant: 1..99 const p = products.get(sku); if (!p) throw new Error('unknown sku'); // server-side price total += p.price * qty; } if (couponCode !== undefined) { const c = coupons.get(couponCode); if (!c) throw new Error('invalid coupon'); if (c.usedBy.has(userId) || c.usedBy.size >= c.maxUses) throw new Error('coupon exhausted'); c.usedBy.add(userId); // consume atomically with order total = Math.max(0, total - c.amount); // invariant: total >= 0 } return { userId, total }; } assert.equal(checkout('u1', [{ sku: 'sku1', qty: 2 }]).total, 5000); assert.throws(() => checkout('u1', [{ sku: 'sku1', qty: -5 }]), /invalid qty/); assert.throws(() => checkout('u1', [{ sku: 'sku1', qty: 1.5 }]), /invalid qty/); assert.throws(() => checkout('u1', [{ sku: 'sku1', qty: 1, price: 1 }, { sku: 'nope', qty: 1 }]), /unknown sku/); assert.equal(checkout('u2', [{ sku: 'sku2', qty: 1 }], 'SAVE10').total, 0); // 999 - 1000 clamps to 0 assert.throws(() => checkout('u2', [{ sku: 'sku2', qty: 1 }], 'SAVE10'), /coupon exhausted/); assert.throws(() => checkout('u3', [{ sku: 'sku2', qty: 1 }], 'SAVE10'), /coupon exhausted/); // maxUses hit // ── 2. Race condition / TOCTOU on balance (double spend) // VULNERABLE: // const bal = await db.get(user); // two concurrent requests both read 100 // if (bal >= amount) await db.set(user, bal - amount); // both pass, both write 0 => spent 200 // FIXED: single atomic conditional update (UPDATE ... SET bal = bal - $1 WHERE id = $2 AND bal >= $1), // or a per-key lock/queue. Simulated here with a serialized critical section. const balances = new Map([['u1', 100]]); const locks = new Map(); async function withLock(key, fn) { const prev = locks.get(key) || Promise.resolve(); let release; const next = new Promise((r) => { release = r; }); locks.set(key, prev.then(() => next)); await prev; try { return await fn(); } finally { release(); } } async function withdraw(user, amount) { if (!Number.isInteger(amount) || amount <= 0) return false; return withLock(user, async () => { const bal = balances.get(user); await Promise.resolve(); // simulate async gap where the race would happen if (bal < amount) return false; balances.set(user, bal - amount); return true; }); } // Also: idempotency keys on money-moving endpoints so retries don't double-charge. // ── 3. Rate limiting: token bucket for OTP / password-reset attempts // VULNERABLE: POST /verify-otp accepts unlimited guesses; 6 digits = 1e6 tries, trivial for a bot. // FIXED: class TokenBucket { constructor(capacity, refillPerMs) { this.capacity = capacity; this.refill = refillPerMs; this.buckets = new Map(); } take(key, now = Date.now()) { const b = this.buckets.get(key) || { tokens: this.capacity, at: now }; b.tokens = Math.min(this.capacity, b.tokens + (now - b.at) * this.refill); b.at = now; if (b.tokens < 1) { this.buckets.set(key, b); return false; } b.tokens -= 1; this.buckets.set(key, b); return true; } } const otpLimiter = new TokenBucket(5, 1 / 60_000); // 5 attempts, +1 per minute let t = 1_000_000; for (let i = 0; i < 5; i++) assert.equal(otpLimiter.take('u1', t), true); assert.equal(otpLimiter.take('u1', t), false); // 6th attempt blocked assert.equal(otpLimiter.take('u2', t), true); // other key unaffected assert.equal(otpLimiter.take('u1', t + 59_000), false); // not yet refilled assert.equal(otpLimiter.take('u1', t + 60_000), true); // one token back after a minute // Also: invalidate OTP after N failures, bind OTP to session, expire in 5-10 minutes. // ── 4. STRIDE in one table (say these names) const STRIDE = Object.freeze({ Spoofing: 'authentication', Tampering: 'integrity', Repudiation: 'non-repudiation / audit logging', InformationDisclosure: 'confidentiality', DenialOfService: 'availability', ElevationOfPrivilege: 'authorization', }); assert.equal(Object.keys(STRIDE).length, 6); (async () => { const results = await Promise.all([withdraw('u1', 100), withdraw('u1', 100), withdraw('u1', 100)]); assert.deepEqual(results.filter(Boolean).length, 1); // exactly one wins assert.equal(balances.get('u1'), 0); // never negative assert.equal(await withdraw('u1', -50), false); console.log('ok: owasp/a06-insecure-design.js'); })();