Skip to main content
interlace
Plugin: express-securityRules

no-idor-resource-access

This rule detects a resource fetched by an identifier taken straight from the request inside a handler that never mentions the authenticated principal — the classic IDOR shape

Disallow fetching a resource by a request-supplied key in a handler with no principal in scope

Severity: 🔴 High (ships as warn — see below) CWE: CWE-639

Rule Details

app.get('/invoices/:id', (req, res) =>
  Invoice.findById(req.params.id).then((doc) => res.json(doc)),
);

The caller is authenticated. The route is guarded. And incrementing the id in the URL returns another tenant's invoice, because nothing ties the lookup to the caller. That is CWE-639 — authorization bypass through a user-controlled key, the vulnerability class known in the field as IDOR.

The rule reports a lookup call when:

  1. The method is a single-resource fetch — findById, findByPk, findOne, findUnique, findFirst, findByIdAndUpdate, findByIdAndDelete, getById, deleteOne, updateOne, replaceOne, …
  2. One of its arguments carries a key read off req.params / req.query / req.body — directly, or nested in a filter object ({ _id: req.params.id }, { where: { id: req.params.id } }).
  3. It sits inside a function that takes a req parameter (an Express handler or middleware).
  4. That function never reads an authenticated principal — req.user, req.auth, req.session, res.locals.user, …

Condition 4 stands in for "there is no ownership check", which makes it a heuristic: the rule ships as warn and never at enforcement severity (plugin scope-audit invariant I3).

Examples

❌ Incorrect

// The textbook IDOR
app.get('/invoices/:id', (req, res) =>
  Invoice.findById(req.params.id).then((doc) => res.json(doc)),
);

// Query-string key
app.get('/invoices', (req, res) =>
  Invoice.findByPk(req.query.invoiceId).then(send),
);

// Filter object built from the request
app.post('/invoices/find', (req, res) =>
  Invoice.findOne({ _id: req.body.id }).then(send),
);

// Prisma-style nested where clause
app.get('/x/:id', (req, res) =>
  prisma.invoice.findUnique({ where: { id: req.params.id } }).then(send),
);

// Destructive operations count too
app.delete('/invoices/:id', async function (req, res) {
  await Invoice.findByIdAndDelete(req.params.id);
  res.sendStatus(204);
});

✅ Correct

// Scope the query to the principal — the id alone is not enough
app.get('/invoices/:id', (req, res) =>
  Invoice.findOne({ _id: req.params.id, owner: req.user.id }).then((doc) =>
    res.json(doc),
  ),
);

// Or verify ownership on the loaded document
app.get('/invoices/:id', async function (req, res) {
  const doc = await Invoice.findById(req.params.id);
  if (doc.ownerId !== req.session.userId) return res.sendStatus(403);
  res.json(doc);
});

// Keys that do not come from the request are not findings
app.get('/me', (req, res) => User.findById(currentUserId).then(send));

// Collection queries are a different problem (and a different rule)
app.get('/invoices', (req, res) =>
  Invoice.find({ tag: req.params.tag }).then(send),
);

Options

OptionTypeDefaultDescription
lookupMethodsstring[]['findById', 'findByPk', 'findOne', 'findUnique', …]Lookup methods treated as a fetch-by-key (replaces the default set)
{
  "rules": {
    "express-security/no-idor-resource-access": [
      "warn",
      { "lookupMethods": ["findById", "fetchById"] }
    ]
  }
}

When Not To Use It

If ownership is enforced by the data layer for every query — a repository that always injects the tenant from an async-local context, or Postgres row-level security keyed on the connection role — the lookups this rule flags are already scoped, and the findings are noise.

Known False Positives

Ownership checked in a helper. The rule sees only the handler body. A handler that delegates to assertOwnership(req, id) looks identical to one that checks nothing:

// ⚠️ REPORTED, but safe
app.get('/invoices/:id', (req, res) => {
  assertOwnership(req, req.params.id);
  return Invoice.findById(req.params.id).then(send);
});

Mitigation: read the principal in the handler (assertOwnership(req.user, req.params.id)), which is clearer at the call site anyway, or disable the rule inline with a comment naming where the check lives.

Known False Negatives

The following patterns are not detected due to static analysis limitations:

Key Copied To A Variable

Why: No data-flow analysis — the request read must be in the call.

// ❌ NOT DETECTED
const { id } = req.params;
Invoice.findById(id);

An Unrelated Principal Read Suppresses The Report

Why: Any req.user in the handler counts as "the caller is in scope", even when it has nothing to do with the lookup.

// ❌ NOT DETECTED — the log line suppresses it
app.get('/invoices/:id', (req, res) => {
  logger.info({ actor: req.user.id });
  return Invoice.findById(req.params.id).then(send);
});

Lookups Outside The Method Vocabulary

Why: The method must be a known single-resource fetch.

// ❌ NOT DETECTED — until `loadInvoice` is added to lookupMethods
app.get('/invoices/:id', (req, res) => loadInvoice(req.params.id).then(send));

Further Reading

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.