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 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 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,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 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) };
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,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.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",