Skip to Content
How it is testedThe acl security register

The acl security register

libs/acl/SECURITY.md is the security register of @evanion/acl: forty classes of attack, each held to a describe block of tests that npx nx test @evanion/acl runs. This page shows every entry beside its tests.

Measured at 86c6966e (2026-11-06) on Node v24.20.0, by the build that produced this page.

How to read a case

Each case below is a vitest test from @evanion/acl’s adversarial suite, the tests in libs/acl/src/security, and this command runs that suite and nothing else:

npx nx test @evanion/acl -- src/security

The acl overview defines the matrix, the subject, a rule and a decision, and this section defines what the cases use beyond those.

The cases are set in Baize, the board game shop of the acl overview, with the object kinds account (a customer’s), listing (a game for sale), order, question (one a customer asked) and wishlist, and the roles customer, bookseller and owner.

The helpers every case calls

A case writes its matrix as JSON, the form parseMatrix accepts from a producer outside your code, such as a policy service written in another language. A hostile or malformed matrix arrives that way, and its author never calls policy(), the builder the acl overview shows. The helpers in libs/acl/src/security/fixtures.ts build the JSON:

  • permission(object, action, body) builds one permission and derives its key, such as listing.read, from object, the object kind, and the action.
  • raw(node) passes a permission through unchecked, for a case that attacks the permission’s own shape. rawMatrix(node) does the same for a whole document.
  • matrix(permissions, envelope) wraps the permissions in a matrix. The envelope holds the matrix’s other members, such as version, which names the matrix’s revision so a holder can tell a stale copy, and schema, which declares each object kind’s fields and their types.
  • foreign(permissions, options, envelope) hands the matrix to parseMatrix, the entry point for a matrix somebody else wrote. A permission key the matrix does not declare gets a refusal, a decision whose allowed is false.
  • local(permissions, options, envelope) hands it to hydratePolicy, the entry point for a matrix your own code wrote, including one your server sent to a browser. A permission key the matrix does not declare throws.
  • options is an AccessOptions, the evaluator’s own settings. Its version overrides the version the matrix carries.
  • A rule’s when member lists its conditions, and the rule matches when all of them hold. when(...conditions) builds such a rule, and always, a rule with an empty when, matches every call. Conditions gives a condition’s shape.
/** A permission body, minus the three fields the key is built from. */ export type PermissionBody = Omit<Permission, 'key' | 'object' | 'action'>; /** * One permission, keyed from its object and action. * * The key is derived rather than written, so a test that means to attack the * key/object/action agreement has to say so with `raw`. */ export function permission( object: string, action: string, body: PermissionBody = {}, ): Permission { return { key: `${object}.${action}`, object, action, ...body }; } /** * A permission whose shape is exactly what the attack needs, valid or not. The * one way malformed configuration enters the suite. */ export function raw(node: Record<string, unknown>): Permission { return node as unknown as Permission; } /** The envelope members a document carries around its permissions. */ export type Envelope = Omit<Matrix, 'permissions'>; /** The document the entry points take. */ export function matrix( permissions: readonly Permission[], envelope: Envelope = {}, ): Matrix { return { ...envelope, permissions }; } /** * A document whose envelope is exactly what the attack needs, valid or not. * The one way a malformed envelope enters the suite. */ export function rawMatrix(node: Record<string, unknown>): Matrix { return node as unknown as Matrix; } /** * An access object over a matrix somebody else wrote: the `parseMatrix` path, * which answers a refusal for a permission key the matrix does not declare. */ export function foreign( permissions: readonly Permission[], options?: AccessOptions, envelope?: Envelope, ): Access { return parseMatrix(matrix(permissions, envelope), options); } /** * An access object over a matrix your own code wrote: the `hydratePolicy` path, * which throws on a permission key the matrix does not declare. */ export function local( permissions: readonly Permission[], options?: AccessOptions, envelope?: Envelope, ): Access { return hydratePolicy(matrix(permissions, envelope), options); } /** The unconditional rule: an empty `when`, the rule `p.always` writes. */ export const always: Rule = { when: [] }; /** A rule over an AND-ed set of conditions. */ export function when(...conditions: readonly Condition[]): Rule { return { when: conditions }; }

Four more names appear in single cases:

  • fromJson(text) is JSON.parse(text), which is how a foreign write arrives carrying an own __proto__ key.
  • countingSubject(attributes) returns { subject, reads, reset }. subject counts every read of its attributes, reads() returns the count, and reset() sets it to zero.
  • new Gen(rng(seed)), from generator.ts, is a seeded random generator, so a failing draw names the seed that reruns it. gen.pick(values) draws one value, gen.list(max, make) draws a list of up to max items, and gen.bool(chance) draws a boolean that is true with that chance.
  • ruleId(rule, side) is the identifier a decision’s rule field carries for that rule, where side is 'allow' or 'deny'.

Conditions

A condition is { field, op, value } or { field, op, path }. field is a path such as subject.role or object.status, or now, the clock. value is a literal to compare with, and path, which eq accepts, names a second path to compare with. The cases use six operators:

  • eq holds when the two sides are equal, with no type coercion.
  • in and not-in hold when the field’s value is, or is not, in the list value.
  • contains holds when the field’s value is a list that holds value.
  • before and after hold when now is before or after the instant value.

Terms the cases use

  • A permission’s deny rules sit in denyRules, beside its allow rules in rules. A deny rule that matches refuses, whatever the allow rules say.
  • A field list such as fields: ['*', '!role'] names the fields a permission lets a caller read or write. * is the baseline, every field, and !role is an exclusion. Both are syntax, and neither is ever a field name.
  • fields can also be an object. Its fields member holds the list, and every other member names a field and a config for it. targets lists the values a write may set on the field, and transitions maps the field’s current value to the values it may move to. With configs and no list, every field the configs do not name stays writable.
  • A projection is a row loaded with some of its fields. A rule that reads a field the projection lacks cannot be settled. The decision is then unevaluable in two cases: the unsettled rule is a deny rule and the allow rules have not all failed, or it is an allow rule and no other allow rule matched. decision.missing names the fields to fetch.

A decision’s reason in these cases is one of five values:

reasonWhen
denieda deny rule matched
no-rule-matchedno allow rule matched
unknown-actionthe matrix declares no permission for the action
unevaluablea rule read a field the projection lacks, as above
unusable-clocka time condition read a clock that does not parse

A field’s entry in reasons can also be transition-failed: the write moves the field to a value its transitions config does not allow from the field’s current value.

The evaluator

foreign and local return an evaluator, which the cases call access. The cases use these members of it:

  • can(subject, kind, action, row?, now?) returns one decision. now is the clock a time condition reads, and without it the evaluator reads the wall clock.
  • canFields(subject, kind, action, row, axis, proposed?) also decides each field. axis is 'read' for the fields of row, or 'write' for the fields of proposed, the write a caller submitted. Its result carries fields, which marks each field 'allowed', 'denied' or 'unevaluable', and reasons, which names why per field. action is the decision on the action alone, and allowed is true only when the action and every field allow.
  • capabilities(subject) returns a decision for every permission in the matrix, keyed by permission key.
  • authorize(subject) returns a handle with the subject bound, so its can(kind, action) takes no subject.
  • access.matrix is the frozen matrix the evaluator decides from, and access.version is its version.

