@nage-api/core 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.
- package/LICENSE +202 -0
- package/README.md +141 -0
- package/dist/bootstrap/bootstrap.d.ts +48 -0
- package/dist/bootstrap/bootstrap.js +255 -0
- package/dist/bootstrap/drain.d.ts +48 -0
- package/dist/bootstrap/drain.js +113 -0
- package/dist/bootstrap/lifecycle.d.ts +30 -0
- package/dist/bootstrap/lifecycle.js +64 -0
- package/dist/bootstrap/process-guards.d.ts +42 -0
- package/dist/bootstrap/process-guards.js +103 -0
- package/dist/bootstrap/query-parser.d.ts +34 -0
- package/dist/bootstrap/query-parser.js +37 -0
- package/dist/bootstrap/shutdown.d.ts +55 -0
- package/dist/bootstrap/shutdown.js +182 -0
- package/dist/constants.d.ts +32 -0
- package/dist/constants.js +48 -0
- package/dist/context/active-context.d.ts +23 -0
- package/dist/context/active-context.js +34 -0
- package/dist/context/request-context.middleware.d.ts +31 -0
- package/dist/context/request-context.middleware.js +95 -0
- package/dist/context/request-context.service.d.ts +29 -0
- package/dist/context/request-context.service.js +67 -0
- package/dist/decorators/owner.decorator.d.ts +18 -0
- package/dist/decorators/owner.decorator.js +31 -0
- package/dist/decorators/public.decorator.d.ts +13 -0
- package/dist/decorators/public.decorator.js +23 -0
- package/dist/decorators/version.decorators.d.ts +34 -0
- package/dist/decorators/version.decorators.js +40 -0
- package/dist/errors/catalog.d.ts +149 -0
- package/dist/errors/catalog.js +289 -0
- package/dist/errors/index.d.ts +3 -0
- package/dist/errors/index.js +22 -0
- package/dist/errors/nage.error.d.ts +43 -0
- package/dist/errors/nage.error.js +45 -0
- package/dist/guards/api-version.guard.d.ts +20 -0
- package/dist/guards/api-version.guard.js +73 -0
- package/dist/http/all-exceptions.filter.d.ts +25 -0
- package/dist/http/all-exceptions.filter.js +256 -0
- package/dist/http/envelope.d.ts +25 -0
- package/dist/http/envelope.js +44 -0
- package/dist/http/no-envelope.decorator.d.ts +11 -0
- package/dist/http/no-envelope.decorator.js +16 -0
- package/dist/http/request-timeout.decorators.d.ts +23 -0
- package/dist/http/request-timeout.decorators.js +29 -0
- package/dist/http/request-timeout.interceptor.d.ts +28 -0
- package/dist/http/request-timeout.interceptor.js +75 -0
- package/dist/http/response.interceptor.d.ts +19 -0
- package/dist/http/response.interceptor.js +73 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +135 -0
- package/dist/job/job.factory.d.ts +29 -0
- package/dist/job/job.factory.js +50 -0
- package/dist/logging/json.logger.d.ts +23 -0
- package/dist/logging/json.logger.js +136 -0
- package/dist/logging/nest-logger.adapter.d.ts +20 -0
- package/dist/logging/nest-logger.adapter.js +46 -0
- package/dist/module/core.module.d.ts +40 -0
- package/dist/module/core.module.js +112 -0
- package/dist/security/audit.d.ts +42 -0
- package/dist/security/audit.js +399 -0
- package/dist/security/index.d.ts +15 -0
- package/dist/security/index.js +50 -0
- package/dist/security/legacy-scan.d.ts +24 -0
- package/dist/security/legacy-scan.js +98 -0
- package/dist/security/random.d.ts +40 -0
- package/dist/security/random.js +87 -0
- package/dist/security/rate-limit.decorators.d.ts +24 -0
- package/dist/security/rate-limit.decorators.js +25 -0
- package/dist/security/rate-limit.guard.d.ts +44 -0
- package/dist/security/rate-limit.guard.js +130 -0
- package/dist/security/rate-limit.store.d.ts +30 -0
- package/dist/security/rate-limit.store.js +63 -0
- package/dist/security/redaction.d.ts +54 -0
- package/dist/security/redaction.js +146 -0
- package/dist/security/tls.d.ts +29 -0
- package/dist/security/tls.js +48 -0
- package/dist/tokens.d.ts +60 -0
- package/dist/tokens.js +89 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +8 -0
- package/package.json +77 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Source scanner for the insecure patterns the legacy framework shipped
|
|
4
|
+
* (PLAN.md §12, §23.3 — `nage doctor --legacy`).
|
|
5
|
+
*
|
|
6
|
+
* The config audit covers a `@nage-api` application. This covers the repository it
|
|
7
|
+
* is being migrated from, where the same problems live as literals in source:
|
|
8
|
+
* `rejectUnauthorized: false`, `Math.random()` for tokens, `synchronize: true`,
|
|
9
|
+
* an argument-less `enableCors()`, uuid v1, and shipped default credentials.
|
|
10
|
+
*
|
|
11
|
+
* It takes file contents rather than paths so it stays testable and free of any
|
|
12
|
+
* filesystem dependency; the CLI supplies the files.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.scanForLegacyPatterns = scanForLegacyPatterns;
|
|
16
|
+
const LEGACY_PATTERNS = [
|
|
17
|
+
{
|
|
18
|
+
pattern: /rejectUnauthorized\s*:\s*false/,
|
|
19
|
+
severity: 'critical',
|
|
20
|
+
message: 'TLS certificate verification is disabled, so the connection is interceptable.',
|
|
21
|
+
remediation: 'Remove it and declare `ssl: "verify-full"` in nage.config.ts.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
pattern: /Math\s*\.\s*random\s*\(/,
|
|
25
|
+
severity: 'high',
|
|
26
|
+
message: 'Math.random() is predictable and unsuitable for tokens, OTPs or ids.',
|
|
27
|
+
remediation: 'Use randomToken()/randomOtp() from @nage-api/core, which draw from node:crypto.',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
pattern: /\buuid\s*\.\s*v1\s*\(|\bv1\s*\(\s*\)\s*(?=[,;)])|require\(['"]uuid\/v1['"]\)/,
|
|
31
|
+
severity: 'medium',
|
|
32
|
+
message: 'uuid v1 encodes a timestamp and MAC address, so ids are guessable and identifying.',
|
|
33
|
+
remediation: 'Use randomId(), which returns a v4 UUID.',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pattern: /synchronize\s*:\s*true|alter\s*:\s*true/,
|
|
37
|
+
severity: 'high',
|
|
38
|
+
message: 'Schema auto-sync rewrites tables at boot; data loss is one model edit away.',
|
|
39
|
+
remediation: 'Generate a migration and apply it with `nage db migrate`.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
pattern: /enableCors\s*\(\s*\)/,
|
|
43
|
+
severity: 'critical',
|
|
44
|
+
message: 'enableCors() with no arguments allows every origin.',
|
|
45
|
+
remediation: 'Configure http.cors.origins with an explicit allow-list.',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pattern: /admin@admin\.com|['"]123456['"]/,
|
|
49
|
+
severity: 'critical',
|
|
50
|
+
message: 'A default credential is present in source.',
|
|
51
|
+
remediation: 'Remove it; seed an administrator with a generated password on first run.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
pattern: /jwt\s*:\s*\{[^}]*secret\s*:\s*['"][^'"]{0,15}['"]/s,
|
|
55
|
+
severity: 'critical',
|
|
56
|
+
message: 'A JWT secret is hard-coded and short.',
|
|
57
|
+
remediation: 'Load signing material from the environment or a secret provider.',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
pattern: /NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0/,
|
|
61
|
+
severity: 'critical',
|
|
62
|
+
message: 'TLS verification is disabled for the whole Node process.',
|
|
63
|
+
remediation: 'Remove it and trust the correct CA instead.',
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
/** Lines that are obviously commented out are not findings. */
|
|
67
|
+
function isCommented(line) {
|
|
68
|
+
const trimmed = line.trimStart();
|
|
69
|
+
return trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Scan files for legacy insecure patterns.
|
|
73
|
+
*
|
|
74
|
+
* @returns one finding per match, located as `path:line`.
|
|
75
|
+
*/
|
|
76
|
+
function scanForLegacyPatterns(files) {
|
|
77
|
+
const findings = [];
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const lines = file.content.split('\n');
|
|
80
|
+
lines.forEach((line, index) => {
|
|
81
|
+
if (isCommented(line))
|
|
82
|
+
return;
|
|
83
|
+
for (const legacy of LEGACY_PATTERNS) {
|
|
84
|
+
if (!legacy.pattern.test(line))
|
|
85
|
+
continue;
|
|
86
|
+
findings.push({
|
|
87
|
+
code: 'SEC_LEGACY_PATTERN',
|
|
88
|
+
severity: legacy.severity,
|
|
89
|
+
location: `${file.path}:${index + 1}`,
|
|
90
|
+
message: legacy.message,
|
|
91
|
+
remediation: legacy.remediation,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return findings;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=legacy-scan.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographically secure randomness (PLAN.md §12).
|
|
3
|
+
*
|
|
4
|
+
* The legacy framework built OTPs and tokens on `Math.random()` and used uuid
|
|
5
|
+
* v1 (which encodes a timestamp and MAC address). Both are predictable enough
|
|
6
|
+
* to guess. Everything here draws from `node:crypto`, and the shared ESLint
|
|
7
|
+
* config makes `Math.random()` an error so the old pattern cannot come back.
|
|
8
|
+
*/
|
|
9
|
+
/** Unambiguous alphabet: no `0`/`O`, no `1`/`l`/`I` — for codes people retype. */
|
|
10
|
+
export declare const UNAMBIGUOUS_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
11
|
+
export declare const ALPHANUMERIC_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
12
|
+
/** A v4 UUID. Unlike v1 it encodes neither a timestamp nor a MAC address. */
|
|
13
|
+
export declare function randomId(): string;
|
|
14
|
+
/** URL-safe random token of `byteLength` bytes of entropy (32 = 256 bits). */
|
|
15
|
+
export declare function randomToken(byteLength?: number): string;
|
|
16
|
+
/** Hex-encoded random token, for contexts that reject base64 characters. */
|
|
17
|
+
export declare function randomHex(byteLength?: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Random string over `alphabet`, drawn without modulo bias — `randomInt` rejects
|
|
20
|
+
* and re-draws rather than folding a range, which is where naive
|
|
21
|
+
* `bytes[i] % alphabet.length` implementations leak a skew.
|
|
22
|
+
*/
|
|
23
|
+
export declare function randomString(length: number, alphabet?: string): string;
|
|
24
|
+
/** Uniform integer in `[min, max)`. */
|
|
25
|
+
export declare function randomInt(min: number, max: number): number;
|
|
26
|
+
/**
|
|
27
|
+
* Numeric one-time code.
|
|
28
|
+
*
|
|
29
|
+
* Returned as a string so leading zeros survive — a numeric OTP that loses them
|
|
30
|
+
* silently shrinks the keyspace it advertises.
|
|
31
|
+
*/
|
|
32
|
+
export declare function randomOtp(length?: number): string;
|
|
33
|
+
/**
|
|
34
|
+
* Constant-time string comparison.
|
|
35
|
+
*
|
|
36
|
+
* Used for OTPs, API keys and token hashes: `a === b` returns as soon as it
|
|
37
|
+
* finds a difference, and that timing difference is measurable.
|
|
38
|
+
*/
|
|
39
|
+
export declare function secureCompare(left: string, right: string): boolean;
|
|
40
|
+
//# sourceMappingURL=random.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Cryptographically secure randomness (PLAN.md §12).
|
|
4
|
+
*
|
|
5
|
+
* The legacy framework built OTPs and tokens on `Math.random()` and used uuid
|
|
6
|
+
* v1 (which encodes a timestamp and MAC address). Both are predictable enough
|
|
7
|
+
* to guess. Everything here draws from `node:crypto`, and the shared ESLint
|
|
8
|
+
* config makes `Math.random()` an error so the old pattern cannot come back.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.ALPHANUMERIC_ALPHABET = exports.UNAMBIGUOUS_ALPHABET = void 0;
|
|
12
|
+
exports.randomId = randomId;
|
|
13
|
+
exports.randomToken = randomToken;
|
|
14
|
+
exports.randomHex = randomHex;
|
|
15
|
+
exports.randomString = randomString;
|
|
16
|
+
exports.randomInt = randomInt;
|
|
17
|
+
exports.randomOtp = randomOtp;
|
|
18
|
+
exports.secureCompare = secureCompare;
|
|
19
|
+
const node_crypto_1 = require("node:crypto");
|
|
20
|
+
/** Unambiguous alphabet: no `0`/`O`, no `1`/`l`/`I` — for codes people retype. */
|
|
21
|
+
exports.UNAMBIGUOUS_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
22
|
+
exports.ALPHANUMERIC_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
23
|
+
/** A v4 UUID. Unlike v1 it encodes neither a timestamp nor a MAC address. */
|
|
24
|
+
function randomId() {
|
|
25
|
+
return (0, node_crypto_1.randomUUID)();
|
|
26
|
+
}
|
|
27
|
+
/** URL-safe random token of `byteLength` bytes of entropy (32 = 256 bits). */
|
|
28
|
+
function randomToken(byteLength = 32) {
|
|
29
|
+
return (0, node_crypto_1.randomBytes)(byteLength).toString('base64url');
|
|
30
|
+
}
|
|
31
|
+
/** Hex-encoded random token, for contexts that reject base64 characters. */
|
|
32
|
+
function randomHex(byteLength = 32) {
|
|
33
|
+
return (0, node_crypto_1.randomBytes)(byteLength).toString('hex');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Random string over `alphabet`, drawn without modulo bias — `randomInt` rejects
|
|
37
|
+
* and re-draws rather than folding a range, which is where naive
|
|
38
|
+
* `bytes[i] % alphabet.length` implementations leak a skew.
|
|
39
|
+
*/
|
|
40
|
+
function randomString(length, alphabet = exports.ALPHANUMERIC_ALPHABET) {
|
|
41
|
+
if (length <= 0)
|
|
42
|
+
return '';
|
|
43
|
+
if (alphabet.length < 2) {
|
|
44
|
+
throw new RangeError('randomString needs an alphabet of at least two characters');
|
|
45
|
+
}
|
|
46
|
+
const characters = [];
|
|
47
|
+
for (let index = 0; index < length; index += 1) {
|
|
48
|
+
characters.push(alphabet.charAt((0, node_crypto_1.randomInt)(alphabet.length)));
|
|
49
|
+
}
|
|
50
|
+
return characters.join('');
|
|
51
|
+
}
|
|
52
|
+
/** Uniform integer in `[min, max)`. */
|
|
53
|
+
function randomInt(min, max) {
|
|
54
|
+
return (0, node_crypto_1.randomInt)(min, max);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Numeric one-time code.
|
|
58
|
+
*
|
|
59
|
+
* Returned as a string so leading zeros survive — a numeric OTP that loses them
|
|
60
|
+
* silently shrinks the keyspace it advertises.
|
|
61
|
+
*/
|
|
62
|
+
function randomOtp(length = 6) {
|
|
63
|
+
if (length < 4)
|
|
64
|
+
throw new RangeError('An OTP shorter than 4 digits is not acceptable');
|
|
65
|
+
let code = '';
|
|
66
|
+
for (let index = 0; index < length; index += 1)
|
|
67
|
+
code += String((0, node_crypto_1.randomInt)(10));
|
|
68
|
+
return code;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Constant-time string comparison.
|
|
72
|
+
*
|
|
73
|
+
* Used for OTPs, API keys and token hashes: `a === b` returns as soon as it
|
|
74
|
+
* finds a difference, and that timing difference is measurable.
|
|
75
|
+
*/
|
|
76
|
+
function secureCompare(left, right) {
|
|
77
|
+
const leftBuffer = Buffer.from(left, 'utf8');
|
|
78
|
+
const rightBuffer = Buffer.from(right, 'utf8');
|
|
79
|
+
// timingSafeEqual requires equal lengths, and the length itself is not secret;
|
|
80
|
+
// compare the buffer against itself so the work is done either way.
|
|
81
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
82
|
+
(0, node_crypto_1.timingSafeEqual)(leftBuffer, leftBuffer);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return (0, node_crypto_1.timingSafeEqual)(leftBuffer, rightBuffer);
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=random.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type CustomDecorator } from '@nestjs/common';
|
|
2
|
+
/** Per-route override of the global throttle. */
|
|
3
|
+
export interface RateLimitPolicy {
|
|
4
|
+
readonly limit: number;
|
|
5
|
+
readonly windowMs: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Tighten (or loosen) the limit for one route.
|
|
9
|
+
*
|
|
10
|
+
* The expensive and abusable endpoints — login, OTP send, password reset — get
|
|
11
|
+
* their own budget rather than sharing the global one (§12).
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* @RateLimit({ limit: 5, windowMs: 60_000 })
|
|
15
|
+
* @Post('auth/otp/send')
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare const RateLimit: (policy: RateLimitPolicy) => CustomDecorator;
|
|
19
|
+
/**
|
|
20
|
+
* Exempt a route entirely — health probes an orchestrator polls every second,
|
|
21
|
+
* and internal callbacks behind their own authentication.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SkipRateLimit: () => CustomDecorator;
|
|
24
|
+
//# sourceMappingURL=rate-limit.decorators.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SkipRateLimit = exports.RateLimit = void 0;
|
|
4
|
+
const common_1 = require("@nestjs/common");
|
|
5
|
+
const constants_js_1 = require("../constants.js");
|
|
6
|
+
/**
|
|
7
|
+
* Tighten (or loosen) the limit for one route.
|
|
8
|
+
*
|
|
9
|
+
* The expensive and abusable endpoints — login, OTP send, password reset — get
|
|
10
|
+
* their own budget rather than sharing the global one (§12).
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* @RateLimit({ limit: 5, windowMs: 60_000 })
|
|
14
|
+
* @Post('auth/otp/send')
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
const RateLimit = (policy) => (0, common_1.SetMetadata)(constants_js_1.METADATA_KEYS.rateLimit, policy);
|
|
18
|
+
exports.RateLimit = RateLimit;
|
|
19
|
+
/**
|
|
20
|
+
* Exempt a route entirely — health probes an orchestrator polls every second,
|
|
21
|
+
* and internal callbacks behind their own authentication.
|
|
22
|
+
*/
|
|
23
|
+
const SkipRateLimit = () => (0, common_1.SetMetadata)(constants_js_1.METADATA_KEYS.skipRateLimit, true);
|
|
24
|
+
exports.SkipRateLimit = SkipRateLimit;
|
|
25
|
+
//# sourceMappingURL=rate-limit.decorators.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The global throttle guard (PLAN.md §12).
|
|
3
|
+
*
|
|
4
|
+
* The legacy framework installed `@nestjs/throttler` but never registered a
|
|
5
|
+
* guard, so only one endpoint was ever limited and brute force was unimpeded.
|
|
6
|
+
* Here the guard is global: routes are limited unless they opt out, which is the
|
|
7
|
+
* direction that fails safe.
|
|
8
|
+
*/
|
|
9
|
+
import { type CanActivate, type ExecutionContext } from '@nestjs/common';
|
|
10
|
+
import { Reflector } from '@nestjs/core';
|
|
11
|
+
import type { NageCoreConfig, RateLimitStore } from '@nage-api/contracts';
|
|
12
|
+
import { RequestContextService } from '../context/request-context.service.js';
|
|
13
|
+
import type { RateLimitPolicy } from './rate-limit.decorators.js';
|
|
14
|
+
/** Defaults: 100 requests a minute per caller. */
|
|
15
|
+
export declare const DEFAULT_RATE_LIMIT: RateLimitPolicy;
|
|
16
|
+
interface RequestLike {
|
|
17
|
+
readonly ip?: string;
|
|
18
|
+
readonly url?: string;
|
|
19
|
+
readonly method?: string;
|
|
20
|
+
readonly socket?: {
|
|
21
|
+
readonly remoteAddress?: string;
|
|
22
|
+
};
|
|
23
|
+
readonly headers?: Record<string, string | string[] | undefined>;
|
|
24
|
+
}
|
|
25
|
+
export declare class RateLimitGuard implements CanActivate {
|
|
26
|
+
#private;
|
|
27
|
+
private readonly reflector;
|
|
28
|
+
private readonly context;
|
|
29
|
+
private readonly config;
|
|
30
|
+
private readonly store?;
|
|
31
|
+
constructor(reflector: Reflector, context: RequestContextService, config: NageCoreConfig, store?: RateLimitStore | undefined);
|
|
32
|
+
canActivate(execution: ExecutionContext): Promise<boolean>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Best-effort client address.
|
|
36
|
+
*
|
|
37
|
+
* `request.ip` already honours the trust-proxy setting, so it is preferred;
|
|
38
|
+
* `x-forwarded-for` is only consulted as a fallback and takes the left-most
|
|
39
|
+
* entry. An untrusted proxy header is spoofable, which is why trust-proxy is
|
|
40
|
+
* configured explicitly rather than assumed.
|
|
41
|
+
*/
|
|
42
|
+
export declare function clientIp(request: RequestLike): string;
|
|
43
|
+
export {};
|
|
44
|
+
//# sourceMappingURL=rate-limit.guard.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The global throttle guard (PLAN.md §12).
|
|
4
|
+
*
|
|
5
|
+
* The legacy framework installed `@nestjs/throttler` but never registered a
|
|
6
|
+
* guard, so only one endpoint was ever limited and brute force was unimpeded.
|
|
7
|
+
* Here the guard is global: routes are limited unless they opt out, which is the
|
|
8
|
+
* direction that fails safe.
|
|
9
|
+
*/
|
|
10
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
11
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
12
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
13
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
14
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
15
|
+
};
|
|
16
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
17
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
18
|
+
};
|
|
19
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
20
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
21
|
+
};
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.RateLimitGuard = exports.DEFAULT_RATE_LIMIT = void 0;
|
|
24
|
+
exports.clientIp = clientIp;
|
|
25
|
+
const common_1 = require("@nestjs/common");
|
|
26
|
+
const core_1 = require("@nestjs/core");
|
|
27
|
+
const constants_js_1 = require("../constants.js");
|
|
28
|
+
const request_context_service_js_1 = require("../context/request-context.service.js");
|
|
29
|
+
const catalog_js_1 = require("../errors/catalog.js");
|
|
30
|
+
const tokens_js_1 = require("../tokens.js");
|
|
31
|
+
/** Defaults: 100 requests a minute per caller. */
|
|
32
|
+
exports.DEFAULT_RATE_LIMIT = { limit: 100, windowMs: 60_000 };
|
|
33
|
+
let RateLimitGuard = class RateLimitGuard {
|
|
34
|
+
reflector;
|
|
35
|
+
context;
|
|
36
|
+
config;
|
|
37
|
+
store;
|
|
38
|
+
constructor(reflector, context, config, store) {
|
|
39
|
+
this.reflector = reflector;
|
|
40
|
+
this.context = context;
|
|
41
|
+
this.config = config;
|
|
42
|
+
this.store = store;
|
|
43
|
+
}
|
|
44
|
+
async canActivate(execution) {
|
|
45
|
+
if (execution.getType() !== 'http')
|
|
46
|
+
return true;
|
|
47
|
+
const settings = this.config.security?.rateLimit;
|
|
48
|
+
if (settings?.enabled === false || this.store === undefined)
|
|
49
|
+
return true;
|
|
50
|
+
const skip = this.reflector.getAllAndOverride(constants_js_1.METADATA_KEYS.skipRateLimit, [execution.getHandler(), execution.getClass()]);
|
|
51
|
+
if (skip === true)
|
|
52
|
+
return true;
|
|
53
|
+
const http = execution.switchToHttp();
|
|
54
|
+
const request = http.getRequest();
|
|
55
|
+
if (this.#isExcluded(request.url, settings?.excludePaths))
|
|
56
|
+
return true;
|
|
57
|
+
const policy = this.#resolvePolicy(execution, settings);
|
|
58
|
+
const key = this.#buildKey(execution, request, settings?.perUser ?? true);
|
|
59
|
+
const result = await this.store.consume(key, policy.limit, policy.windowMs);
|
|
60
|
+
const response = http.getResponse();
|
|
61
|
+
response.setHeader?.('ratelimit-limit', result.limit);
|
|
62
|
+
response.setHeader?.('ratelimit-remaining', result.remaining);
|
|
63
|
+
response.setHeader?.('ratelimit-reset', Math.ceil((result.resetAt - Date.now()) / 1000));
|
|
64
|
+
if (!result.allowed) {
|
|
65
|
+
response.setHeader?.('retry-after', result.retryAfterSeconds);
|
|
66
|
+
throw new catalog_js_1.RateLimitError(result.retryAfterSeconds, {
|
|
67
|
+
detail: `Rate limit of ${policy.limit} per ${policy.windowMs}ms exceeded`,
|
|
68
|
+
meta: { key, limit: policy.limit, windowMs: policy.windowMs },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
#resolvePolicy(execution, settings) {
|
|
74
|
+
const route = this.reflector.getAllAndOverride(constants_js_1.METADATA_KEYS.rateLimit, [execution.getHandler(), execution.getClass()]);
|
|
75
|
+
if (route !== undefined)
|
|
76
|
+
return route;
|
|
77
|
+
return {
|
|
78
|
+
limit: settings?.limit ?? exports.DEFAULT_RATE_LIMIT.limit,
|
|
79
|
+
windowMs: settings?.windowMs ?? exports.DEFAULT_RATE_LIMIT.windowMs,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Budgets are per caller **and** per route, so one hammered endpoint cannot
|
|
84
|
+
* exhaust a caller's budget for every other endpoint. An authenticated caller
|
|
85
|
+
* is counted by user id: behind a shared NAT, IP alone would let one client
|
|
86
|
+
* throttle a whole office.
|
|
87
|
+
*/
|
|
88
|
+
#buildKey(execution, request, perUser) {
|
|
89
|
+
const user = perUser ? this.context.get()?.user : undefined;
|
|
90
|
+
const identity = user === undefined ? `ip:${clientIp(request)}` : `user:${String(user.id)}`;
|
|
91
|
+
const route = `${request.method ?? 'GET'} ${execution.getClass().name}.${execution.getHandler().name}`;
|
|
92
|
+
return `${identity}|${route}`;
|
|
93
|
+
}
|
|
94
|
+
#isExcluded(url, excludePaths) {
|
|
95
|
+
if (url === undefined || excludePaths === undefined)
|
|
96
|
+
return false;
|
|
97
|
+
const path = url.split('?', 1)[0] ?? url;
|
|
98
|
+
return excludePaths.some((excluded) => path === excluded || path.startsWith(`${excluded}/`));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
exports.RateLimitGuard = RateLimitGuard;
|
|
102
|
+
exports.RateLimitGuard = RateLimitGuard = __decorate([
|
|
103
|
+
(0, common_1.Injectable)(),
|
|
104
|
+
__param(0, (0, common_1.Inject)(core_1.Reflector)),
|
|
105
|
+
__param(1, (0, common_1.Inject)(request_context_service_js_1.RequestContextService)),
|
|
106
|
+
__param(2, (0, common_1.Inject)(tokens_js_1.NAGE_CONFIG)),
|
|
107
|
+
__param(3, (0, common_1.Optional)()),
|
|
108
|
+
__param(3, (0, common_1.Inject)(tokens_js_1.NAGE_RATE_LIMIT_STORE)),
|
|
109
|
+
__metadata("design:paramtypes", [core_1.Reflector,
|
|
110
|
+
request_context_service_js_1.RequestContextService, Object, Object])
|
|
111
|
+
], RateLimitGuard);
|
|
112
|
+
/**
|
|
113
|
+
* Best-effort client address.
|
|
114
|
+
*
|
|
115
|
+
* `request.ip` already honours the trust-proxy setting, so it is preferred;
|
|
116
|
+
* `x-forwarded-for` is only consulted as a fallback and takes the left-most
|
|
117
|
+
* entry. An untrusted proxy header is spoofable, which is why trust-proxy is
|
|
118
|
+
* configured explicitly rather than assumed.
|
|
119
|
+
*/
|
|
120
|
+
function clientIp(request) {
|
|
121
|
+
if (request.ip !== undefined && request.ip !== '')
|
|
122
|
+
return request.ip;
|
|
123
|
+
const forwarded = request.headers?.['x-forwarded-for'];
|
|
124
|
+
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
125
|
+
const candidate = first?.split(',')[0]?.trim();
|
|
126
|
+
if (candidate !== undefined && candidate !== '')
|
|
127
|
+
return candidate;
|
|
128
|
+
return request.socket?.remoteAddress ?? 'unknown';
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=rate-limit.guard.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process rate-limit counters (PLAN.md §12).
|
|
3
|
+
*
|
|
4
|
+
* A fixed window per key: cheap, predictable, and enough for a single instance.
|
|
5
|
+
* It is deliberately behind `RateLimitStore` because it is **per process** — a
|
|
6
|
+
* limit of 100 across four pods is 400 unless a shared store is supplied through
|
|
7
|
+
* `NageCoreModule.forRoot(config, { rateLimitStore })`.
|
|
8
|
+
*
|
|
9
|
+
* No shared implementation ships. An earlier version of this comment said
|
|
10
|
+
* `@nage-api/cache` provided a Redis-backed one; it never did, and a false claim here
|
|
11
|
+
* is worse than the gap, because it reads as "already handled" to the one person
|
|
12
|
+
* who would otherwise have written it.
|
|
13
|
+
*/
|
|
14
|
+
import type { RateLimitResult, RateLimitStore } from '@nage-api/contracts';
|
|
15
|
+
export interface MemoryRateLimitStoreOptions {
|
|
16
|
+
/** Injectable clock, so window expiry is testable without waiting. */
|
|
17
|
+
readonly now?: () => number;
|
|
18
|
+
/** Entries are swept once this many keys accumulate. */
|
|
19
|
+
readonly sweepThreshold?: number;
|
|
20
|
+
}
|
|
21
|
+
export declare class MemoryRateLimitStore implements RateLimitStore {
|
|
22
|
+
#private;
|
|
23
|
+
readonly name = "memory";
|
|
24
|
+
constructor(options?: MemoryRateLimitStoreOptions);
|
|
25
|
+
consume(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
|
|
26
|
+
reset(key: string): Promise<void>;
|
|
27
|
+
/** Current number of tracked keys — for tests and health reporting. */
|
|
28
|
+
get size(): number;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=rate-limit.store.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* In-process rate-limit counters (PLAN.md §12).
|
|
4
|
+
*
|
|
5
|
+
* A fixed window per key: cheap, predictable, and enough for a single instance.
|
|
6
|
+
* It is deliberately behind `RateLimitStore` because it is **per process** — a
|
|
7
|
+
* limit of 100 across four pods is 400 unless a shared store is supplied through
|
|
8
|
+
* `NageCoreModule.forRoot(config, { rateLimitStore })`.
|
|
9
|
+
*
|
|
10
|
+
* No shared implementation ships. An earlier version of this comment said
|
|
11
|
+
* `@nage-api/cache` provided a Redis-backed one; it never did, and a false claim here
|
|
12
|
+
* is worse than the gap, because it reads as "already handled" to the one person
|
|
13
|
+
* who would otherwise have written it.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.MemoryRateLimitStore = void 0;
|
|
17
|
+
class MemoryRateLimitStore {
|
|
18
|
+
name = 'memory';
|
|
19
|
+
#windows = new Map();
|
|
20
|
+
#now;
|
|
21
|
+
#sweepThreshold;
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.#now = options.now ?? Date.now;
|
|
24
|
+
this.#sweepThreshold = options.sweepThreshold ?? 10_000;
|
|
25
|
+
}
|
|
26
|
+
consume(key, limit, windowMs) {
|
|
27
|
+
const now = this.#now();
|
|
28
|
+
const existing = this.#windows.get(key);
|
|
29
|
+
const window = existing === undefined || existing.resetAt <= now
|
|
30
|
+
? { count: 0, resetAt: now + windowMs }
|
|
31
|
+
: existing;
|
|
32
|
+
window.count += 1;
|
|
33
|
+
this.#windows.set(key, window);
|
|
34
|
+
// An unbounded map is a memory leak an attacker can drive by varying the
|
|
35
|
+
// key, so expired windows are swept once the map grows.
|
|
36
|
+
if (this.#windows.size > this.#sweepThreshold)
|
|
37
|
+
this.#sweep(now);
|
|
38
|
+
const allowed = window.count <= limit;
|
|
39
|
+
return Promise.resolve({
|
|
40
|
+
allowed,
|
|
41
|
+
limit,
|
|
42
|
+
remaining: Math.max(0, limit - window.count),
|
|
43
|
+
resetAt: window.resetAt,
|
|
44
|
+
retryAfterSeconds: allowed ? 0 : Math.max(1, Math.ceil((window.resetAt - now) / 1000)),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
reset(key) {
|
|
48
|
+
this.#windows.delete(key);
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
}
|
|
51
|
+
/** Current number of tracked keys — for tests and health reporting. */
|
|
52
|
+
get size() {
|
|
53
|
+
return this.#windows.size;
|
|
54
|
+
}
|
|
55
|
+
#sweep(now) {
|
|
56
|
+
for (const [key, window] of this.#windows) {
|
|
57
|
+
if (window.resetAt <= now)
|
|
58
|
+
this.#windows.delete(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.MemoryRateLimitStore = MemoryRateLimitStore;
|
|
63
|
+
//# sourceMappingURL=rate-limit.store.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction (PLAN.md §12, §18).
|
|
3
|
+
*
|
|
4
|
+
* Structured logs are only safe if the structure cannot smuggle a credential
|
|
5
|
+
* out. Three places need scrubbing, and all three are here so they cannot drift:
|
|
6
|
+
* arbitrary field values, request headers, and URLs carrying tokens in the query
|
|
7
|
+
* string.
|
|
8
|
+
*/
|
|
9
|
+
export declare const REDACTED = "[redacted]";
|
|
10
|
+
/**
|
|
11
|
+
* Substituted where the walk stops.
|
|
12
|
+
*
|
|
13
|
+
* The depth bound used to `return value`, which meant a credential nested deeper
|
|
14
|
+
* than the bound was written out verbatim — the redaction failed *open* at
|
|
15
|
+
* exactly the point where the structure was most likely to be something the
|
|
16
|
+
* caller had not thought about. Whatever cannot be scrubbed is not logged.
|
|
17
|
+
*/
|
|
18
|
+
export declare const TRUNCATED = "[truncated]";
|
|
19
|
+
/** A structure that refers to itself. Emitted instead of recursing forever. */
|
|
20
|
+
export declare const CIRCULAR = "[circular]";
|
|
21
|
+
/** Headers that carry credentials and are never logged verbatim. */
|
|
22
|
+
export declare const REDACTED_HEADERS: readonly string[];
|
|
23
|
+
/** Query parameters that carry credentials, e.g. `?access_token=…`. */
|
|
24
|
+
export declare const REDACTED_QUERY_PARAMS: readonly string[];
|
|
25
|
+
/** Build a lookup set from the defaults plus any configured extras. */
|
|
26
|
+
export declare function redactionSet(extra?: readonly string[]): ReadonlySet<string>;
|
|
27
|
+
/**
|
|
28
|
+
* Replace the value of any matching key, at any depth.
|
|
29
|
+
*
|
|
30
|
+
* Three things this has to survive, because a logger that throws inside a
|
|
31
|
+
* request handler turns a log line into a 500:
|
|
32
|
+
*
|
|
33
|
+
* - **Depth.** Bounded, and the bound substitutes `TRUNCATED` rather than
|
|
34
|
+
* passing the subtree through — see that constant.
|
|
35
|
+
* - **Cycles.** A request object, a Sequelize instance and an Axios error all
|
|
36
|
+
* refer back to themselves. Tracked per branch, so a value that legitimately
|
|
37
|
+
* appears twice side by side is still logged twice.
|
|
38
|
+
* - **Values `JSON.stringify` mangles.** A `Date` has no own enumerable
|
|
39
|
+
* properties, so walking it produced `{}` and lost the timestamp entirely; a
|
|
40
|
+
* `Buffer` expanded into one key per byte, turning a 1MB upload into a log
|
|
41
|
+
* line nothing will ever read.
|
|
42
|
+
*/
|
|
43
|
+
export declare function redact(value: unknown, fields: ReadonlySet<string>, depth?: number, seen?: ReadonlySet<object>): unknown;
|
|
44
|
+
/** Scrub credential-bearing request headers before they reach a log line. */
|
|
45
|
+
export declare function redactHeaders(headers: Record<string, unknown> | undefined, extra?: readonly string[]): Record<string, unknown>;
|
|
46
|
+
/**
|
|
47
|
+
* Scrub credentials out of a URL's query string.
|
|
48
|
+
*
|
|
49
|
+
* Request URLs are logged on every error; a token passed as a query parameter
|
|
50
|
+
* (which the legacy WebSocket handshake did) would otherwise be written to disk
|
|
51
|
+
* in clear text on every failure.
|
|
52
|
+
*/
|
|
53
|
+
export declare function redactUrl(url: string | undefined): string | undefined;
|
|
54
|
+
//# sourceMappingURL=redaction.d.ts.map
|