Skip to content

IDOR Defense — Server-Side Authorization Checks

Defends against

Insecure Direct Object Reference (IDOR) — reading another user's confidential documents on Documents (/documents?id=).

Prerequisites

  • Local INSECURE-threat-intel-app stack (Docker setup).
  • You can read Bob's confidential document (id=1011) while logged in as analyst, triggering the "Found IDOR" flag.
  • Editor open on exercise-files/web-attacks-101/INSECURE-threat-intel-app.

Principle

IDOR is broken access control: the server resolves an object from a user-supplied reference (id=1011) but never checks whether the authenticated caller is allowed to see that object. Authentication ("who are you?") succeeded; authorization ("may you access this?") is missing.

The fix is a server-side rule enforced on every object access:

The session user may read object X only if they own X (or hold a role that grants access).

Hiding the link in the UI, using big random IDs, or returning 404 are not authorization. The check must run on the server, on every request, close to the data.


Step 1 — Locate the vulnerable lookup

getDocumentById fetches by id alone — no notion of who is asking:

``11:20:exercise-files/web-attacks-101/INSECURE-threat-intel-app/src/lib/lab/idor.ts /** INTENTIONALLY VULNERABLE — no authorization check (IDOR). */ export async function getDocumentById(id: number): Promise<LabDocument | null> { // VULNERABLE: looks up the row by id ONLY — there is no check that the // session user is allowed to read this document. const result = await pool.query(SELECT id, owner_username, title, body, confidential FROM dashboard.lab_documents WHERE id = $1`, [id] ); if (result.rowCount === 0) return null; return result.rows[0] as LabDocument; }

The page then renders whatever comes back, including `owner_username: bob`:

```25:28:exercise-files/web-attacks-101/INSECURE-threat-intel-app/src/pages/documents.astro
const idParam = Astro.url.searchParams.get('id') ?? '';
const docId = Number.parseInt(idParam, 10);
// VULNERABLE: fetches by id alone — `username` is known here but never used to authorize access.
const doc = idParam.trim() && Number.isFinite(docId) ? await getDocumentById(docId) : null;
const labFlag = detectIdor(doc, username) ? LAB_FLAGS.idor : null;

The username is available (from the verified session via middleware) — it just is not used to gate access.

Step 2 — Enforce ownership in the data layer

Pass the caller's identity into the lookup and let the database filter by owner. This is the most robust place because it cannot be bypassed by a code path that forgets to check:

/** Returns the document only if it belongs to the requesting user. */
export async function getDocumentForUser(
  id: number,
  requester: string
): Promise<LabDocument | null> {
  const result = await pool.query(
    `SELECT id, owner_username, title, body, confidential
     FROM dashboard.lab_documents
     WHERE id = $1 AND owner_username = $2`,
    [id, requester]
  );
  if (result.rowCount === 0) return null;
  return result.rows[0] as LabDocument;
}

Now id=1011 as analyst returns zero rows → the page shows "No document found", identical to a non-existent ID. The attacker cannot distinguish "exists but forbidden" from "does not exist", which also avoids leaking which IDs are real.

Step 3 — Support shared/role-based access cleanly

Real apps have documents shared with teams or readable by admins. Keep the rule explicit rather than sprinkling if checks:

function canRead(doc: LabDocument, user: { username: string; role: string }): boolean {
  if (doc.owner_username === user.username) return true;   // owner
  if (user.role === "admin") return true;                   // role grant
  // if (isSharedWith(doc.id, user.username)) return true;  // explicit share
  return false;
}

export async function getDocumentForUser(
  id: number,
  user: { username: string; role: string }
): Promise<LabDocument | null> {
  const result = await pool.query(
    `SELECT id, owner_username, title, body, confidential
     FROM dashboard.lab_documents WHERE id = $1`,
    [id]
  );
  const doc = (result.rows[0] as LabDocument) ?? null;
  if (!doc || !canRead(doc, user)) return null; // 404-style for both cases
  return doc;
}

This centralizes the policy in one testable function. Prefer the WHERE-clause version (Step 2) when access is a simple ownership match; use the policy-function version when access rules are richer.

Step 4 — Update the page

In src/pages/documents.astro, pass the session user into the lookup:

import { getDocumentForUser } from '../lib/lab/idor';
// ...
const username = Astro.locals.username; // set by middleware after session verify
const doc = (username && Number.isFinite(docId))
  ? await getDocumentForUser(docId, username)
  : null;

Drop the ?? 'analyst' fallback — an unauthenticated request should never default to a real identity. Middleware already guarantees locals.username is set for protected routes, so an absent value means something is wrong and should fail closed.

Step 5 — Verify the fix

npm run docker:down && npm run docker:up
Test (as analyst) Before After (fixed)
/documents?id=10011005 (own) Your notes shown Your notes shown (unchanged)
/documents?id=1011 (Bob) SENSITIVE budget/VPN note shown "No document found"

The "Found IDOR" flag (detectIdor) fires only when a confidential doc owned by someone else is returned. With ownership enforced, the cross-user read returns null, so the flag stops firing.

Confirm in Burp Repeater: replay GET /documents?id=1011 and verify the confidential body is gone from the response.


Defense in depth

  • Authorize on every object access — reads and writes, API and page routes. A common mistake is gating the page but leaving a JSON API (/api/documents/1011) open.
  • Fail closed. Deny by default; grant explicitly. Missing identity → no access.
  • Don't rely on obscurity. Unguessable UUIDs raise the bar but are not authorization; logs, referrers, and shared links leak IDs.
  • Object-scoped queries. Filtering by owner_username in the WHERE clause makes "forgot to check" impossible for that query.
  • Automated tests: "User A cannot read/modify User B's object" as a standing regression test for each resource type.
  • Audit logging on access to sensitive objects to detect enumeration attempts.

Verification checklist

  • [ ] Every object fetch includes the caller's identity in the query or a policy check.
  • [ ] Cross-user access returns the same response as "not found".
  • [ ] No default/fallback identity for unauthenticated requests.
  • [ ] Both the page route and any underlying API enforce the check.
  • [ ] The "Found IDOR" flag no longer appears for other users' documents.