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/securityThe 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 aslisting.read, fromobject, 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 asversion, which names the matrix’s revision so a holder can tell a stale copy, andschema, which declares each object kind’s fields and their types.foreign(permissions, options, envelope)hands the matrix toparseMatrix, the entry point for a matrix somebody else wrote. A permission key the matrix does not declare gets a refusal, a decision whoseallowedisfalse.local(permissions, options, envelope)hands it tohydratePolicy, 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.optionsis anAccessOptions, the evaluator’s own settings. Itsversionoverrides the version the matrix carries.- A rule’s
whenmember lists its conditions, and the rule matches when all of them hold.when(...conditions)builds such a rule, andalways, a rule with an emptywhen, 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)isJSON.parse(text), which is how a foreign write arrives carrying an own__proto__key.countingSubject(attributes)returns{ subject, reads, reset }.subjectcounts every read of its attributes,reads()returns the count, andreset()sets it to zero.new Gen(rng(seed)), fromgenerator.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 tomaxitems, andgen.bool(chance)draws a boolean that istruewith that chance.ruleId(rule, side)is the identifier a decision’srulefield carries for that rule, wheresideis'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:
eqholds when the two sides are equal, with no type coercion.inandnot-inhold when the field’s value is, or is not, in the listvalue.containsholds when the field’s value is a list that holdsvalue.beforeandafterhold whennowis before or after the instantvalue.
Terms the cases use
- A permission’s deny rules sit in
denyRules, beside its allow rules inrules. 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!roleis an exclusion. Both are syntax, and neither is ever a field name. fieldscan also be an object. Itsfieldsmember holds the list, and every other member names a field and a config for it.targetslists the values a write may set on the field, andtransitionsmaps 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
unevaluablein 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.missingnames the fields to fetch.
A decision’s reason in these cases is one of five values:
reason | When |
|---|---|
denied | a deny rule matched |
no-rule-matched | no allow rule matched |
unknown-action | the matrix declares no permission for the action |
unevaluable | a rule read a field the projection lacks, as above |
unusable-clock | a 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.nowis 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.axisis'read'for the fields ofrow, or'write'for the fields ofproposed, the write a caller submitted. Its result carriesfields, which marks each field'allowed','denied'or'unevaluable', andreasons, which names why per field.actionis the decision on the action alone, andallowedis 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 itscan(kind, action)takes no subject.access.matrixis the frozen matrix the evaluator decides from, andaccess.versionis 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
InvalidRuleErrorthatforeignorlocalthrows while it builds the evaluator. - Tier 2, primitive supplied.
@evanion/aclsupplies the primitive that defends against the attack, such aspickAllowedFieldsoraccess.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.
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.
- Tier 3 leads, because an entry stating that no defence exists is the hardest to fake.
- Tier 2 follows, because a primitive your code skips is the mistake you are most likely to make.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
0 cases: 0 prevented, 0 primitive supplied, 0 out of scope. Name an identifier directly, such as sec-201, or clear the text and pick a tier.
Out of scope, published as a non-defence
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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 = 'role'; // 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.
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.
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.
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
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.
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.
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.
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.
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',
'!*',
'role',
'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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.