Skip to content

Security Considerations

AccessControl sits on the authorization path of production systems, so v3 is hardened against the classes of bug that matter for an access‑control library — and documents the few decisions that remain yours.

A denial returns granted: false. An error (invalid query, a strict violation, a misused async condition) is thrown. The danger is a caller that wraps a check in try/catch and lets the catch fall through to “allow”.

Use tryCan() on the hot path: it never throws — every failure resolves to granted: false (the error event still fires for observability). Keep can() for boot/config and tests, where you want a typo to blow up.

// ✅ fail-closed: an unknown role, bad input, or async-required all deny
if (ac.tryCan(role).readAny('post').granted) {
show();
} else {
deny();
}

See Best Practices › can vs tryCan.

Names (roles, resources, actions, groups, categories) are user/data‑controlled strings used as object keys — the classic prototype‑pollution surface.

ac.can('user').readAny('toString').granted; // false (never throws)
ac.grant('__proto__'); // throws RESERVED_NAME

If your grants/conditions are authored in code (the common case), they are trusted input. If they come from a store that lower‑privileged users can edit, treat conditions as untrusted and note the following.

The matches operator compiles a regular expression. A malicious pattern can cause catastrophic backtracking (CPU DoS).

Deeply nested and/or/not trees are rejected at compile time (> 100 levels, err.code === 'INVALID_CONDITION') so a pathological condition from a store cannot exhaust the stack on the auth path.

By default (engine.safeErrors: true) error messages omit caller‑supplied values (names, the raw query/grants object) so request data doesn’t leak into logs. The values stay available programmatically.

const e = grab(() => ac.can('ghost').readAny('post').granted);
e.message; // "Role not found." (redacted)
e.code; // "ROLE_NOT_FOUND"
e.role; // "ghost" (structured field)

Also return uniform responses for denials (same status/body) so “doesn’t exist” vs “denied” isn’t observable to a client.

Names are restricted to ASCII [A-Za-z0-9_-] by default — which also rules out Unicode homograph attacks (visually identical names with different code points).

getGrants(), getGrantsList() and getRequirements() return detached deep copies — mutating a result can never alter the live model or neuter a require() gate. lock() deep‑freezes the model; after it, every mutator throws err.code === 'LOCKED'. Permission.attributes / .roles are frozen copies too.

Authorization isn’t constant‑time (more roles/rules/conditions ⇒ more work). In practice this is buried under network and DB latency, so it’s effectively unexploitable for server‑side checks — we treat constant‑time evaluation as a non‑goal. If it’s in your threat model: rate‑limit auth‑sensitive endpoints and keep denial responses uniform.

The published package’s only runtime dependency is notation — although from the same author, it is pinned to an exact version — and there are zero production advisories (npm audit --omit=dev). Recommended for consumers:

Terminal window
npm audit --omit=dev # audit only what actually ships

100% coverage and mutation testing prove the written code behaves; they cannot prove the engine is safe against inputs the code never anticipated. AccessControl therefore also ships:

  • an adversarial suite (prototype gadgets, inherited‑key names, context spoofing, notation‑path pollution, lock/immutability, deny/wildcard leakage), and
  • a seeded property fuzzer asserting engine invariants over thousands of random policies (determinism, possession cascade, multi‑role = union, serialization round‑trip, deny/require monotonicity, filter idempotence).

See Quality & testing.