nestjs-shield 1.0.0 → 1.0.1
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/README.md +1 -0
- package/dist/apply-to.js +9 -0
- package/dist/checks/auto-ban.check.js +4 -2
- package/dist/shield.engine.d.ts +2 -0
- package/dist/shield.engine.js +51 -32
- package/dist/shield.module.d.ts +8 -3
- package/dist/shield.module.js +17 -2
- package/dist/shield.types.d.ts +1 -0
- package/dist/storage/redis.storage.js +18 -4
- package/dist/utils/banner.util.d.ts +10 -0
- package/dist/utils/banner.util.js +75 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,6 +129,7 @@ interface ShieldConfig {
|
|
|
129
129
|
window: number; // ms — count window
|
|
130
130
|
banDuration: number; // ms — initial ban
|
|
131
131
|
escalate?: boolean; // 2× each repeat ban, default true
|
|
132
|
+
maxBanDuration?: number; // ms — cap on escalated ban duration
|
|
132
133
|
};
|
|
133
134
|
|
|
134
135
|
slowDown?: {
|
package/dist/apply-to.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Shield = void 0;
|
|
4
|
+
const common_1 = require("@nestjs/common");
|
|
4
5
|
const shield_engine_1 = require("./shield.engine");
|
|
5
6
|
const shield_middleware_1 = require("./shield.middleware");
|
|
6
7
|
const shield_constants_1 = require("./shield.constants");
|
|
7
8
|
const memory_storage_1 = require("./storage/memory.storage");
|
|
8
9
|
const redis_storage_1 = require("./storage/redis.storage");
|
|
10
|
+
const banner_util_1 = require("./utils/banner.util");
|
|
9
11
|
function buildStorage(option) {
|
|
10
12
|
if (!option || option === 'memory')
|
|
11
13
|
return new memory_storage_1.MemoryStorage();
|
|
@@ -24,9 +26,11 @@ function buildStorage(option) {
|
|
|
24
26
|
class Shield {
|
|
25
27
|
static applyTo(app, config = {}) {
|
|
26
28
|
let engine = null;
|
|
29
|
+
let resolvedConfig = config;
|
|
27
30
|
if (typeof app.get === 'function') {
|
|
28
31
|
try {
|
|
29
32
|
engine = app.get(shield_constants_1.SHIELD_ENGINE);
|
|
33
|
+
resolvedConfig = engine.getConfig();
|
|
30
34
|
}
|
|
31
35
|
catch {
|
|
32
36
|
engine = null;
|
|
@@ -37,6 +41,11 @@ class Shield {
|
|
|
37
41
|
engine = new shield_engine_1.ShieldEngine(config, storage);
|
|
38
42
|
}
|
|
39
43
|
app.use((0, shield_middleware_1.createShieldMiddleware)(engine));
|
|
44
|
+
if (resolvedConfig.enabled !== false && engine.markBannerShown()) {
|
|
45
|
+
const { banner, summary } = banner_util_1.BannerUtil.build(resolvedConfig);
|
|
46
|
+
process.stdout.write(banner + '\n');
|
|
47
|
+
new common_1.Logger('Shield').log(summary);
|
|
48
|
+
}
|
|
40
49
|
}
|
|
41
50
|
}
|
|
42
51
|
exports.Shield = Shield;
|
|
@@ -33,8 +33,10 @@ class AutoBanCheck {
|
|
|
33
33
|
return;
|
|
34
34
|
const banCountKey = `${shield_constants_1.KEY_BAN_COUNT}:${ip}`;
|
|
35
35
|
const banCountResult = await storage.increment(banCountKey, config.banDuration * 16);
|
|
36
|
-
const
|
|
37
|
-
const
|
|
36
|
+
const exponent = Math.min(Math.max(0, banCountResult.count - 1), 30);
|
|
37
|
+
const escalation = config.escalate === false ? 1 : Math.pow(2, exponent);
|
|
38
|
+
const computed = config.banDuration * escalation;
|
|
39
|
+
const duration = config.maxBanDuration !== undefined ? Math.min(computed, config.maxBanDuration) : computed;
|
|
38
40
|
const expiresAt = Date.now() + duration;
|
|
39
41
|
await storage.set(`${shield_constants_1.KEY_BAN}:${ip}`, String(expiresAt), duration);
|
|
40
42
|
await storage.delete(violationsKey);
|
package/dist/shield.engine.d.ts
CHANGED
|
@@ -11,9 +11,11 @@ export interface EngineDecision {
|
|
|
11
11
|
export declare class ShieldEngine {
|
|
12
12
|
private readonly config;
|
|
13
13
|
private readonly storage;
|
|
14
|
+
private bannerShown;
|
|
14
15
|
constructor(config: ShieldConfig, storage: ShieldStorage);
|
|
15
16
|
getConfig(): ShieldConfig;
|
|
16
17
|
getStorage(): ShieldStorage;
|
|
18
|
+
markBannerShown(): boolean;
|
|
17
19
|
run(req: AnyRequest, res: AnyResponse, overrides?: DecoratorOverrides): Promise<EngineDecision>;
|
|
18
20
|
private merge;
|
|
19
21
|
private mergeRateLimit;
|
package/dist/shield.engine.js
CHANGED
|
@@ -31,6 +31,7 @@ let ShieldEngine = class ShieldEngine {
|
|
|
31
31
|
constructor(config, storage) {
|
|
32
32
|
this.config = config;
|
|
33
33
|
this.storage = storage;
|
|
34
|
+
this.bannerShown = false;
|
|
34
35
|
}
|
|
35
36
|
getConfig() {
|
|
36
37
|
return this.config;
|
|
@@ -38,6 +39,12 @@ let ShieldEngine = class ShieldEngine {
|
|
|
38
39
|
getStorage() {
|
|
39
40
|
return this.storage;
|
|
40
41
|
}
|
|
42
|
+
markBannerShown() {
|
|
43
|
+
if (this.bannerShown)
|
|
44
|
+
return false;
|
|
45
|
+
this.bannerShown = true;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
41
48
|
async run(req, res, overrides = {}) {
|
|
42
49
|
if (this.config.enabled === false)
|
|
43
50
|
return { allowed: true, ip: '' };
|
|
@@ -141,42 +148,54 @@ let ShieldEngine = class ShieldEngine {
|
|
|
141
148
|
}
|
|
142
149
|
release = burstOut.release;
|
|
143
150
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (!rl.allowed) {
|
|
153
|
-
if (this.config.response?.rateLimit429?.includeRetryAfter !== false && rl.retryAfterMs) {
|
|
154
|
-
headers_util_1.HeadersUtil.writeRetryAfter(res, rl.retryAfterMs);
|
|
151
|
+
try {
|
|
152
|
+
const rateLimit = this.mergeRateLimit(overrides);
|
|
153
|
+
let rateLimitTtl = 0;
|
|
154
|
+
if (!skipSet.has('rate-limit') && rateLimit) {
|
|
155
|
+
const rl = await rate_limit_check_1.RateLimitCheck.check(this.storage, req, ip, rateLimit);
|
|
156
|
+
rateLimitTtl = rateLimit.ttl;
|
|
157
|
+
if (rateLimit.headers !== false) {
|
|
158
|
+
headers_util_1.HeadersUtil.writeRateLimit(res, rateLimit.standardHeaders ?? 'draft-7', rl.limit, rl.remaining, rl.resetMs);
|
|
155
159
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
160
|
+
if (!rl.allowed) {
|
|
161
|
+
if (this.config.response?.rateLimit429?.includeRetryAfter !== false && rl.retryAfterMs) {
|
|
162
|
+
headers_util_1.HeadersUtil.writeRetryAfter(res, rl.retryAfterMs);
|
|
163
|
+
}
|
|
164
|
+
await auto_ban_check_1.AutoBanCheck.recordViolation(this.storage, ip, this.config.autoBan);
|
|
165
|
+
if (release)
|
|
166
|
+
await release();
|
|
167
|
+
this.notifyReject(req, res, ip, 'rate-limit', rl.reason ?? 'rate limited', 429, rl.retryAfterMs);
|
|
168
|
+
return {
|
|
169
|
+
allowed: false,
|
|
170
|
+
ip,
|
|
171
|
+
exception: new shield_exceptions_1.ShieldRateLimitException({
|
|
172
|
+
message: this.config.response?.rateLimit429?.message ?? rl.reason ?? 'Too many requests',
|
|
173
|
+
code: this.config.response?.rateLimit429?.code,
|
|
174
|
+
layer: 'rate-limit',
|
|
175
|
+
retryAfter: rl.retryAfterMs ? Math.ceil(rl.retryAfterMs / 1000) : undefined,
|
|
176
|
+
}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
let delayMs;
|
|
181
|
+
const slowDown = (overrides.slowDown ?? this.config.slowDown);
|
|
182
|
+
if (!skipSet.has('slow-down') && slowDown) {
|
|
183
|
+
const sd = await slow_down_check_1.SlowDownCheck.check(this.storage, ip, rateLimitTtl || 60_000, slowDown);
|
|
184
|
+
if (sd.delayMs && sd.delayMs > 0)
|
|
185
|
+
delayMs = sd.delayMs;
|
|
170
186
|
}
|
|
187
|
+
return { allowed: true, ip, delayMs, release };
|
|
171
188
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
189
|
+
catch (err) {
|
|
190
|
+
if (release) {
|
|
191
|
+
try {
|
|
192
|
+
await release();
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw err;
|
|
178
198
|
}
|
|
179
|
-
return { allowed: true, ip, delayMs, release };
|
|
180
199
|
}
|
|
181
200
|
merge(base, override) {
|
|
182
201
|
if (!base && !override)
|
package/dist/shield.module.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { DynamicModule, ModuleMetadata, OnModuleDestroy, Type } from '@nestjs/common';
|
|
1
|
+
import { DynamicModule, ModuleMetadata, OnApplicationBootstrap, OnModuleDestroy, Type } from '@nestjs/common';
|
|
2
|
+
import { ShieldEngine } from './shield.engine';
|
|
2
3
|
import type { ShieldConfig } from './shield.types';
|
|
3
4
|
import type { ShieldStorage } from './storage/shield-storage.interface';
|
|
4
5
|
export interface ShieldAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
|
5
6
|
useFactory: (...args: unknown[]) => Promise<ShieldConfig> | ShieldConfig;
|
|
6
7
|
inject?: (string | symbol | Type<unknown>)[];
|
|
7
8
|
}
|
|
8
|
-
export declare class ShieldModule implements OnModuleDestroy {
|
|
9
|
+
export declare class ShieldModule implements OnApplicationBootstrap, OnModuleDestroy {
|
|
9
10
|
private readonly storage;
|
|
10
|
-
|
|
11
|
+
private readonly config;
|
|
12
|
+
private readonly engine;
|
|
13
|
+
private readonly logger;
|
|
14
|
+
constructor(storage: ShieldStorage, config: ShieldConfig, engine: ShieldEngine);
|
|
15
|
+
onApplicationBootstrap(): void;
|
|
11
16
|
static forRoot(config?: ShieldConfig): DynamicModule;
|
|
12
17
|
static forRootAsync(options: ShieldAsyncOptions): DynamicModule;
|
|
13
18
|
onModuleDestroy(): Promise<void>;
|
package/dist/shield.module.js
CHANGED
|
@@ -21,6 +21,7 @@ const shield_engine_1 = require("./shield.engine");
|
|
|
21
21
|
const shield_guard_1 = require("./shield.guard");
|
|
22
22
|
const memory_storage_1 = require("./storage/memory.storage");
|
|
23
23
|
const redis_storage_1 = require("./storage/redis.storage");
|
|
24
|
+
const banner_util_1 = require("./utils/banner.util");
|
|
24
25
|
function buildStorage(option) {
|
|
25
26
|
if (!option || option === 'memory')
|
|
26
27
|
return new memory_storage_1.MemoryStorage();
|
|
@@ -37,8 +38,20 @@ function buildStorage(option) {
|
|
|
37
38
|
return option;
|
|
38
39
|
}
|
|
39
40
|
let ShieldModule = ShieldModule_1 = class ShieldModule {
|
|
40
|
-
constructor(storage) {
|
|
41
|
+
constructor(storage, config, engine) {
|
|
41
42
|
this.storage = storage;
|
|
43
|
+
this.config = config;
|
|
44
|
+
this.engine = engine;
|
|
45
|
+
this.logger = new common_1.Logger('Shield');
|
|
46
|
+
}
|
|
47
|
+
onApplicationBootstrap() {
|
|
48
|
+
if (this.config.enabled === false)
|
|
49
|
+
return;
|
|
50
|
+
if (!this.engine.markBannerShown())
|
|
51
|
+
return;
|
|
52
|
+
const { banner, summary } = banner_util_1.BannerUtil.build(this.config);
|
|
53
|
+
process.stdout.write(banner + '\n');
|
|
54
|
+
this.logger.log(summary);
|
|
42
55
|
}
|
|
43
56
|
static forRoot(config = {}) {
|
|
44
57
|
const providers = [
|
|
@@ -101,5 +114,7 @@ exports.ShieldModule = ShieldModule;
|
|
|
101
114
|
exports.ShieldModule = ShieldModule = ShieldModule_1 = __decorate([
|
|
102
115
|
(0, common_1.Module)({}),
|
|
103
116
|
__param(0, (0, common_1.Inject)(shield_constants_1.SHIELD_STORAGE)),
|
|
104
|
-
|
|
117
|
+
__param(1, (0, common_1.Inject)(shield_constants_1.SHIELD_CONFIG)),
|
|
118
|
+
__param(2, (0, common_1.Inject)(shield_constants_1.SHIELD_ENGINE)),
|
|
119
|
+
__metadata("design:paramtypes", [Object, Object, shield_engine_1.ShieldEngine])
|
|
105
120
|
], ShieldModule);
|
package/dist/shield.types.d.ts
CHANGED
|
@@ -99,6 +99,18 @@ end
|
|
|
99
99
|
|
|
100
100
|
return { allowed, count, resetMs }
|
|
101
101
|
`;
|
|
102
|
+
const INCREMENT_LUA = `
|
|
103
|
+
local key = KEYS[1]
|
|
104
|
+
local by = tonumber(ARGV[1])
|
|
105
|
+
local ttlMs = tonumber(ARGV[2])
|
|
106
|
+
local count = redis.call('INCRBY', key, by)
|
|
107
|
+
if count == by then
|
|
108
|
+
redis.call('PEXPIRE', key, ttlMs)
|
|
109
|
+
end
|
|
110
|
+
local ttl = redis.call('PTTL', key)
|
|
111
|
+
if ttl < 0 then ttl = 0 end
|
|
112
|
+
return { count, ttl }
|
|
113
|
+
`;
|
|
102
114
|
const SLIDING_COUNTER_LUA = `
|
|
103
115
|
local cur = KEYS[1]
|
|
104
116
|
local prev = KEYS[2]
|
|
@@ -129,10 +141,11 @@ class RedisStorage {
|
|
|
129
141
|
}
|
|
130
142
|
async increment(key, ttlMs, by = 1) {
|
|
131
143
|
const k = this.k(key);
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
144
|
+
const res = (await this.runScript('shieldIncrement', INCREMENT_LUA, [k], [by, ttlMs]));
|
|
145
|
+
return {
|
|
146
|
+
count: Number(res[0]),
|
|
147
|
+
expiresAt: Date.now() + Math.max(0, Number(res[1])),
|
|
148
|
+
};
|
|
136
149
|
}
|
|
137
150
|
async consumeToken(key, capacity, refillPerMs, cost = 1) {
|
|
138
151
|
const k = this.k(key);
|
|
@@ -241,6 +254,7 @@ class RedisStorage {
|
|
|
241
254
|
this.client.defineCommand('shieldLeakyBucket', { numberOfKeys: 1, lua: LEAKY_BUCKET_LUA });
|
|
242
255
|
this.client.defineCommand('shieldSlidingLog', { numberOfKeys: 1, lua: SLIDING_LOG_LUA });
|
|
243
256
|
this.client.defineCommand('shieldSlidingCounter', { numberOfKeys: 2, lua: SLIDING_COUNTER_LUA });
|
|
257
|
+
this.client.defineCommand('shieldIncrement', { numberOfKeys: 1, lua: INCREMENT_LUA });
|
|
244
258
|
this.commandsDefined = true;
|
|
245
259
|
}
|
|
246
260
|
catch {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BannerUtil = void 0;
|
|
4
|
+
class BannerUtil {
|
|
5
|
+
static build(config) {
|
|
6
|
+
const version = BannerUtil.version();
|
|
7
|
+
const title = `nestjs-shield v${version} -- up and running`;
|
|
8
|
+
const inner = ` ${title} `;
|
|
9
|
+
const width = Math.max(inner.length, 50);
|
|
10
|
+
const padded = BannerUtil.centerPad(inner, width);
|
|
11
|
+
const top = ' +' + '-'.repeat(width) + '+';
|
|
12
|
+
const mid = ' |' + ' '.repeat(width) + '|';
|
|
13
|
+
const banner = [
|
|
14
|
+
'',
|
|
15
|
+
top,
|
|
16
|
+
mid,
|
|
17
|
+
' |' + padded + '|',
|
|
18
|
+
mid,
|
|
19
|
+
' +' + '-'.repeat(width) + '+',
|
|
20
|
+
'',
|
|
21
|
+
].join('\n');
|
|
22
|
+
const layers = [];
|
|
23
|
+
if (config.whitelist)
|
|
24
|
+
layers.push('whitelist');
|
|
25
|
+
if (config.blacklist)
|
|
26
|
+
layers.push('blacklist');
|
|
27
|
+
if (config.autoBan)
|
|
28
|
+
layers.push('auto-ban');
|
|
29
|
+
if (config.userAgent)
|
|
30
|
+
layers.push('user-agent');
|
|
31
|
+
if (config.payload)
|
|
32
|
+
layers.push('payload');
|
|
33
|
+
if (config.burst)
|
|
34
|
+
layers.push('burst');
|
|
35
|
+
if (config.rateLimit)
|
|
36
|
+
layers.push('rate-limit');
|
|
37
|
+
if (config.slowDown)
|
|
38
|
+
layers.push('slow-down');
|
|
39
|
+
const parts = [`storage=${BannerUtil.storageLabel(config)}`];
|
|
40
|
+
if (config.rateLimit) {
|
|
41
|
+
const rl = config.rateLimit;
|
|
42
|
+
const seconds = Math.max(1, Math.round(rl.ttl / 1000));
|
|
43
|
+
parts.push(`rate=${rl.limit}/${seconds}s (${rl.algorithm ?? 'token-bucket'})`);
|
|
44
|
+
}
|
|
45
|
+
if (layers.length)
|
|
46
|
+
parts.push(`layers=[${layers.join(', ')}]`);
|
|
47
|
+
return { banner, summary: parts.join(' | ') };
|
|
48
|
+
}
|
|
49
|
+
static centerPad(text, width) {
|
|
50
|
+
if (text.length >= width)
|
|
51
|
+
return text.slice(0, width);
|
|
52
|
+
const total = width - text.length;
|
|
53
|
+
const left = Math.floor(total / 2);
|
|
54
|
+
const right = total - left;
|
|
55
|
+
return ' '.repeat(left) + text + ' '.repeat(right);
|
|
56
|
+
}
|
|
57
|
+
static storageLabel(config) {
|
|
58
|
+
const s = config.storage;
|
|
59
|
+
if (!s || s === 'memory')
|
|
60
|
+
return 'memory';
|
|
61
|
+
if (typeof s === 'object' && 'type' in s)
|
|
62
|
+
return s.type;
|
|
63
|
+
return 'custom';
|
|
64
|
+
}
|
|
65
|
+
static version() {
|
|
66
|
+
try {
|
|
67
|
+
const pkg = require('../../package.json');
|
|
68
|
+
return pkg.version ?? '?';
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return '?';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
exports.BannerUtil = BannerUtil;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nestjs-shield",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Layered API protection for NestJS: rate limiting, IP allow/block lists, auto-ban, slow-down, UA filtering, burst caps, payload limits. Pluggable storage (memory/Redis).",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|