@nage-api/testing 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ /**
3
+ * The authorization-bypass pack (PLAN.md §15.2, §19).
4
+ *
5
+ * Every case here is an attack that works against a plausible implementation.
6
+ * They are collected in the kit rather than in `@nage-api/auth` so that anything
7
+ * else making an access decision — a realtime room policy, a driver's ownership
8
+ * scoping, a generated resource's guard — is held to the same standard.
9
+ *
10
+ * The harness is deliberately abstract: `attempt(credential, target)` returns
11
+ * whether access was granted. That reduces every authorization surface to the
12
+ * one question the pack cares about, and keeps the pack free of HTTP.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.describeAuthzBypass = void 0;
16
+ /**
17
+ * The ways a grant string can look like another one.
18
+ *
19
+ * Each is a comparison someone has actually written: `includes` instead of
20
+ * `===`, a case-insensitive compare, a value that survived a `trim()`, and a
21
+ * prefix that a `startsWith`/`endsWith` check accepts.
22
+ */
23
+ function lookAlikesOf(grant) {
24
+ return [`${grant}-readonly`, grant.toUpperCase(), ` ${grant}`, `x${grant}`];
25
+ }
26
+ const LOOK_ALIKE_VARIANTS = 4;
27
+ const describeAuthzBypass = (api, harness) => {
28
+ const { describe, it, expect } = api;
29
+ describe(`${harness.name}: authorization bypass`, () => {
30
+ it('should allow the principal that legitimately has access', async () => {
31
+ // The control. Without it, an implementation that denies everything would
32
+ // pass every other case in the pack.
33
+ const result = await harness.attempt(harness.authorised, harness.target);
34
+ expect(result.allowed).toBe(true);
35
+ });
36
+ it('should refuse a request with no credential', async () => {
37
+ const result = await harness.attempt(undefined, harness.target);
38
+ expect(result.allowed).toBe(false);
39
+ });
40
+ it('should refuse a principal without the required grant', async () => {
41
+ const result = await harness.attempt(harness.unauthorised, harness.target);
42
+ expect(result.allowed).toBe(false);
43
+ });
44
+ it('should refuse a principal with no roles and no permissions', async () => {
45
+ const result = await harness.attempt({ ...harness.unauthorised, roles: [], permissions: [] }, harness.target);
46
+ expect(result.allowed).toBe(false);
47
+ });
48
+ /**
49
+ * Whether the grants on the credential are what this surface compares.
50
+ *
51
+ * A surface that re-derives grants from a store — a resolver in front of a
52
+ * guard — cannot be attacked by varying the credential's grants at all, and
53
+ * that immunity is exactly what the forged-claims case asserts. Probing for
54
+ * it keeps the look-alike cases from reporting a bypass the shape of the
55
+ * surface makes impossible: strip every grant from an otherwise authorised
56
+ * principal, and a surface that still allows was never reading them.
57
+ */
58
+ const readsGrantsFromCredential = async () => {
59
+ const stripped = await harness.attempt({ ...harness.authorised, roles: [], permissions: [] }, harness.target);
60
+ return !stripped.allowed;
61
+ };
62
+ it('should not accept a role that merely looks like the required one', async () => {
63
+ // Substring and case matching are the two ways a grant check is written
64
+ // wrong: `admin` should not be satisfied by `administrator-readonly`,
65
+ // `ADMIN`, or ` admin`.
66
+ //
67
+ // The impostor inherits the *authorised* identity, not the unauthorised
68
+ // one. That matters more than it looks: a guard that consults a store as
69
+ // well as the claims refuses an unauthorised id on the store check, so the
70
+ // grant comparison is never reached and the case passes for the wrong
71
+ // reason — while the same guard still grants `admin` to a stored principal
72
+ // whose role is `administrator-readonly`.
73
+ if (!(await readsGrantsFromCredential()))
74
+ return;
75
+ const rolesAlone = await harness.attempt({ ...harness.authorised, permissions: [] }, harness.target);
76
+ // Permissions are cleared for the same reason: an implementation that
77
+ // grants on either channel would be satisfied by the permission and never
78
+ // compare the role. If roles alone do not grant, there is no role
79
+ // comparison to attack here — the combined case below covers that guard.
80
+ if (!rolesAlone.allowed)
81
+ return;
82
+ for (const role of harness.authorised.roles) {
83
+ for (const impostor of lookAlikesOf(role)) {
84
+ const result = await harness.attempt({ ...harness.authorised, roles: [impostor], permissions: [] }, harness.target);
85
+ expect(result.allowed).toBe(false);
86
+ }
87
+ }
88
+ });
89
+ it('should not accept a permission that merely looks like the required one', async () => {
90
+ if (!(await readsGrantsFromCredential()))
91
+ return;
92
+ const permissionsAlone = await harness.attempt({ ...harness.authorised, roles: [] }, harness.target);
93
+ if (!permissionsAlone.allowed)
94
+ return;
95
+ for (const permission of harness.authorised.permissions) {
96
+ for (const impostor of lookAlikesOf(permission)) {
97
+ const result = await harness.attempt({ ...harness.authorised, roles: [], permissions: [impostor] }, harness.target);
98
+ expect(result.allowed).toBe(false);
99
+ }
100
+ }
101
+ });
102
+ it('should not accept look-alikes of every grant at once', async () => {
103
+ // Valid whatever the guard's semantics, and never vacuous: the control case
104
+ // has already established this principal is otherwise allowed, and every
105
+ // grant it holds is replaced by a look-alike here. This is the case that
106
+ // still bites for a guard requiring a role *and* a permission, where each
107
+ // single-channel probe above is inconclusive.
108
+ if (!(await readsGrantsFromCredential()))
109
+ return;
110
+ for (let variant = 0; variant < LOOK_ALIKE_VARIANTS; variant += 1) {
111
+ const result = await harness.attempt({
112
+ ...harness.authorised,
113
+ roles: harness.authorised.roles.map((role) => lookAlikesOf(role)[variant] ?? role),
114
+ // `Permission` is a `${string}:${string}` template type, and a
115
+ // look-alike deliberately violates that shape — which is the point of
116
+ // the case, so the cast is the assertion.
117
+ permissions: harness.authorised.permissions.map((permission) => (lookAlikesOf(permission)[variant] ?? permission)),
118
+ }, harness.target);
119
+ expect(result.allowed).toBe(false);
120
+ }
121
+ });
122
+ it('should not name the missing grant in the refusal', async () => {
123
+ // Telling a caller which role they lack maps the permission model out for
124
+ // them one request at a time.
125
+ const result = await harness.attempt(harness.unauthorised, harness.target);
126
+ const message = result.message ?? '';
127
+ for (const role of harness.authorised.roles)
128
+ expect(message).not.toContain(role);
129
+ for (const permission of harness.authorised.permissions) {
130
+ expect(message).not.toContain(permission);
131
+ }
132
+ });
133
+ it('should not name the target in the refusal', async () => {
134
+ // Which resources exist is not something an unauthorised caller may
135
+ // enumerate by reading error messages.
136
+ const result = await harness.attempt(harness.unauthorised, harness.target);
137
+ expect(result.message ?? '').not.toContain(harness.target);
138
+ });
139
+ const forgedClaims = harness.forgedClaims;
140
+ if (forgedClaims !== undefined) {
141
+ it('should ignore claims the store does not back', async () => {
142
+ // A signed token proves who you are, not what you may still do. This is
143
+ // the case that separates checking the token from checking the truth.
144
+ const result = await harness.attempt(forgedClaims, harness.target);
145
+ expect(result.allowed).toBe(false);
146
+ });
147
+ }
148
+ const ownTarget = harness.ownTarget;
149
+ if (ownTarget !== undefined) {
150
+ it('should allow a principal to reach what they own', async () => {
151
+ const result = await harness.attempt(harness.unauthorised, ownTarget);
152
+ expect(result.allowed).toBe(true);
153
+ });
154
+ it('should refuse a principal reaching somebody else’s resource', async () => {
155
+ const result = await harness.attempt(harness.unauthorised, harness.target);
156
+ expect(result.allowed).toBe(false);
157
+ });
158
+ }
159
+ });
160
+ };
161
+ exports.describeAuthzBypass = describeAuthzBypass;
162
+ //# sourceMappingURL=authz-bypass.pack.js.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The error non-leakage pack (PLAN.md §17, §19).
3
+ *
4
+ * §17's whole premise is two audiences: the client gets a code and a safe
5
+ * message, the log gets everything. The defect that premise exists to prevent is
6
+ * the legacy `ErrorResponse`, which echoed `error.message` on a 500 and shipped
7
+ * SQL and upstream payloads to callers.
8
+ *
9
+ * A package registers this pack against its own error types, and the pack
10
+ * asserts the separation holds — including for the cases that are easy to get
11
+ * right once and lose later: a `cause` chain, a `meta` bag, and a nested detail.
12
+ */
13
+ import type { Pack } from '../test-api.js';
14
+ /** What a package supplies to run the pack. */
15
+ export interface ErrorNonLeakageHarness {
16
+ /** Name used in the suite description, e.g. `@nage-api/core errors`. */
17
+ readonly name: string;
18
+ /**
19
+ * Build an error that carries every sensitive channel at once: an operator
20
+ * detail, a `meta` bag and a `cause`.
21
+ */
22
+ buildLeakyError(secrets: {
23
+ detail: string;
24
+ metaValue: string;
25
+ causeMessage: string;
26
+ }): unknown;
27
+ /** Project the error to what a client would receive. */
28
+ toClientPayload(error: unknown): unknown;
29
+ /** The client-safe message, if the implementation exposes one separately. */
30
+ safeMessageOf?(error: unknown): string;
31
+ /**
32
+ * Serialise the error the way the application's logger would.
33
+ *
34
+ * Optional: when supplied, the pack additionally asserts the operator *does*
35
+ * get the detail — a redaction that swallowed everything would otherwise pass
36
+ * every leak test while making the system undebuggable.
37
+ */
38
+ toLogRecord?(error: unknown): unknown;
39
+ }
40
+ /**
41
+ * Register the pack.
42
+ *
43
+ * ```ts
44
+ * describeErrorNonLeakage({ describe, it, beforeEach, afterEach, expect }, harness);
45
+ * ```
46
+ */
47
+ export declare const describeErrorNonLeakage: Pack<ErrorNonLeakageHarness>;
48
+ //# sourceMappingURL=error-non-leakage.pack.d.ts.map
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ /**
3
+ * The error non-leakage pack (PLAN.md §17, §19).
4
+ *
5
+ * §17's whole premise is two audiences: the client gets a code and a safe
6
+ * message, the log gets everything. The defect that premise exists to prevent is
7
+ * the legacy `ErrorResponse`, which echoed `error.message` on a 500 and shipped
8
+ * SQL and upstream payloads to callers.
9
+ *
10
+ * A package registers this pack against its own error types, and the pack
11
+ * asserts the separation holds — including for the cases that are easy to get
12
+ * right once and lose later: a `cause` chain, a `meta` bag, and a nested detail.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.describeErrorNonLeakage = void 0;
16
+ const no_leak_js_1 = require("./no-leak.js");
17
+ const SECRETS = {
18
+ detail: 'postgres://app:Sup3rSecretPassw0rd@db.internal:5432/app',
19
+ metaValue: 'AKIA-EXAMPLE-ACCESS-KEY-ID',
20
+ causeMessage: 'ORA-00933: SQL command not properly ended near TABLE users',
21
+ };
22
+ /**
23
+ * Register the pack.
24
+ *
25
+ * ```ts
26
+ * describeErrorNonLeakage({ describe, it, beforeEach, afterEach, expect }, harness);
27
+ * ```
28
+ */
29
+ const describeErrorNonLeakage = (api, harness) => {
30
+ const { describe, it, expect } = api;
31
+ describe(`${harness.name}: error non-leakage`, () => {
32
+ it('should keep the operator detail out of the client payload', () => {
33
+ const error = harness.buildLeakyError(SECRETS);
34
+ // The specific defect: the legacy envelope echoed `error.message`.
35
+ (0, no_leak_js_1.expectNoSecrets)(harness.toClientPayload(error), [SECRETS.detail], 'client payload');
36
+ });
37
+ it('should keep the meta bag out of the client payload', () => {
38
+ const error = harness.buildLeakyError(SECRETS);
39
+ (0, no_leak_js_1.expectNoSecrets)(harness.toClientPayload(error), [SECRETS.metaValue], 'client payload');
40
+ });
41
+ it('should keep the cause chain out of the client payload', () => {
42
+ // A cause is the easiest channel to forget: it is not an own property, so
43
+ // a redaction written against `Object.entries` misses it entirely.
44
+ const error = harness.buildLeakyError(SECRETS);
45
+ (0, no_leak_js_1.expectNoSecrets)(harness.toClientPayload(error), [SECRETS.causeMessage], 'client payload');
46
+ });
47
+ it('should leak nothing when every channel is checked at once', () => {
48
+ const error = harness.buildLeakyError(SECRETS);
49
+ const result = (0, no_leak_js_1.scanForLeaks)(harness.toClientPayload(error), Object.values(SECRETS));
50
+ expect(result.leaked).toBe(false);
51
+ });
52
+ it('should still expose a client-safe message', () => {
53
+ // A payload with no message at all is not a pass: a client needs
54
+ // something to show, and "" invites callers to fall back to the code.
55
+ const error = harness.buildLeakyError(SECRETS);
56
+ const payload = harness.toClientPayload(error);
57
+ expect(typeof payload.message).toBe('string');
58
+ expect(payload.message.length).toBeGreaterThan(0);
59
+ expect(typeof payload.code).toBe('string');
60
+ });
61
+ if (harness.safeMessageOf !== undefined) {
62
+ it('should keep the safe message free of secrets too', () => {
63
+ const error = harness.buildLeakyError(SECRETS);
64
+ // Called on the harness rather than pulled into a local, so an
65
+ // implementation whose `safeMessageOf` reads `this` still works.
66
+ (0, no_leak_js_1.expectNoSecrets)(harness.safeMessageOf?.(error), Object.values(SECRETS), 'safe message');
67
+ });
68
+ }
69
+ if (harness.toLogRecord !== undefined) {
70
+ it('should give the operator the detail the client was denied', () => {
71
+ // The other half of §17. A redaction that dropped everything would pass
72
+ // every test above while making production undebuggable.
73
+ const error = harness.buildLeakyError(SECRETS);
74
+ const logged = (0, no_leak_js_1.scanForLeaks)(harness.toLogRecord?.(error), [SECRETS.detail], 'log record');
75
+ expect(logged.leaked).toBe(true);
76
+ });
77
+ }
78
+ });
79
+ };
80
+ exports.describeErrorNonLeakage = describeErrorNonLeakage;
81
+ //# sourceMappingURL=error-non-leakage.pack.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Asserting that a value does not carry a secret (PLAN.md §19: "error
3
+ * non-leakage"; §12, §17).
4
+ *
5
+ * This is the single most reusable assertion in the kit, because the same defect
6
+ * appears everywhere: a password in a validation error, a connection string in a
7
+ * health response, a token in a log line, a refresh token in an audit event.
8
+ * Each package has its own version of "check the error does not contain the
9
+ * password"; this is that check, written once and made thorough.
10
+ *
11
+ * Thorough means three things a hand-rolled `expect(x).not.toContain(secret)`
12
+ * usually misses:
13
+ *
14
+ * - **non-enumerable properties.** `Error.message` and `Error.stack` do not
15
+ * appear in `JSON.stringify(error)`, so the naive check passes on an error
16
+ * whose message is the password.
17
+ * - **encodings.** A secret can survive as base64 or URL-encoded text.
18
+ * - **cycles.** A serializer that throws on a cyclic object turns a security
19
+ * assertion into an unrelated failure.
20
+ */
21
+ /** Everything a value could be hiding a secret in. */
22
+ export interface LeakScanResult {
23
+ readonly leaked: boolean;
24
+ /** Which secrets were found, and where. */
25
+ readonly findings: readonly {
26
+ secret: string;
27
+ location: string;
28
+ encoding: string;
29
+ }[];
30
+ /** The text that was searched, for a failure message. */
31
+ readonly scanned: string;
32
+ }
33
+ /**
34
+ * Flatten anything into searchable text, including the parts `JSON.stringify`
35
+ * leaves out.
36
+ */
37
+ export declare function serialiseDeep(value: unknown, seen?: WeakSet<object>, depth?: number): string;
38
+ /**
39
+ * Scan a value for any of `secrets`.
40
+ *
41
+ * @param location a label used in the failure message, e.g. `response body`
42
+ */
43
+ export declare function scanForLeaks(value: unknown, secrets: readonly string[], location?: string): LeakScanResult;
44
+ /**
45
+ * Throw if `value` carries any of `secrets`.
46
+ *
47
+ * Framework-agnostic: it throws a plain `Error`, so it works in any runner and
48
+ * inside a pack that has no `expect`.
49
+ */
50
+ export declare function expectNoSecrets(value: unknown, secrets: readonly string[], location?: string): void;
51
+ /** Replace every encoding of every secret with a marker of the same shape. */
52
+ export declare function mask(text: string, secrets: readonly string[]): string;
53
+ /** Secrets that show up in almost every fixture; a convenience default. */
54
+ export declare const COMMON_TEST_SECRETS: readonly string[];
55
+ //# sourceMappingURL=no-leak.d.ts.map
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ /**
3
+ * Asserting that a value does not carry a secret (PLAN.md §19: "error
4
+ * non-leakage"; §12, §17).
5
+ *
6
+ * This is the single most reusable assertion in the kit, because the same defect
7
+ * appears everywhere: a password in a validation error, a connection string in a
8
+ * health response, a token in a log line, a refresh token in an audit event.
9
+ * Each package has its own version of "check the error does not contain the
10
+ * password"; this is that check, written once and made thorough.
11
+ *
12
+ * Thorough means three things a hand-rolled `expect(x).not.toContain(secret)`
13
+ * usually misses:
14
+ *
15
+ * - **non-enumerable properties.** `Error.message` and `Error.stack` do not
16
+ * appear in `JSON.stringify(error)`, so the naive check passes on an error
17
+ * whose message is the password.
18
+ * - **encodings.** A secret can survive as base64 or URL-encoded text.
19
+ * - **cycles.** A serializer that throws on a cyclic object turns a security
20
+ * assertion into an unrelated failure.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.COMMON_TEST_SECRETS = void 0;
24
+ exports.serialiseDeep = serialiseDeep;
25
+ exports.scanForLeaks = scanForLeaks;
26
+ exports.expectNoSecrets = expectNoSecrets;
27
+ exports.mask = mask;
28
+ /** Secrets shorter than this are too likely to appear by chance to check. */
29
+ const MIN_SECRET_LENGTH = 4;
30
+ /**
31
+ * Flatten anything into searchable text, including the parts `JSON.stringify`
32
+ * leaves out.
33
+ */
34
+ function serialiseDeep(value, seen = new WeakSet(), depth = 0) {
35
+ if (depth > 8)
36
+ return '';
37
+ if (value === null || value === undefined)
38
+ return String(value);
39
+ // Narrowed positively, one primitive kind at a time. A negative
40
+ // `typeof value !== 'object'` does not narrow `unknown` at all, so `String()`
41
+ // would still be receiving `unknown` — and `String(someSymbol)` throws a
42
+ // TypeError, which turns a leak assertion into an unrelated failure.
43
+ if (typeof value === 'string')
44
+ return value;
45
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
46
+ return String(value);
47
+ }
48
+ if (typeof value === 'symbol')
49
+ return value.toString();
50
+ if (typeof value === 'function')
51
+ return value.name;
52
+ if (typeof value !== 'object')
53
+ return '';
54
+ if (seen.has(value))
55
+ return '[cycle]';
56
+ seen.add(value);
57
+ if (value instanceof Error) {
58
+ // `message`, `stack` and `cause` are non-enumerable or symbol-adjacent, so
59
+ // a JSON round trip drops exactly the fields most likely to hold a leak.
60
+ const parts = [value.name, value.message, value.stack ?? ''];
61
+ const cause = value.cause;
62
+ if (cause !== undefined)
63
+ parts.push(serialiseDeep(cause, seen, depth + 1));
64
+ for (const [key, nested] of Object.entries(value)) {
65
+ parts.push(key, serialiseDeep(nested, seen, depth + 1));
66
+ }
67
+ return parts.join(' ');
68
+ }
69
+ if (Array.isArray(value)) {
70
+ return value.map((item) => serialiseDeep(item, seen, depth + 1)).join(' ');
71
+ }
72
+ if (value instanceof Map) {
73
+ return [...value.entries()]
74
+ .map(([key, nested]) => `${serialiseDeep(key, seen, depth + 1)} ${serialiseDeep(nested, seen, depth + 1)}`)
75
+ .join(' ');
76
+ }
77
+ if (value instanceof Set) {
78
+ return [...value].map((item) => serialiseDeep(item, seen, depth + 1)).join(' ');
79
+ }
80
+ return Object.entries(value)
81
+ .map(([key, nested]) => `${key} ${serialiseDeep(nested, seen, depth + 1)}`)
82
+ .join(' ');
83
+ }
84
+ /** Encodings a secret could survive in. */
85
+ function encodingsOf(secret) {
86
+ const variants = [{ encoding: 'plain', text: secret }];
87
+ variants.push({ encoding: 'base64', text: Buffer.from(secret, 'utf8').toString('base64') });
88
+ variants.push({ encoding: 'base64url', text: Buffer.from(secret, 'utf8').toString('base64url') });
89
+ const encoded = encodeURIComponent(secret);
90
+ if (encoded !== secret)
91
+ variants.push({ encoding: 'url', text: encoded });
92
+ return variants.filter((variant) => variant.text.length >= MIN_SECRET_LENGTH);
93
+ }
94
+ /**
95
+ * Scan a value for any of `secrets`.
96
+ *
97
+ * @param location a label used in the failure message, e.g. `response body`
98
+ */
99
+ function scanForLeaks(value, secrets, location = 'value') {
100
+ const scanned = serialiseDeep(value);
101
+ const findings = [];
102
+ for (const secret of secrets) {
103
+ if (secret.length < MIN_SECRET_LENGTH)
104
+ continue;
105
+ for (const variant of encodingsOf(secret)) {
106
+ if (scanned.includes(variant.text)) {
107
+ findings.push({ secret, location, encoding: variant.encoding });
108
+ }
109
+ }
110
+ }
111
+ return { leaked: findings.length > 0, findings, scanned };
112
+ }
113
+ /**
114
+ * Throw if `value` carries any of `secrets`.
115
+ *
116
+ * Framework-agnostic: it throws a plain `Error`, so it works in any runner and
117
+ * inside a pack that has no `expect`.
118
+ */
119
+ function expectNoSecrets(value, secrets, location = 'value') {
120
+ const result = scanForLeaks(value, secrets, location);
121
+ if (!result.leaked)
122
+ return;
123
+ const described = result.findings
124
+ .map((finding) => `${finding.secret.slice(0, 3)}… (${finding.encoding})`)
125
+ .join(', ');
126
+ throw new Error(`${location} leaked ${String(result.findings.length)} secret(s): ${described}. ` +
127
+ // The excerpt is masked first. A CI log is where secrets go to live
128
+ // forever, and a *failing* leak assertion must not be the thing that puts
129
+ // one there — which is exactly what printing the raw scanned text did.
130
+ `Searched text began: ${mask(result.scanned, secrets).slice(0, 200)}`);
131
+ }
132
+ /** Replace every encoding of every secret with a marker of the same shape. */
133
+ function mask(text, secrets) {
134
+ let masked = text;
135
+ for (const secret of secrets) {
136
+ if (secret.length < MIN_SECRET_LENGTH)
137
+ continue;
138
+ for (const variant of encodingsOf(secret)) {
139
+ masked = masked.split(variant.text).join(`[${variant.encoding}:redacted]`);
140
+ }
141
+ }
142
+ return masked;
143
+ }
144
+ /** Secrets that show up in almost every fixture; a convenience default. */
145
+ exports.COMMON_TEST_SECRETS = [
146
+ 'Correct-Horse-9',
147
+ 'changeme',
148
+ 'super-secret',
149
+ 'hunter2',
150
+ ];
151
+ //# sourceMappingURL=no-leak.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The query allow-list pack (PLAN.md §12, §14.2, §19).
3
+ *
4
+ * The query DSL is what made the legacy framework productive, and it was also
5
+ * the way a client could filter and sort by any column that existed —
6
+ * `password_hash`, `deleted_at`, another tenant's `user_id` — and ask for
7
+ * `limit: -1`. §12 makes both a per-model allow-list and an enforced `maxLimit`
8
+ * mandatory.
9
+ *
10
+ * A driver or a policy implementation registers this pack, and the pack tries
11
+ * every shape of bypass. That is the value of it living here: when someone finds
12
+ * a new bypass, adding one case protects every implementation at once.
13
+ */
14
+ import type { Pack } from '../test-api.js';
15
+ export interface QueryAllowListHarness {
16
+ readonly name: string;
17
+ /** A field the policy allows for filtering and sorting. */
18
+ readonly allowedField: string;
19
+ /** A field that exists on the model but must never be reachable. */
20
+ readonly forbiddenField: string;
21
+ /** The configured ceiling on `limit`. */
22
+ readonly maxLimit: number;
23
+ /**
24
+ * Parse a raw query as a client would have sent it.
25
+ *
26
+ * Should throw for anything the policy refuses; the pack asserts on *whether*
27
+ * it threw, not on the error's type, so it fits any implementation.
28
+ */
29
+ parse(raw: Record<string, unknown>): unknown;
30
+ /** Operators the policy allows on `allowedField`. */
31
+ readonly allowedOperators?: readonly string[];
32
+ /** Operators that must be refused, e.g. a raw-SQL escape hatch. */
33
+ readonly forbiddenOperators?: readonly string[];
34
+ }
35
+ export declare const describeQueryAllowList: Pack<QueryAllowListHarness>;
36
+ //# sourceMappingURL=query-allow-list.pack.d.ts.map
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ /**
3
+ * The query allow-list pack (PLAN.md §12, §14.2, §19).
4
+ *
5
+ * The query DSL is what made the legacy framework productive, and it was also
6
+ * the way a client could filter and sort by any column that existed —
7
+ * `password_hash`, `deleted_at`, another tenant's `user_id` — and ask for
8
+ * `limit: -1`. §12 makes both a per-model allow-list and an enforced `maxLimit`
9
+ * mandatory.
10
+ *
11
+ * A driver or a policy implementation registers this pack, and the pack tries
12
+ * every shape of bypass. That is the value of it living here: when someone finds
13
+ * a new bypass, adding one case protects every implementation at once.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.describeQueryAllowList = void 0;
17
+ const describeQueryAllowList = (api, harness) => {
18
+ const { describe, it, expect } = api;
19
+ const parses = (raw) => {
20
+ expect(() => harness.parse(raw)).not.toThrow();
21
+ };
22
+ const refuses = (raw) => {
23
+ expect(() => harness.parse(raw)).toThrow();
24
+ };
25
+ describe(`${harness.name}: query allow-list`, () => {
26
+ it('should accept an allowed field', () => {
27
+ parses({ where: { [harness.allowedField]: 'value' } });
28
+ });
29
+ it('should refuse a field outside the allow-list', () => {
30
+ // The legacy behaviour: any column that existed was filterable, including
31
+ // the ones holding credentials.
32
+ refuses({ where: { [harness.forbiddenField]: 'value' } });
33
+ });
34
+ it('should refuse a forbidden field in a sort', () => {
35
+ // Sorting leaks ordering information even when the column is not selected:
36
+ // sort by `password_hash` and you learn its distribution.
37
+ refuses({ sort: [{ field: harness.forbiddenField, direction: 'asc' }] });
38
+ });
39
+ it('should refuse a forbidden field nested inside a logical group', () => {
40
+ // A check that only looks at the top level of `where` misses this.
41
+ refuses({
42
+ where: {
43
+ and: [{ [harness.allowedField]: 'ok' }, { [harness.forbiddenField]: 'sneaky' }],
44
+ },
45
+ });
46
+ });
47
+ it('should refuse a forbidden field in a deeply nested group', () => {
48
+ refuses({
49
+ where: {
50
+ or: [{ and: [{ or: [{ [harness.forbiddenField]: 'deep' }] }] }],
51
+ },
52
+ });
53
+ });
54
+ it('should refuse an unbounded limit', () => {
55
+ // `limit: -1` was the legacy way to ask for the whole table.
56
+ refuses({ limit: -1 });
57
+ });
58
+ it('should refuse a limit above the ceiling', () => {
59
+ refuses({ limit: harness.maxLimit + 1 });
60
+ });
61
+ it('should accept a limit at the ceiling', () => {
62
+ // The boundary itself must be allowed, or the ceiling is off by one.
63
+ parses({ limit: harness.maxLimit });
64
+ });
65
+ it('should refuse a non-numeric limit', () => {
66
+ refuses({ limit: 'all' });
67
+ });
68
+ it('should refuse a negative offset', () => {
69
+ refuses({ offset: -5 });
70
+ });
71
+ it('should refuse a prototype-polluting field name', () => {
72
+ // `__proto__` as a filter key is how a where-clause builder that assigns
73
+ // into a fresh object ends up mutating `Object.prototype`.
74
+ refuses({ where: { __proto__: { polluted: true } } });
75
+ refuses({ where: { constructor: 'x' } });
76
+ });
77
+ for (const operator of harness.allowedOperators ?? []) {
78
+ it(`should accept the ${operator} operator on an allowed field`, () => {
79
+ parses({ where: { [harness.allowedField]: { [operator]: 'value' } } });
80
+ });
81
+ }
82
+ for (const operator of harness.forbiddenOperators ?? []) {
83
+ it(`should refuse the ${operator} operator`, () => {
84
+ refuses({ where: { [harness.allowedField]: { [operator]: 'value' } } });
85
+ });
86
+ }
87
+ });
88
+ };
89
+ exports.describeQueryAllowList = describeQueryAllowList;
90
+ //# sourceMappingURL=query-allow-list.pack.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The upload validation pack (PLAN.md §12, §19).
3
+ *
4
+ * A file arriving from a client carries three attacker-controlled claims — the
5
+ * name, the extension and the `Content-Type` — and one thing that is not a
6
+ * claim: the bytes. The pack asserts an implementation trusts only the bytes.
7
+ *
8
+ * The malicious matrix lives here rather than in `@nage-api/storage`'s own suite so
9
+ * that a second implementation (an S3 driver with its own pre-signed-upload
10
+ * validation, say) is held to the same standard rather than to a paraphrase.
11
+ */
12
+ import type { Pack } from '../test-api.js';
13
+ /** A file the pack will try to sneak past an implementation. */
14
+ export interface MaliciousFile {
15
+ readonly label: string;
16
+ readonly filename: string;
17
+ readonly contentType: string;
18
+ readonly content: Buffer;
19
+ readonly why: string;
20
+ }
21
+ export interface UploadValidationHarness {
22
+ readonly name: string;
23
+ /**
24
+ * Validate a file. Should throw for anything it refuses.
25
+ *
26
+ * The pack asserts on refusal, not on the error type, so any implementation
27
+ * fits.
28
+ */
29
+ validate(input: {
30
+ filename: string;
31
+ contentType: string;
32
+ content: Buffer;
33
+ }): unknown;
34
+ /** A file the implementation is configured to accept, for the control case. */
35
+ readonly accepted: {
36
+ filename: string;
37
+ contentType: string;
38
+ content: Buffer;
39
+ };
40
+ /** Extra cases specific to this implementation. */
41
+ readonly additional?: readonly MaliciousFile[];
42
+ }
43
+ /** Files that must never be accepted, whatever they claim to be. */
44
+ export declare const MALICIOUS_UPLOADS: readonly MaliciousFile[];
45
+ export declare const describeUploadValidation: Pack<UploadValidationHarness>;
46
+ //# sourceMappingURL=upload-validation.pack.d.ts.map