nestjs-shield 1.0.0 → 1.1.0

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 CHANGED
@@ -37,7 +37,7 @@ Or from GitHub Packages (published as `@yassinos-coder/nestjs-shield`) — first
37
37
  GITHUB_TOKEN=ghp_xxx npm install @yassinos-coder/nestjs-shield
38
38
  ```
39
39
 
40
- Node ≥ 18, NestJS 9 / 10 / 11.
40
+ Node ≥ 18, NestJS 9 / 10 / 11 / 12. NestJS 12 applications require Node ≥ 20.
41
41
 
42
42
  ## Quick start
43
43
 
@@ -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 escalation = config.escalate === false ? 1 : Math.pow(2, banCountResult.count - 1);
37
- const duration = config.banDuration * escalation;
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);
@@ -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;
@@ -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
- const rateLimit = this.mergeRateLimit(overrides);
145
- let rateLimitTtl = 0;
146
- if (!skipSet.has('rate-limit') && rateLimit) {
147
- const rl = await rate_limit_check_1.RateLimitCheck.check(this.storage, req, ip, rateLimit);
148
- rateLimitTtl = rateLimit.ttl;
149
- if (rateLimit.headers !== false) {
150
- headers_util_1.HeadersUtil.writeRateLimit(res, rateLimit.standardHeaders ?? 'draft-7', rl.limit, rl.remaining, rl.resetMs);
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
- await auto_ban_check_1.AutoBanCheck.recordViolation(this.storage, ip, this.config.autoBan);
157
- if (release)
158
- await release();
159
- this.notifyReject(req, res, ip, 'rate-limit', rl.reason ?? 'rate limited', 429, rl.retryAfterMs);
160
- return {
161
- allowed: false,
162
- ip,
163
- exception: new shield_exceptions_1.ShieldRateLimitException({
164
- message: this.config.response?.rateLimit429?.message ?? rl.reason ?? 'Too many requests',
165
- code: this.config.response?.rateLimit429?.code,
166
- layer: 'rate-limit',
167
- retryAfter: rl.retryAfterMs ? Math.ceil(rl.retryAfterMs / 1000) : undefined,
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
- let delayMs;
173
- const slowDown = (overrides.slowDown ?? this.config.slowDown);
174
- if (!skipSet.has('slow-down') && slowDown) {
175
- const sd = await slow_down_check_1.SlowDownCheck.check(this.storage, ip, rateLimitTtl || 60_000, slowDown);
176
- if (sd.delayMs && sd.delayMs > 0)
177
- delayMs = sd.delayMs;
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)
@@ -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
- constructor(storage: ShieldStorage);
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>;
@@ -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
- __metadata("design:paramtypes", [Object])
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);
@@ -25,6 +25,7 @@ export interface AutoBanConfig {
25
25
  window: number;
26
26
  banDuration: number;
27
27
  escalate?: boolean;
28
+ maxBanDuration?: number;
28
29
  }
29
30
  export interface SlowDownConfig {
30
31
  delayAfter: number;
@@ -99,6 +99,31 @@ 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
+ `;
114
+ const BURST_INCREMENT_LUA = `
115
+ local count = redis.call('INCR', KEYS[1])
116
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
117
+ return count
118
+ `;
119
+ const BURST_DECREMENT_LUA = `
120
+ local count = tonumber(redis.call('GET', KEYS[1]) or '0')
121
+ if count <= 1 then
122
+ redis.call('DEL', KEYS[1])
123
+ return 0
124
+ end
125
+ return redis.call('DECR', KEYS[1])
126
+ `;
102
127
  const SLIDING_COUNTER_LUA = `
103
128
  local cur = KEYS[1]
104
129
  local prev = KEYS[2]
@@ -129,10 +154,11 @@ class RedisStorage {
129
154
  }
130
155
  async increment(key, ttlMs, by = 1) {
131
156
  const k = this.k(key);
132
- const count = by === 1 ? await this.client.incr(k) : await this.client.incrby(k, by);
133
- await this.client.pexpire(k, ttlMs);
134
- const ttl = await this.client.pttl(k);
135
- return { count, expiresAt: Date.now() + Math.max(0, ttl) };
157
+ const res = (await this.runScript('shieldIncrement', INCREMENT_LUA, [k], [by, ttlMs]));
158
+ return {
159
+ count: Number(res[0]),
160
+ expiresAt: Date.now() + Math.max(0, Number(res[1])),
161
+ };
136
162
  }
137
163
  async consumeToken(key, capacity, refillPerMs, cost = 1) {
138
164
  const k = this.k(key);
@@ -153,11 +179,9 @@ class RedisStorage {
153
179
  }
154
180
  async fixedWindow(key, ttlMs, limit) {
155
181
  const k = this.k(key);
156
- const count = await this.client.incr(k);
157
- if (count === 1)
158
- await this.client.pexpire(k, ttlMs);
159
- const ttl = await this.client.pttl(k);
160
- return { count, allowed: count <= limit, resetMs: Math.max(0, ttl), limit };
182
+ const res = (await this.runScript('shieldIncrement', INCREMENT_LUA, [k], [1, ttlMs]));
183
+ const count = Number(res[0]);
184
+ return { count, allowed: count <= limit, resetMs: Math.max(0, Number(res[1])), limit };
161
185
  }
162
186
  async slidingWindowCounter(key, ttlMs, limit) {
163
187
  const now = Date.now();
@@ -212,16 +236,13 @@ class RedisStorage {
212
236
  }
213
237
  async incrementConcurrent(key) {
214
238
  const k = this.k(`burst:${key}`);
215
- const next = await this.client.incr(k);
216
- await this.client.pexpire(k, 60_000);
217
- return next;
239
+ const next = await this.runScript('shieldBurstIncrement', BURST_INCREMENT_LUA, [k], [60_000]);
240
+ return Number(next);
218
241
  }
219
242
  async decrementConcurrent(key) {
220
243
  const k = this.k(`burst:${key}`);
221
- const next = await this.client.decr(k);
222
- if (next <= 0)
223
- await this.client.del(k);
224
- return Math.max(0, next);
244
+ const next = await this.runScript('shieldBurstDecrement', BURST_DECREMENT_LUA, [k], []);
245
+ return Math.max(0, Number(next));
225
246
  }
226
247
  k(key) {
227
248
  return this.prefix ? `${this.prefix}:${key}` : key;
@@ -241,6 +262,9 @@ class RedisStorage {
241
262
  this.client.defineCommand('shieldLeakyBucket', { numberOfKeys: 1, lua: LEAKY_BUCKET_LUA });
242
263
  this.client.defineCommand('shieldSlidingLog', { numberOfKeys: 1, lua: SLIDING_LOG_LUA });
243
264
  this.client.defineCommand('shieldSlidingCounter', { numberOfKeys: 2, lua: SLIDING_COUNTER_LUA });
265
+ this.client.defineCommand('shieldIncrement', { numberOfKeys: 1, lua: INCREMENT_LUA });
266
+ this.client.defineCommand('shieldBurstIncrement', { numberOfKeys: 1, lua: BURST_INCREMENT_LUA });
267
+ this.client.defineCommand('shieldBurstDecrement', { numberOfKeys: 1, lua: BURST_DECREMENT_LUA });
244
268
  this.commandsDefined = true;
245
269
  }
246
270
  catch {
@@ -0,0 +1,10 @@
1
+ import type { ShieldConfig } from '../shield.types';
2
+ export declare class BannerUtil {
3
+ static build(config: ShieldConfig): {
4
+ banner: string;
5
+ summary: string;
6
+ };
7
+ private static centerPad;
8
+ private static storageLabel;
9
+ private static version;
10
+ }
@@ -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.0",
3
+ "version": "1.1.0",
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",
@@ -51,8 +51,8 @@
51
51
  "node": ">=18"
52
52
  },
53
53
  "peerDependencies": {
54
- "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
55
- "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
54
+ "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
55
+ "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
56
56
  "reflect-metadata": "^0.1.13 || ^0.2.0",
57
57
  "rxjs": "^7.0.0"
58
58
  },
@@ -65,9 +65,9 @@
65
65
  "ipaddr.js": "^2.2.0"
66
66
  },
67
67
  "devDependencies": {
68
- "@nestjs/common": "^10.4.0",
69
- "@nestjs/core": "^10.4.0",
70
- "@types/node": "^20.12.0",
68
+ "@nestjs/common": "^12.0.1",
69
+ "@nestjs/core": "^12.0.1",
70
+ "@types/node": "^22.0.0",
71
71
  "ioredis": "^5.4.1",
72
72
  "reflect-metadata": "^0.2.2",
73
73
  "rimraf": "^5.0.7",