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

IDOR

Insecure Direct Object Reference (IDOR): the app accepts an identifier (URL path, body, query string) and looks up the resource without checking whether the current user is authorised for THAT specific resource. The fix is server-side authorisation on every read and write. Assessment work is RoE-first: test only what your engagement covers, and stop at the first sign you’re reading data you weren’t scoped for.

Server authz + opaque IDs + tests

EXAMPLE
// SCENARIO — an invoicing API. Defensive perspective, with assessment notes.

// ─── VULNERABLE ────────────────────────────────────────────────

app.get('/invoices/:id', requireLogin, async (req, res) => {
    const inv = await db.invoice.findUnique({ where: { id: Number(req.params.id) } });
    if (!inv) return res.sendStatus(404);
    res.json(inv);
    // ❌ no ownership check — any logged-in user can read any invoice.
});

app.post('/invoices/:id/refund', requireLogin, async (req, res) => {
    const inv = await db.invoice.findUnique({ where: { id: Number(req.params.id) } });
    if (!inv) return res.sendStatus(404);
    await refunder.refund(inv);          // ❌ same bug — destructive action on someone else's record
    res.sendStatus(204);
});

// ─── FIX 1 — Authorise on every read AND write ─────────────────

app.get('/invoices/:id', requireLogin, async (req, res) => {
    const inv = await db.invoice.findFirst({
        where: { id: Number(req.params.id), userId: req.user.id },
    });
    if (!inv) return res.sendStatus(404);    // hide existence — don't 403
    res.json(inv);
});

app.post('/invoices/:id/refund', requireLogin, async (req, res) => {
    const { count } = await db.invoice.updateMany({
        where: { id: Number(req.params.id), userId: req.user.id, status: 'paid' },
        data:  { status: 'refunded' },
    });
    if (count === 0) return res.sendStatus(404);
    res.sendStatus(204);
});

// ─── FIX 2 — Central policy layer ──────────────────────────────

function can(user, action, resource) {
    if (user.role === 'admin') return true;
    switch (action) {
        case 'invoice:read':
        case 'invoice:refund':
            return resource.userId === user.id;
        case 'invoice:approve':
            return user.role === 'finance' && resource.amountCents < 10_000_00;
        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) => can(req.user, action, req.invoice) ? next() : res.sendStatus(404);
}

app.get   ('/invoices/:id',          requireLogin, load, allow('invoice:read'),    (req, res) => res.json(req.invoice));
app.post  ('/invoices/:id/refund',   requireLogin, load, allow('invoice:refund'),  doRefund);
app.post  ('/invoices/:id/approve',  requireLogin, load, allow('invoice:approve'), doApprove);

// ─── FIX 3 — Opaque, unguessable identifiers ───────────────────

import { nanoid } from 'nanoid';

await db.invoice.create({
    data: { publicId: nanoid(21), userId, /* … */ },
});

app.get('/invoices/:publicId', requireLogin, async (req, res) => {
    const inv = await db.invoice.findFirst({
        where: { publicId: req.params.publicId, userId: req.user.id },
    });
    if (!inv) return res.sendStatus(404);
    res.json(inv);
});

// Opaque IDs are a deterrent for probing; they are NEVER a substitute for authz checks.

// ─── FIX 4 — Don't trust ANY client-supplied id ────────────────

// Watch for IDOR hidden in less obvious shapes:
//   POST /transfer { fromAccount, toAccount, amount }
//     → fromAccount MUST belong to req.user; verify, don't accept on faith
//   GET /files?owner=alice
//     → ignore the owner param; read from session
//   GET /export?path=/etc/passwd
//     → never accept raw paths from the client
//   Webhook bodies with userId/tenantId fields
//     → derive from the authenticated context, not the body

// ─── REGRESSION TESTS ──────────────────────────────────────────

import request from 'supertest';

test('cannot read another user invoice', async () => {
    const alicesInvoice = await db.invoice.create({ data: { userId: alice.id, /* … */ } });
    const res = await request(app)
        .get(`/invoices/${alicesInvoice.id}`)
        .set('Cookie', sessionFor(bob));
    expect(res.status).toBe(404);                 // not 200, not 403 — don't reveal existence
});

test('cannot refund another user invoice', async () => {
    const inv = await db.invoice.create({ data: { userId: alice.id, status: 'paid' } });
    const res = await request(app)
        .post(`/invoices/${inv.id}/refund`)
        .set('Cookie', sessionFor(bob));
    expect(res.status).toBe(404);
    const fresh = await db.invoice.findUnique({ where: { id: inv.id } });
    expect(fresh.status).toBe('paid');            // not refunded
});

test('admin can read any invoice', async () => {
    const inv = await db.invoice.create({ data: { userId: alice.id, /* … */ } });
    const res = await request(app)
        .get(`/invoices/${inv.id}`)
        .set('Cookie', sessionFor(adminUser));
    expect(res.status).toBe(200);
});

// ─── DETECTION ─────────────────────────────────────────────────

function logAccessAnomaly(req, dbRecord) {
    if (dbRecord && dbRecord.userId !== req.user.id && req.user.role !== 'admin') {
        log.warn({ user: req.user.id, owner: dbRecord.userId, path: req.path }, 'cross-tenant access blocked');
    }
}

// ─── ASSESSMENT NOTES (RoE-FIRST) ──────────────────────────────

// You are doing AUTHORISED testing only. The right mindset:
//   • Stay inside the SCOPED endpoints and TEST ACCOUNTS named in the Rules of Engagement
//   • Use accounts you control. Don't read another real user's data, even read-only
//   • For SaaS, cross-tenant testing needs WRITTEN authorisation from BOTH tenants
//   • Stop and report immediately if you read data you weren't expecting to access
//   • Don't pivot from one bug to another beyond scope; capture findings, return to the scope tree
//   • Log every endpoint touched (path, method, response status). No trace, no usable report
//   • Don't keep client data — wipe local copies after the engagement closes
//   • Coordinate with the customer on rate limits; respect business-hours windows in the RoE

// Reporting checklist
//   1. Endpoint and HTTP method
//   2. Authenticated identity used
//   3. The resource id touched
//   4. The expected vs observed authz decision
//   5. Proof: response status + response excerpt (redacted)
//   6. Severity: data type read/modified, scale (one record vs many), preconditions
//   7. Suggested fix mapped to the codebase (server-side filter, central policy, opaque ID)
//   8. Replication steps an engineer can run from a fresh clone

// ─── CHECKLIST ─────────────────────────────────────────────────
// 1. Every DB lookup includes a user/tenant predicate in the WHERE clause
// 2. Centralised policy/authz layer, not handler-by-handler
// 3. 404 (not 403) when authz fails — don't reveal existence
// 4. Opaque IDs in URLs as a probing deterrent (not a control)
// 5. Regression tests for read + write across user roles
// 6. Audit logs for cross-tenant access attempts
// 7. RoE-first assessment: scope, accounts, time window, escalation path

Why it matters

IDOR is the easiest critical-severity bug to introduce and the easiest to test for: every request that touches a record must check that the current user is authorised for that record. Centralise the check in a policy layer, return 404 on failure, and lock the behavior with cross-user regression tests — on the assessment side, keep every action inside the scope your RoE actually grants you.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
# IDOR = Insecure Direct Object Reference.
# Test pattern (on YOUR / authorised app):
#   - Log in as user A, note IDs in URLs / JSON bodies
#   - Try those IDs as user B → expect 403/404
# Fix: server-side authZ on every protected resource.
Try it Yourself »

Discussion

Loading…