pickAllowedFields(decision, proposed) is a function, not a member. It returns the part of proposed that a canFields decision marked 'allowed', and throws when the action itself is refused.

The three tiers

Every entry in @evanion/acl’s register sits in one of three tiers, and the tier decides what the entry’s test may claim:

  • Tier 1, prevented. The attack ends in a construction error or a refusal, and the test asserts one of the two. A construction error is an exception such as InvalidRuleError that foreign or local throws while it builds the evaluator.
  • Tier 2, primitive supplied. @evanion/acl supplies the primitive that defends against the attack, such as pickAllowedFields or access.version, and your code has to use it. The test asserts the primitive is correct and, where a hand-written idiom exists, that the idiom fails on the same decision. SEC-101’s idiom filters a write by hand on !== 'denied', which keeps the unevaluable fields.
  • Tier 3, out of scope. An evaluator cannot defend against the class. The test pins what the evaluator does, so nobody reads its answer as a defence. A second test in the same describe, not shown here, checks that the acl README states the limit.
40classes of attack
24prevented
8primitive supplied
8out of scope
164cases in libs/acl/src/security

Each entry names its class in the Common Weakness Enumeration (CWE), and some also name an entry in one of the Open Worldwide Application Security Project (OWASP) Top 10 lists. API3:2023, for example, is the third entry of the OWASP API Security Top 10 of 2023: broken object property level authorization.

8 of the 40 entries carry an OWASP identifier and the other 32 carry a CWE alone, over 24 distinct CWEs and 3 OWASP entries (A01:2021, API1:2023, API3:2023). The identifier is a property of one entry. This page counts entries and never categories.

The order of the cases

The page shows @evanion/acl’s cases in four groups. Three of the four criteria come from the register’s columns: the tier, the OWASP identifier, and whether every clause of the Mechanism column names construction. The fourth, whether a case draws from the seeded generator, comes from the test file.

  1. Tier 3 leads, because an entry stating that no defence exists is the hardest to fake.
  2. Tier 2 follows, because a primitive your code skips is the mistake you are most likely to make.
  3. Tier 1 entries whose defence acts when a decision is made come next, since a wrong grant there raises no error. The entries carrying an OWASP identifier come first, then SEC-019, which draws its writes from the seeded generator, then the rest by identifier.
  4. Tier 1 entries whose whole mechanism is a construction error come last, because the first run of the code throws.

Every group but the third runs by identifier. The OWASP column is the one place the order rests on a judgement, since somebody chose each mapping. The register maps SEC-001 to API3:2023, and without that mapping SEC-001 would sort after SEC-019. tools/repo-checks/src/test-statistics.test.ts fails the build when the cases on this page leave this order.

  1. Out of scope, published as a non-defence — An entry stating that no defence exists is the least fakeable thing here, so it leads. 8 entries: SEC-201, SEC-202, SEC-203, SEC-204, SEC-205, SEC-206, SEC-207, SEC-208.
  2. The primitive exists, your code has to use it — Where a hand-written idiom exists, the register states it beside the primitive, and that idiom is the mistake a competent engineer actually makes. 8 entries: SEC-101, SEC-102, SEC-103, SEC-104, SEC-105, SEC-106, SEC-107, SEC-108.
  3. Prevented when a decision is made — A wrong grant with no error is the failure a reviewer never catches by reading. Within the group: the entries carrying an OWASP identifier, then the entry drawing from the seeded generator, then the rest by identifier. 18 entries: SEC-001, SEC-004, SEC-010, SEC-011, SEC-019, SEC-005, SEC-006, SEC-007, SEC-008, SEC-012, SEC-013, SEC-014, SEC-016, SEC-017, SEC-018, SEC-022, SEC-023, SEC-024.
  4. Refused at construction — Last, because a construction error is loud: the first developer to run the code sees a thrown exception, so no silent failure was avoided. 6 entries: SEC-002, SEC-003, SEC-009, SEC-015, SEC-020, SEC-021.

The acl register’s forty cases

@evanion/acl’s forty register entries follow, each with its test, in four groups: out of scope, primitive supplied, prevented when a decision is made, and refused at construction.

Out of scope, published as a non-defence

out of scope

SEC-201 — Confused deputy: a forged subject

CWE-441, A01:2021

can authorizes the bag it is handed and has no channel to ask where it came from. Resolve the subject from a verified session, server-side.

The test that proves it
it('authorizes a forged subject exactly as it would a real one', () => { // No defence. The engine has no channel to ask where the subject came from, // and this is what that costs. const access = foreign([ permission('order', 'refund', { rules: [when({ field: 'subject.role', op: 'eq', value: 'owner' })], }), ]); expect(access.can({ role: 'owner' }, 'order', 'refund').allowed).toBe(true); });

libs/acl/src/security/tier3-contract.test.ts, run by npx nx test @evanion/acl.

out of scope

SEC-202 — Complete mediation

CWE-862, A01:2021

Nothing makes an app call can. authorize(subject) makes the checked path the easy one; it cannot make the unchecked one impossible.

The test that proves it
it('names the handle that makes the checked path the easy one', () => { const access = foreign([ permission('listing', 'read', { rules: [always] }), ]); const bound = access.authorize({ id: 'u1' }); expect(typeof bound.can).toBe('function'); expect(bound.can('listing', 'read').allowed).toBe(true); });

libs/acl/src/security/tier3-contract.test.ts, run by npx nx test @evanion/acl.

out of scope

SEC-203 — Time of check to time of use (TOCTOU) between the decision and the write

CWE-367

A decision describes the snapshot it was given and carries no freshness token. Re-check inside the transaction.

The test that proves it
it('carries no freshness token a writer could check', () => { const access = foreign([ permission('question', 'update', { rules: [ when({ field: 'object.askedBy', op: 'eq', path: 'subject.id' }), ], }), ]); const decision = access.can({ id: 'u1' }, 'question', 'update', { id: 'q1', askedBy: 'u1', }); // The decision names the key, the reason and the rule. Nothing in it ties // the answer to a version of the row it was answered about. expect(Object.keys(decision).sort()).toEqual([ 'allowed', 'key', 'reason', 'rule', ]); });

libs/acl/src/security/tier3-contract.test.ts, run by npx nx test @evanion/acl.

out of scope

SEC-204 — A self-authorizing condition over subject-writable data

CWE-639

The engine cannot know which object fields the subject can write, and the guarded read is a different action from the write that opens it.

The test that proves it
it('grants the subject that added itself to the field the rule reads', () => { // No defence. The engine cannot know which object fields the subject can // write, so this is the grant the acl contract warns about under "What a // condition may read", shown working. const access = foreign([ permission('wishlist', 'read', { rules: [ when({ field: 'object.sharedWith', op: 'contains', value: 'u1' }), ], }), ]); expect( access.can({ id: 'u1' }, 'wishlist', 'read', { sharedWith: ['u2'] }) .allowed, ).toBe(false); expect( access.can({ id: 'u1' }, 'wishlist', 'read', { sharedWith: ['u2', 'u1'] }) .allowed, ).toBe(true); });

libs/acl/src/security/tier3-contract.test.ts, run by npx nx test @evanion/acl.

out of scope

SEC-205 — Client-side enforcement

CWE-602

The same call answers the same way in a browser and on a server; nothing in the types separates authoritative from advisory. The runtime decides.

The test that proves it
it('answers a browser and a server identically, which is why the runtime decides', () => { const access = foreign([ permission('listing', 'delete', { rules: [always] }), ]); // The same call, the same answer, wherever it runs. Nothing in the return // type separates an authoritative decision from a rendering hint. expect(access.can({ id: 'u1' }, 'listing', 'delete').allowed).toBe(true); });

libs/acl/src/security/tier3-contract.test.ts, run by npx nx test @evanion/acl.

out of scope

SEC-206 — Disclosure through the matrix itself

CWE-200

The matrix ships in full: every key, role string, field name and time window. Name things as if they will be read.

The test that proves it
it('exposes every key, condition and field name it was built from', () => { const access = foreign([ permission('order', 'mark-down', { rules: [when({ field: 'subject.role', op: 'eq', value: 'owner' })], fields: { fields: ['*', '!supplierCost'] }, }), ]); expect(JSON.stringify(access.matrix)).toContain('owner'); expect(JSON.stringify(access.matrix)).toContain('supplierCost'); });

libs/acl/src/security/tier3-contract.test.ts, run by npx nx test @evanion/acl.

out of scope

SEC-207 — A caller-supplied clock

CWE-807, CWE-367

now is a parameter, and a clock that parses is taken as given on every entry point that accepts one: a client-supplied instant slides every window in the matrix. Resolve it server-side. A clock that does not parse is SEC-018 and refuses.

The test that proves it
it('opens a closed window for a caller that supplies its own instant', () => { const access = foreign([ permission('listing', 'preorder', { rules: [when({ field: 'now', op: 'before', value: '2020-01-01' })], }), ]); expect(access.can({}, 'listing', 'preorder').allowed).toBe(false); expect( access.can({}, 'listing', 'preorder', undefined, '2019-06-01').allowed, ).toBe(true); });

libs/acl/src/security/tier3-contract.test.ts, run by npx nx test @evanion/acl.

out of scope

SEC-208 — The subject and the object are read live

CWE-367

The matrix is copied and frozen; the subject and the object are the app's data and are not. A bag whose properties are accessors answers the deny side and the allow side separately. Pass plain, resolved objects.

The test that proves it
it('decides the deny side and the allow side against separate reads', () => { // No defence. The matrix is copied and frozen; the subject is the app's own // data and is not. A subject whose getter answers twice decides twice. let read = 0; const subject: Record<string, unknown> = {}; Object.defineProperty(subject, 'role', { enumerable: true, get() { read++; return read === 1 ? 'nobody' : 'owner'; }, }); const access = foreign([ permission('order', 'refund', { rules: [when({ field: 'subject.role', op: 'eq', value: 'owner' })], denyRules: [when({ field: 'subject.role', op: 'eq', value: 'owner' })], }), ]); expect(access.can(subject, 'order', 'refund').allowed).toBe(true); expect(access.can({ role: 'owner' }, 'order', 'refund').allowed).toBe( false, ); });

libs/acl/src/security/tier3-contract.test.ts, run by npx nx test @evanion/acl.

The primitive exists, your code has to use it

primitive supplied

SEC-101 — Mass assignment when applying a write

CWE-915, API3:2023

pickAllowedFields keeps only what the decision marked allowed. Filtering by hand on !== 'denied' writes the unevaluable fields and every key the decision does not carry.

The test that proves it
/** The hand-rolled filter `pickAllowedFields` exists to replace. */ const naiveFilter = ( decision: FieldDecision, proposed: Record<string, unknown>, ): Record<string, unknown> => Object.fromEntries( Object.entries(proposed).filter( ([key]) => decision.fields[key] !== 'denied', ), ); const access = () => foreign([ permission('account', 'update', { rules: [always], fields: { fields: ['*', '!role'], membership: { targets: ['none', 'library'] }, }, }), ]); const decisionFor = (proposed: Record<string, unknown>): FieldDecision => access().canFields( { id: 'u1' }, 'account', 'update', { id: 'u1', membership: 'none' }, 'write', proposed, ); it('shows the hand-rolled deny-list filter writing what the decision refused', () => { const proposed = { '*': 'everything', name: 'Grace', membership: 'staff' }; const decision = decisionFor(proposed); expect(decision.fields['membership']).toBe('denied'); // `*` is syntax and names no field, so the decision holds no entry for it, // and a filter keeping every key not marked 'denied' keeps it. expect(naiveFilter(decision, proposed)).toEqual({ '*': 'everything', name: 'Grace', }); expect(pickAllowedFields(decision, proposed)).toEqual({ name: 'Grace' }); });

libs/acl/src/security/tier2-primitives.test.ts, run by npx nx test @evanion/acl.

primitive supplied

SEC-102 — A per-field config mistaken for a writable-field list

CWE-915

A targets/transitions config restricts that field and no other; the writable set is stated by fields.fields. Without a name list every unnamed key is writable.

The test that proves it
const configOnly = () => foreign([ permission('account', 'update', { rules: [always], fields: { role: { targets: ['customer'] } }, }), ]); it('leaves every unnamed key writable when only a config is given', () => { const proposed = { role: 'owner', isOwner: true, credit: 500 }; const decision = configOnly().canFields( { id: 'u1' }, 'account', 'update', { id: 'u1' }, 'write', proposed, ); expect(decision.fields['role']).toBe('denied'); expect(decision.fields['isOwner']).toBe('allowed'); expect(pickAllowedFields(decision, proposed)).toEqual({ isOwner: true, credit: 500, }); });

libs/acl/src/security/tier2-primitives.test.ts, run by npx nx test @evanion/acl.

primitive supplied

SEC-103 — Object-level authorization: insecure direct object reference (IDOR) / broken object level authorization (BOLA)

CWE-639, API1:2023

A condition over object. plus a projection that carries the field. A projection that omits it refuses rather than grants.

The test that proves it
const owned = () => foreign([ permission('order', 'read', { rules: [ when({ field: 'object.customerId', op: 'eq', path: 'subject.id' }), ], }), ]); it('refuses rather than allows when the projection omits the owner', () => { const decision = owned().can({ id: 'u1' }, 'order', 'read', { id: 'order-2026-0042', }); expect(decision.allowed).toBe(false); expect(decision.reason).toBe('unevaluable'); expect(decision.missing).toEqual(['object.customerId']); });

libs/acl/src/security/tier2-primitives.test.ts, run by npx nx test @evanion/acl.

primitive supplied

SEC-104 — Reading an unevaluable decision as a grant

CWE-863

allowed is the gate; missing names what one refetch has to bring back. Gating on the reason string instead grants what the engine refused.

The test that proves it
const access = () => foreign([ permission('order', 'read', { rules: [ when( { field: 'object.customerId', op: 'eq', path: 'subject.id' }, { field: 'object.table', op: 'eq', path: 'subject.table' }, ), ], }), ]); it('shows reading anything but `allowed` as the grant going wrong', () => { const decision = access().can({ id: 'u1', table: 3 }, 'order', 'read', { id: 'order-2026-0042', }); // `unevaluable` is not `denied`, so a consumer gating on the reason string // rather than on `allowed` grants what the engine refused. expect(decision.reason === 'denied').toBe(false); expect(decision.allowed).toBe(false); });

libs/acl/src/security/tier2-primitives.test.ts, run by npx nx test @evanion/acl.

primitive supplied

SEC-105 — Unicode and key-shape attacks on a field name

CWE-176

Names match as written, byte for byte. A composed and a decomposed spelling are two fields; an explicit allow-list closes the question, an exclusion list does not.

The test that proves it
// Each name reads as `rôle` or `role` on screen, and its code points differ. const composed = 'rôle'; // ô is U+00F4 const decomposed = 'rôle'; // o, then the combining U+0302 const widened = 'ro​le'; // ro, a zero-width U+200B, then le const homoglyph = 'гole'; // Cyrillic U+0433 in place of r const access = () => foreign([ permission('account', 'update', { rules: [always], fields: { fields: ['*', `!${composed}`] }, }), ]); it('treats a differently spelled name as a different field', () => { const proposed = { [decomposed]: 'owner', [widened]: 'owner', [homoglyph]: 'owner', ROLE: 'owner', }; const decision = access().canFields( { id: 'u1' }, 'account', 'update', {}, 'write', proposed, ); for (const name of [decomposed, widened, homoglyph, 'ROLE']) { expect(decision.fields[name], name).toBe('allowed'); } // The consumer's normalisation, not the engine's: a store that folds these // together gets four names for one column and the exclusion is a bypass. expect(decomposed.normalize('NFC')).toBe(composed); });

libs/acl/src/security/tier2-primitives.test.ts, run by npx nx test @evanion/acl.

primitive supplied

SEC-106 — `in` and `eq` disagreeing about NaN

CWE-1077

includes is SameValueZero and === is not, so a list matches NaN where an equality never does. A rule that means "never" is an equality. JSON carries no NaN in the first place.

The test that proves it
const rule = (op: 'eq' | 'in' | 'not-in') => foreign([ permission('listing', 'read', { rules: [ when({ field: 'subject.table', op, value: op === 'eq' ? Number.NaN : [Number.NaN], }), ], }), ]); it('never matches an equality against NaN', () => { expect( rule('eq').can({ table: Number.NaN }, 'listing', 'read').allowed, ).toBe(false); }); it('matches a membership test against NaN', () => { // `includes` is SameValueZero, `===` is not. A list is the more permissive // operator for this one value, so a rule that means "never" is written as // an equality. expect( rule('in').can({ table: Number.NaN }, 'listing', 'read').allowed, ).toBe(true); expect( rule('not-in').can({ table: Number.NaN }, 'listing', 'read').allowed, ).toBe(false); });

libs/acl/src/security/tier2-primitives.test.ts, run by npx nx test @evanion/acl.

primitive supplied

SEC-107 — A stale matrix still granting a revoked permission

CWE-672

access.version is the surface for detecting a mismatch. Comparing it, failing closed and refetching is the consumer's.

The test that proves it
it('keeps granting what a revoked matrix granted until it is replaced', () => { const stale = foreign( [permission('listing', 'delete', { rules: [always] })], { version: 1, }, ); const current = foreign([permission('listing', 'delete', { rules: [] })], { version: 2, }); expect(stale.can({ id: 'u1' }, 'listing', 'delete').allowed).toBe(true); expect(current.can({ id: 'u1' }, 'listing', 'delete').allowed).toBe(false); // Comparing the two versions and failing closed is the consumer's; the // access object holds no channel to learn it has been superseded. expect(stale.version).not.toBe(current.version); });

libs/acl/src/security/tier2-primitives.test.ts, run by npx nx test @evanion/acl.

primitive supplied

SEC-108 — Reading the field maps as the gate

CWE-863

The maps answer what would be editable whatever the action decides; allowed is the composition of both.

The test that proves it
const access = () => foreign([ permission('listing', 'update', { rules: [when({ field: 'subject.role', op: 'eq', value: 'bookseller' })], fields: { fields: ['*'] }, }), ]); it('reports every field writable while the action is refused', () => { const decision = access().canFields( { id: 'u1', role: 'nobody' }, 'listing', 'update', { id: 'l1' }, 'write', { title: 'x' }, ); // The maps answer "what would be editable", so a blocked caller can still // render the form. They are not the gate. expect(decision.fields['title']).toBe('allowed'); expect(decision.action.allowed).toBe(false); expect(decision.allowed).toBe(false); });

libs/acl/src/security/tier2-primitives.test.ts, run by npx nx test @evanion/acl.

Prevented when a decision is made

prevented

SEC-001 — Mass assignment through a key present only in the proposed write

CWE-915, API3:2023

The write axis decides every key of proposed, not only the keys the object carries, so an exclusion applies to a key the row has never held.

The test that proves it
const editable = () => foreign([ permission('account', 'update', { rules: [always], fields: { fields: ['*', '!role'] }, }), ]); it('decides a key that exists only in the proposed write', () => { const decision = editable().canFields( { id: 'u1' }, 'account', 'update', { id: 'u1', name: 'Ada' }, 'write', { name: 'Grace', role: 'owner' }, ); expect(decision.fields['role']).toBe('denied'); expect(decision.allowed).toBe(false); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-004 — Unevaluable deny: a deny rule reading a path the projection lacks

CWE-863, A01:2021

A deny side that cannot be read outranks a matching allow; the decision is unevaluable and names the paths to fetch.

The test that proves it
const guarded = () => foreign([ permission('listing', 'read', { rules: [always], denyRules: [when({ field: 'object.status', op: 'eq', value: 'draft' })], }), ]); it('refuses when the projection lacks the field the deny reads', () => { const decision = guarded().can({ id: 'u1' }, 'listing', 'read', { id: 'l1', }); expect(decision.allowed).toBe(false); expect(decision.reason).toBe('unevaluable'); expect(decision.missing).toEqual(['object.status']); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-010 — Incorrect default permissions

CWE-276, A01:2021

Nothing is allowed without an allow rule that matched: no rules, an empty rule list and an unknown action all refuse.

The test that proves it
it('refuses a permission with no rules at all', () => { const access = foreign([permission('listing', 'read')]); expect(access.can({ id: 'u1' }, 'listing', 'read').allowed).toBe(false); expect(access.can({ id: 'u1' }, 'listing', 'read').reason).toBe( 'no-rule-matched', ); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-011 — Authorization bypass through a user-controlled key

CWE-566, API1:2023

The untrusted path fails closed on an unknown key; the authored path throws.

The test that proves it
it('refuses an unknown key on a foreign matrix', () => { const access = foreign([ permission('listing', 'read', { rules: [always] }), ]); const decision = access.can({ id: 'u1' }, 'anything', 'at-all'); expect(decision.allowed).toBe(false); expect(decision.reason).toBe('unknown-action'); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-019 — A hostile write escaping the decision it was narrowed against

CWE-915

Property over generated writes of authoring tokens, prototype names, unicode shapes and oversized keys: every picked key was marked allowed, and applying the result moves no prototype.

The test that proves it
/** * The keys a foreign write actually carries: authoring tokens, prototype * names, unicode shapes, and names long enough to be a shape of their own. */ const KEYS = [ 'name', 'role', '__proto__', 'constructor', 'prototype', '*', '!role', '!*', 'ro​le', 'róle', 'rôle', 'rôle', '', ' role', 'role ', 'ROLE', 'a'.repeat(4096), ] as const; it('never picks a key the decision did not mark allowed', () => { let built = 0; for (let seed = 1; seed <= 400; seed++) { const gen = new Gen(rng(seed)); // An exclusion such as `!role` is valid only beside the `*` baseline, so // a drawn list carrying one gets the baseline too. The attack is the // write, and the list only has to be one the engine accepts. const drawn = gen .list(4, () => gen.pick([...KEYS, '*', '!role'])) .filter((n) => n.length > 0); const valid = drawn.some((n) => n.startsWith('!')) ? ['*', ...drawn] : drawn; const names = gen.bool(0.6) ? valid : undefined; let access: ReturnType<typeof foreign>; try { access = foreign([ permission('account', 'update', { rules: [always], ...(names ? { fields: { fields: names } } : {}), }), ]); } catch (error) { // A drawn list may be one construction refuses: an entry beside its // own exclusion, or a token spelled as a path. Each refusal is an // InvalidPermissionError on a list entry, and it names the permission. expect(error, `seed ${seed}`).toBeInstanceOf(InvalidPermissionError); expect((error as InvalidPermissionError).key, `seed ${seed}`).toBe( 'account.update', ); expect((error as InvalidPermissionError).field, `seed ${seed}`).toMatch( /^fields\.fields\[\d+\]$/, ); continue; } built++; const proposed: Record<string, unknown> = {}; for (const key of gen.list(6, () => gen.pick(KEYS))) { Object.defineProperty(proposed, key, { value: gen.pick(['x', 1, null, { isOwner: true }]), enumerable: true, writable: true, configurable: true, }); } const decision = access.canFields( { id: 'u1' }, 'account', 'update', {}, 'write', proposed, ); const writable = pickAllowedFields(decision, proposed); for (const key of Object.keys(writable)) { expect(decision.fields[key], `seed ${seed}: ${key}`).toBe('allowed'); expect(key, `seed ${seed}`).not.toBe('__proto__'); expect(key, `seed ${seed}`).not.toBe('*'); expect(key.startsWith('!'), `seed ${seed}: ${key}`).toBe(false); } const row: Record<string, unknown> = {}; Object.assign(row, writable); expect(Object.getPrototypeOf(row), `seed ${seed}`).toBe(Object.prototype); expect(({} as Record<string, unknown>)['isOwner']).toBeUndefined(); } // Most lists build, so the writes above ran against real decisions. expect(built).toBeGreaterThan(100); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-005 — Prototype pollution reaching a decision

CWE-1321

Every path read and every rule lookup is own-property guarded; every decision map is written with defineProperty.

The test that proves it
const probe = (field: string): Condition => ({ field, op: 'eq', value: 'owner', }); it('reads no inherited member off the subject', () => { const access = foreign([ permission('listing', 'read', { rules: [when(probe('subject.toString'))], }), ]); expect(access.can({ id: 'u1' }, 'listing', 'read').allowed).toBe(false); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-006 — A prototype member naming a transition edge

CWE-1321

The transitions map is read as own properties only.

The test that proves it
const machine = () => foreign([ permission('listing', 'update', { rules: [always], fields: { status: { transitions: { draft: ['published'] } } }, }), ]); it('denies a current value that names an inherited member', () => { for (const current of ['toString', 'constructor', '__proto__']) { const decision = machine().canFields( { id: 'u1' }, 'listing', 'update', { status: current }, 'write', { status: 'published' }, ); expect(decision.fields['status']).toBe('denied'); expect(decision.reasons['status']).toBe('transition-failed'); } });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-007 — A narrowed write carrying `__proto__` into the consumer's row

CWE-1321

__proto__ names no field, so it never enters a decision map, and Object.assign of the result cannot move the row's prototype. pickAllowedFields drops it at every level the rules descend into; a subtree no rule reaches is copied by reference, own __proto__ key included, so a deep merge skips the name itself. A schema declaring the name does not make it a field: the schema binds conditions, not the writable set.

The test that proves it
const access = () => foreign([ permission('account', 'update', { rules: [always], fields: { fields: ['*', '!role'] }, }), ]); it('carries no __proto__ key into the decision', () => { const proposed = fromJson( '{"__proto__": {"isOwner": true}, "name": "Grace"}', ); const decision = access().canFields( { id: 'u1' }, 'account', 'update', {}, 'write', proposed, ); expect(Object.hasOwn(decision.fields, '__proto__')).toBe(false); }); /** * The guarantee holds at every level the rules descend into. A subtree no * rule reaches is copied by reference, own `__proto__` key included, so a * consumer that merges such a subtree skips the name itself. */ it('drops a nested __proto__ where the rules descend, and copies an unwalked subtree whole', () => { const proposed = fromJson( '{"address": {"__proto__": {}, "*": 1, "!role": 2, "city": "Lund"}}', ); const decide = (fields: string[]) => foreign([ permission('account', 'update', { rules: [always], fields: { fields }, }), ]).canFields( { id: 'u1' }, 'account', 'update', { address: { city: 'Malmo' } }, 'write', proposed, ); const walked = pickAllowedFields( decide(['*', '!address.zip']), proposed, ) as { address: object }; const unwalked = pickAllowedFields(decide(['*']), proposed) as { address: object; }; expect(Object.hasOwn(walked.address, '__proto__')).toBe(false); expect(unwalked.address).toBe(proposed['address']); expect(Object.hasOwn(unwalked.address, '__proto__')).toBe(true); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-008 — Authoring token leakage: `*` and `!name` arriving as literal keys

CWE-915

and !name are syntax, not field names, at every level the rules descend into; a subtree no rule reaches is copied by reference. They are syntax wherever they turn up — in the rules, on the object, or in the write.

The test that proves it
const access = () => foreign([ permission('account', 'update', { rules: [always], fields: { fields: ['*', '!role'] }, }), ]); it('drops a proposed key spelled as a baseline or an exclusion', () => { const proposed = { '*': 'everything', '!role': 'owner', name: 'Grace' }; const decision = access().canFields( { id: 'u1' }, 'account', 'update', {}, 'write', proposed, ); expect(Object.keys(decision.fields)).toEqual(['name']); expect(pickAllowedFields(decision, proposed)).toEqual({ name: 'Grace' }); }); /** * The guarantee holds at every level the rules descend into. A subtree no * rule reaches is copied by reference with its literal keys. */ it('drops nested tokens where the rules descend, and copies an unwalked subtree whole', () => { const proposed = fromJson( '{"address": {"__proto__": {}, "*": 1, "!role": 2, "city": "Lund"}}', ); const decide = (fields: string[]) => foreign([ permission('account', 'update', { rules: [always], fields: { fields }, }), ]).canFields( { id: 'u1' }, 'account', 'update', { address: { city: 'Malmo' } }, 'write', proposed, ); const walked = pickAllowedFields( decide(['*', '!address.zip']), proposed, ) as { address: object }; const unwalked = pickAllowedFields(decide(['*']), proposed) as { address: object; }; expect(Object.keys(walked.address)).toEqual(['city']); expect(Object.keys(unwalked.address)).toEqual([ '__proto__', '*', '!role', 'city', ]); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-012 — Mutating the matrix after it is adopted

CWE-913

The matrix is a deep-frozen copy, so neither the caller's reference nor the exposed one can change what evaluates.

The test that proves it
it('ignores a mutation of the matrix the caller passed in', () => { const source: Permission[] = [permission('listing', 'read', { rules: [] })]; const access = foreign(source); (source[0] as { rules: unknown }).rules = [always]; expect(access.can({ id: 'u1' }, 'listing', 'read').allowed).toBe(false); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-013 — Configuration time of check to time of use (TOCTOU): a document that answers validation and evaluation differently

CWE-367

The envelope is rebuilt from one read of each member, frozen, and validated as that copy, so nothing sits between the checked bytes and the evaluated ones. Covers permissions, schema, version, and everything under them.

The test that proves it
/** A property that answers the first read one way and every later one another. */ function twoFaced<T>(first: T, then: T): { get(): T } { let read = 0; return { get: () => (read++ === 0 ? first : then), }; } it('cannot turn a conditional rule into an unconditional one', () => { const face = twoFaced<readonly Condition[]>( [{ field: 'subject.role', op: 'eq', value: 'owner' }], [], ); const access = local([ permission('listing', 'read', { rules: [ { get when() { return face.get(); }, }, ], }), ]); expect(access.can({ role: 'nobody' }, 'listing', 'read').allowed).toBe( false, ); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-014 — Object data that cannot be coerced, crashing the decision

CWE-754, CWE-248

Only a primitive names a transition edge; a value with no prototype, a hostile toString and a symbol all deny.

The test that proves it
const machine = () => foreign([ permission('listing', 'update', { rules: [always], fields: { status: { transitions: { draft: ['published'] } } }, }), ]); const hostile: readonly (readonly [string, unknown])[] = [ ['a value with no prototype', Object.create(null)], [ 'a value whose coercion throws', { toString: () => { throw new Error('boom'); }, }, ], ['a symbol', Symbol('draft')], ['a list', ['draft']], ['a value that coerces to a legal edge', { toString: () => 'draft' }], ]; for (const [name, current] of hostile) { it(`denies a transition from ${name}`, () => { const decision = machine().canFields( { id: 'u1' }, 'listing', 'update', { status: current }, 'write', { status: 'published' }, ); expect(decision.fields['status']).toBe('denied'); expect(decision.reasons['status']).toBe('transition-failed'); }); }

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-016 — Cost of a decision growing faster than the matrix

CWE-400

A decision reads the permission it was asked about and nothing else; the bound is asserted as a count of subject reads, not a duration.

The test that proves it
it('reads the subject once per permission across the whole matrix', () => { const access = foreign( Array.from({ length: 2_000 }, (_, i) => permission(`w${i}`, 'read', { rules: [when({ field: 'subject.id', op: 'eq', value: 'u1' })], }), ), ); const { subject, reads, reset } = countingSubject({ id: 'u1' }); reset(); access.capabilities(subject); expect(reads()).toBe(2_000); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-017 — Type confusion across a JSON boundary

CWE-1287

Comparisons are strict: '1' is not 1, null is not absent, a list is not the scalar it holds, a Date is not the string that spells it.

The test that proves it
const equals = (value: unknown) => foreign([ permission('listing', 'read', { rules: [when({ field: 'subject.table', op: 'eq', value })], }), ]); it('does not match a string against the number it spells', () => { expect(equals(1).can({ table: '1' }, 'listing', 'read').allowed).toBe( false, ); expect(equals('1').can({ table: 1 }, 'listing', 'read').allowed).toBe( false, ); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-018 — A clock or a boundary that does not parse, on either side

CWE-754

A condition whose clock does not parse is unusable-clock, not a fail, and the state outranks a matching allow, so a time-gated deny goes on denying. A boundary that does not parse is an InvalidConditionError at construction.

The test that proves it
it('refuses the deny side ahead of an allow rule that matched', () => { const denied = when({ field: 'now', op: 'after', value: '2020-01-01' }); const access = foreign([ permission('listing', 'preorder', { rules: [always, when({ field: 'subject.id', op: 'eq', value: 's1' })], denyRules: [denied], }), ]); const decision = access.can( { id: 's1' }, 'listing', 'preorder', undefined, 'not a date' as never, ); expect(decision.allowed).toBe(false); expect(decision.reason).toBe('unusable-clock'); expect(decision.rule).toBe(ruleId(denied, 'deny')); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-022 — A nested exclusion that leaks through its parent, such as `!address.street` returning the whole address, or through a raw dotted column such as `address.street` on a flat SQL row

CWE-863

A field rule names a path. The decision carries the nested key as denied, and pickAllowedFields rebuilds the parent without it. Construction refuses a malformed path, an exclusion with no baseline, an entry beside its own exclusion, and a rule below a targets/transitions key. Rules do not index into arrays: an array a rule indexes into, such as items under !items.0.price, is denied with not-traversable on both axes.

The test that proves it
/** * `['*', '!address.street']` names a key below `address`. The decision * carries the street as denied and `pickAllowedFields` rebuilds the parent * without it, so the response holds the city and no street. */ const row = { id: 'o1', address: { city: 'Malmo', street: 'Storgatan 1' }, }; const readable = () => foreign([ permission('order', 'read', { rules: [always], fields: { fields: ['*', '!address.street'] }, }), ]); it('hides the nested key and keeps the rest of the parent', () => { const decision = readable().canFields( { id: 'u1' }, 'order', 'read', row, 'read', ); expect(decision.fields['address.street']).toBe('denied'); expect(pickAllowedFields(decision, row)).toEqual({ id: 'o1', address: { city: 'Malmo' }, }); }); it('leaves the excluded key out of the picked parent on the hydrated path', () => { const decision = local([ permission('order', 'read', { rules: [always], fields: { fields: ['*', '!address.street'] }, }), ]).canFields({ id: 'u1' }, 'order', 'read', row, 'read'); expect(pickAllowedFields(decision, row)).not.toHaveProperty( 'address.street', ); expect( (pickAllowedFields(decision, row) as { address: object }).address, ).not.toHaveProperty('street'); }); it('hides a flat key that spells the excluded path', () => { const flat = { id: 'o1', 'address.street': 'Storgatan 1' }; const decision = readable().canFields( { id: 'u1' }, 'order', 'read', flat, 'read', ); expect(decision.fields['address\\.street']).toBe('denied'); expect(pickAllowedFields(decision, flat)).toEqual({ id: 'o1' }); }); /** * Index paths are not supported. A rule that indexes into an array, such as * `!items.0.price`, would otherwise decide nothing and leave every price in * the response, so the array is hidden whole. */ it.each([['!items.0.price'], ['!items.0']])( 'hides an array that %s indexes into, on both axes', (exclusion) => { const order = { id: 'o1', items: [{ price: 10, sku: 'a' }] }; const access = foreign([ permission('order', 'read', { rules: [always], fields: { fields: ['*', exclusion] }, }), permission('order', 'update', { rules: [always], fields: { fields: ['*', exclusion] }, }), ]); const read = access.canFields( { id: 'u1' }, 'order', 'read', order, 'read', ); const proposed = { items: [{ price: 0, sku: 'a' }] }; const write = access.canFields( { id: 'u1' }, 'order', 'update', order, 'write', proposed, ); expect(read.fields['items']).toBe('denied'); expect(read.reasons['items']).toBe('not-traversable'); expect(pickAllowedFields(read, order)).toEqual({ id: 'o1' }); expect(write.reasons['items']).toBe('not-traversable'); expect(pickAllowedFields(write, proposed)).toEqual({}); }, );

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-023 — A write that replaces a subtree holding a key the caller may not write, such as `address: null` under `!address.street`

CWE-915, CWE-1321

The parent is denied with not-traversable and nothing of it is picked. The same holds for a path-shaped raw key and a stored value with a key the caller may not write. A raw write key whose split holds a constructor or prototype segment, such as constructor.prototype.isAdmin, is refused with not-traversable, because a merge that splits it walks to a prototype (CWE-1321).

The test that proves it
/** * `address: null` would erase a street the caller may not write, so the * parent is refused as a whole and nothing of it is picked. */ const access = () => foreign([ permission('order', 'update', { rules: [always], fields: { fields: ['*', '!address.street'] }, }), ]); const stored = { id: 'o1', address: { city: 'Malmo', street: 'Storgatan 1' }, }; it.each([ ['null', null], ['a string', 'elsewhere'], ['an array', []], ['an empty object', {}], ])('refuses a write that replaces the address with %s', (_, value) => { const proposed = { address: value }; const decision = access().canFields( { id: 'u1' }, 'order', 'update', stored, 'write', proposed, ); expect(decision.fields['address']).toBe('denied'); expect(decision.reasons['address']).toBe('not-traversable'); expect(decision.allowed).toBe(false); expect(pickAllowedFields(decision, proposed)).toEqual({}); }); it('refuses a stored address the write would replace without its street', () => { const proposed = { address: { city: 'Lund' } }; const decision = access().canFields( { id: 'u1' }, 'order', 'update', stored, 'write', proposed, ); expect(decision.reasons['address']).toBe('not-traversable'); expect(pickAllowedFields(decision, proposed)).toEqual({}); }); /** * A deep merge or an ORM splits a raw key on `.`, so `customer.address` * writes the whole nested address and its street with it. */ it('refuses a raw key that spells the path of the replaced subtree', () => { const proposed = { 'customer.address': { city: 'Lund' } }; const decision = foreign([ permission('order', 'update', { rules: [always], fields: { fields: ['*', '!customer.address.street'] }, }), ]).canFields( { id: 'u1' }, 'order', 'update', { id: 'o1', customer: { address: { street: 'Storgatan 1' } } }, 'write', proposed, ); expect(decision.reasons['customer\\.address']).toBe('not-traversable'); expect(pickAllowedFields(decision, proposed)).toEqual({}); }); /** * A merge that splits `constructor.prototype.isAdmin` on `.` walks from the * row to `Object.prototype` and sets `isAdmin` on every object (CWE-1321). */ it.each([ ['constructor.prototype.isAdmin'], ['prototype.isAdmin'], ['a.constructor'], ])('refuses the raw write key %s', (key) => { const proposed = { [key]: true }; const decision = foreign([ permission('order', 'update', { rules: [always], fields: { fields: ['*'] }, }), ]).canFields( { id: 'u1' }, 'order', 'update', { id: 'o1' }, 'write', proposed, ); const column = key.replaceAll('.', '\\.'); expect(decision.fields[column]).toBe('denied'); expect(decision.reasons[column]).toBe('not-traversable'); expect(pickAllowedFields(decision, proposed)).toEqual({}); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-024 — A value the engine cannot walk (a `Date`, a class instance, an accessor, a primitive) under a rule that descends into it

CWE-863

The value is denied with not-traversable and pickAllowedFields leaves it out, so it is never returned with the excluded key inside.

The test that proves it
/** * A `Date`, a class instance or an accessor under a rule that descends into * it cannot be inspected for the excluded key. The engine hides the value * rather than return it with the key inside. */ class Address { constructor(readonly street: string) {} } const readable = () => foreign([ permission('order', 'read', { rules: [always], fields: { fields: ['*', '!address.street'] }, }), ]); it.each([ ['a Date', () => new Date(0)], ['a class instance', () => new Address('Storgatan 1')], [ 'an accessor', () => Object.defineProperty({}, 'street', { enumerable: true, get: () => 'Storgatan 1', }), ], ['a primitive', () => 'Storgatan 1'], ])('hides an address held as %s', (_, make) => { const row = { id: 'o1', address: make() }; const decision = readable().canFields( { id: 'u1' }, 'order', 'read', row, 'read', ); expect(decision.fields['address']).toBe('denied'); expect(decision.reasons['address']).toBe('not-traversable'); expect(pickAllowedFields(decision, row)).toEqual({ id: 'o1' }); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

Refused at construction

prevented

SEC-002 — Fail-open on untrusted configuration: an absent or null `when`

CWE-1188, CWE-276

Construction throws on a when that is absent, null or not a list, and an unconditional grant is spelled as an empty list, the form that survives a JSON round trip.

The test that proves it
const withWhen = (node: Record<string, unknown>) => foreign([ raw({ key: 'listing.read', object: 'listing', action: 'read', rules: [node], }), ]); it('refuses a rule with no when', () => { expect(() => withWhen({})).toThrow(InvalidRuleError); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-003 — Unreachable permission: a key that disagrees with its object and action

CWE-566

Construction throws on a key that differs from object.action and on a delimiter in either part.

The test that proves it
it('refuses a key that disagrees with object and action', () => { expect(() => foreign([ raw({ key: 'listing.read', object: 'listing', action: 'delete', rules: [always], }), ]), ).toThrow(KeyMismatchError); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-009 — Operator and value confusion in a condition

CWE-20

An operand the engine would ignore is refused at construction rather than dropped.

The test that proves it
const cases: readonly (readonly [string, Record<string, unknown>])[] = [ [ 'a membership test without a list', { field: 'subject.roles', op: 'in', value: 'owner' }, ], [ 'a membership test over an object', { field: 'subject.roles', op: 'not-in', value: { owner: true } }, ], [ 'an equality with two comparands', { field: 'subject.id', op: 'eq', path: 'object.customerId', value: 'u1' }, ], ['an equality with no comparand', { field: 'subject.id', op: 'eq' }], [ 'a path on an operator that reads a literal', { field: 'subject.roles', op: 'contains', path: 'subject.id' }, ], [ 'a containment test with no value', { field: 'subject.roles', op: 'contains' }, ], ['a clock read by a value operator', { field: 'now', op: 'eq', value: 1 }], [ 'a value path read by a clock operator', { field: 'subject.at', op: 'before', value: 1 }, ], [ 'a clock boundary that is not an instant', { field: 'now', op: 'before', value: { at: 1 } }, ], [ 'a path below its scope', { field: 'subject.profile.role', op: 'eq', value: 'owner' }, ], [ 'a path naming no scope', { field: 'session.role', op: 'eq', value: 'owner' }, ], [ 'a comparand path naming no scope', { field: 'subject.id', op: 'eq', path: 'window.id' }, ], [ 'a condition that is not an object', { field: 'subject.id', op: 'eq', value: 'u1' }, ], ]; for (const [name, condition] of cases.slice(0, -1)) { it(`refuses ${name}`, () => { expect(() => foreign([ permission('listing', 'read', { rules: [when(condition as unknown as Condition)], }), ]), ).toThrow(InvalidConditionError); }); } it('refuses a condition that is not an object', () => { for (const node of [null, 'subject.id', 42, []]) { expect(() => foreign([ raw({ key: 'listing.read', object: 'listing', action: 'read', rules: [{ when: [node] }], }), ]), ).toThrow(InvalidConditionError); } });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-015 — Resource exhaustion through matrix shape

CWE-674, CWE-400

The freeze walk is iterative, and construction throws on a value nested deeper than the copy walks.

The test that proves it
it('refuses a value nested deeper than the copy walks', () => { let deep: Record<string, unknown> = {}; const root = deep; for (let i = 0; i < 100_000; i++) { const next: Record<string, unknown> = {}; deep['n'] = next; deep = next; } expect(() => foreign([ permission('listing', 'read', { rules: [when({ field: 'subject.id', op: 'eq', value: root })], }), ]), ).toThrow(InvalidMatrixError); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-020 — A document that is not an envelope reaching the engine

CWE-20

A missing or non-array permissions, a version that is neither a string nor a number, a schema that is not one, a declared type that is not one, and a bare list with no envelope at all are all construction errors.

The test that proves it
it('refuses a bare list of permissions, which carries no envelope at all', () => { expect(() => parseMatrix([ permission('listing', 'read', { rules: [always] }), ] as unknown as Parameters<typeof parseMatrix>[0]), ).toThrow(AclConfigError); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

prevented

SEC-021 — A write into a frozen matrix: a `Date`, `Map` or `Set` the freeze did not cover

CWE-915, CWE-471

Object.freeze covers no internal slot, so the walk seals every mutator of a Date, Map and Set with an own property that throws before it freezes the object.

The test that proves it
/** * `Object.freeze` locks own properties and covers no internal slot, so a * frozen `Date` still answers `setTime`. A window boundary is a `Date` the * matrix holds, `Access.matrix` is a public getter, and `toEpoch` reads * `getTime()` on every evaluation, so a moved instant would decide every * later request while `Object.isFrozen` answered true throughout. */ const sale = () => local([ permission('listing', 'read', { rules: [ when({ field: 'now', op: 'after', value: new Date('2030-01-01T00:00:00Z'), }), ], }), ]); const boundaryOf = (access: ReturnType<typeof local>): Date => ( access.matrix.permissions[0]?.rules?.[0]?.when?.[0] as unknown as { value: Date; } ).value; it('refuses setTime on a window boundary, and the decision does not move', () => { const access = sale(); const before = access.can({ id: 'u1' }, 'listing', 'read', { id: 'l1' }); expect(before.allowed).toBe(false); const boundary = boundaryOf(access); expect(Object.isFrozen(boundary)).toBe(true); expect(() => boundary.setTime(0)).toThrow(TypeError); expect(boundary.toISOString()).toBe('2030-01-01T00:00:00.000Z'); expect(access.can({ id: 'u1' }, 'listing', 'read', { id: 'l1' })).toEqual( before, ); }); it('refuses every mutator a Date carries, not only setTime', () => { const boundary = boundaryOf(sale()); for (const name of Object.getOwnPropertyNames(Date.prototype).filter((n) => n.startsWith('set'), )) { expect( () => (boundary as unknown as Record<string, () => void>)[name]?.(), `${name} must refuse`, ).toThrow(TypeError); } }); it('refuses the mutators a Map and a Set carry in a condition value', () => { const access = local([ permission('listing', 'read', { rules: [ when({ field: 'subject.tier', op: 'eq', value: new Map([['pro', 1]]) as unknown as string, }), ], }), permission('listing', 'write', { rules: [ when({ field: 'subject.tier', op: 'eq', value: new Set(['pro']) as unknown as string, }), ], }), ]); const held = access.matrix.permissions.map( (each) => (each.rules?.[0]?.when?.[0] as unknown as { value: unknown }).value, ); const [map, set] = held as [Map<string, number>, Set<string>]; expect(Object.isFrozen(map)).toBe(true); expect(() => map.set('free', 1)).toThrow(TypeError); expect(() => map.delete('pro')).toThrow(TypeError); expect(() => map.clear()).toThrow(TypeError); expect(() => set.add('free')).toThrow(TypeError); expect(() => set.delete('pro')).toThrow(TypeError); expect(() => set.clear()).toThrow(TypeError); expect(map.get('pro')).toBe(1); expect(set.has('pro')).toBe(true); });

libs/acl/src/security/tier1-prevented.test.ts, run by npx nx test @evanion/acl.

What the register cannot show

@evanion/acl’s register tests only the attacks it names, and only inside the evaluator, so an application built on it has attack surface no case reaches.

The register covers the classes somebody named

Each entry is a class somebody named, and a class nobody named has no entry and no test. SEC-019 comes closest to an exception, drawing 400 writes from a seeded generator. It still draws them from key shapes somebody chose, against a property somebody stated.

The register describes one library at one layer

The eight tier 3 entries are what the acl security contract spells out. SEC-205 pins that one call returns the same decision wherever it runs. In a browser that decision hides a control, and a request sent without the control still reaches the server, so only the server’s decision guards the write. The tier 3 entries list what your application does itself: resolve the subject from a verified session, call can on every path, re-check inside the write’s transaction, and take the clock from the server.

How much of the library the suite runs

What coverage measures gives @evanion/acl’s statement and branch coverage beside every other library’s, and names the acl files whose branches no test took.

Last updated on