nestjs-shield 1.0.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/LICENSE +21 -0
- package/README.md +361 -0
- package/dist/algorithms/fixed-window.d.ts +4 -0
- package/dist/algorithms/fixed-window.js +9 -0
- package/dist/algorithms/leaky-bucket.d.ts +4 -0
- package/dist/algorithms/leaky-bucket.js +10 -0
- package/dist/algorithms/sliding-window-counter.d.ts +4 -0
- package/dist/algorithms/sliding-window-counter.js +9 -0
- package/dist/algorithms/sliding-window-log.d.ts +4 -0
- package/dist/algorithms/sliding-window-log.js +9 -0
- package/dist/algorithms/token-bucket.d.ts +4 -0
- package/dist/algorithms/token-bucket.js +10 -0
- package/dist/apply-to.d.ts +8 -0
- package/dist/apply-to.js +42 -0
- package/dist/checks/auto-ban.check.d.ts +6 -0
- package/dist/checks/auto-ban.check.js +43 -0
- package/dist/checks/blacklist.check.d.ts +4 -0
- package/dist/checks/blacklist.check.js +20 -0
- package/dist/checks/burst.check.d.ts +5 -0
- package/dist/checks/burst.check.js +28 -0
- package/dist/checks/payload.check.d.ts +5 -0
- package/dist/checks/payload.check.js +46 -0
- package/dist/checks/rate-limit.check.d.ts +9 -0
- package/dist/checks/rate-limit.check.js +59 -0
- package/dist/checks/slow-down.check.d.ts +5 -0
- package/dist/checks/slow-down.check.js +19 -0
- package/dist/checks/user-agent.check.d.ts +4 -0
- package/dist/checks/user-agent.check.js +31 -0
- package/dist/checks/whitelist.check.d.ts +4 -0
- package/dist/checks/whitelist.check.js +15 -0
- package/dist/decorators/blacklist.decorator.d.ts +2 -0
- package/dist/decorators/blacklist.decorator.js +7 -0
- package/dist/decorators/burst-limit.decorator.d.ts +2 -0
- package/dist/decorators/burst-limit.decorator.js +7 -0
- package/dist/decorators/max-payload.decorator.d.ts +2 -0
- package/dist/decorators/max-payload.decorator.js +7 -0
- package/dist/decorators/rate-limit.decorator.d.ts +2 -0
- package/dist/decorators/rate-limit.decorator.js +7 -0
- package/dist/decorators/skip-shield.decorator.d.ts +2 -0
- package/dist/decorators/skip-shield.decorator.js +7 -0
- package/dist/decorators/slow-down.decorator.d.ts +2 -0
- package/dist/decorators/slow-down.decorator.js +7 -0
- package/dist/decorators/user-agent-policy.decorator.d.ts +2 -0
- package/dist/decorators/user-agent-policy.decorator.js +7 -0
- package/dist/decorators/whitelist.decorator.d.ts +2 -0
- package/dist/decorators/whitelist.decorator.js +7 -0
- package/dist/exceptions/shield.exceptions.d.ts +23 -0
- package/dist/exceptions/shield.exceptions.js +40 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +58 -0
- package/dist/shield.constants.d.ts +22 -0
- package/dist/shield.constants.js +33 -0
- package/dist/shield.engine.d.ts +21 -0
- package/dist/shield.engine.js +215 -0
- package/dist/shield.guard.d.ts +10 -0
- package/dist/shield.guard.js +89 -0
- package/dist/shield.middleware.d.ts +5 -0
- package/dist/shield.middleware.js +47 -0
- package/dist/shield.module.d.ts +14 -0
- package/dist/shield.module.js +105 -0
- package/dist/shield.types.d.ts +141 -0
- package/dist/shield.types.js +2 -0
- package/dist/storage/memory.storage.d.ts +30 -0
- package/dist/storage/memory.storage.js +201 -0
- package/dist/storage/redis.storage.d.ts +44 -0
- package/dist/storage/redis.storage.js +258 -0
- package/dist/storage/shield-storage.interface.d.ts +30 -0
- package/dist/storage/shield-storage.interface.js +2 -0
- package/dist/utils/headers.util.d.ts +5 -0
- package/dist/utils/headers.util.js +23 -0
- package/dist/utils/ip.util.d.ts +6 -0
- package/dist/utils/ip.util.js +100 -0
- package/dist/utils/key.util.d.ts +5 -0
- package/dist/utils/key.util.js +24 -0
- package/dist/utils/ua.util.d.ts +4 -0
- package/dist/utils/ua.util.js +26 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# nestjs-shield
|
|
2
|
+
|
|
3
|
+
Layered API protection for NestJS in a single drop-in module.
|
|
4
|
+
|
|
5
|
+
- **Rate limiting** with five algorithms — token bucket (default), sliding window counter, sliding window log, fixed window, leaky bucket
|
|
6
|
+
- **IP allow / block lists** with CIDR support
|
|
7
|
+
- **Auto-ban** that escalates repeated abusers to a temporary block
|
|
8
|
+
- **Slow-down** that adds progressive delay before issuing a hard 429
|
|
9
|
+
- **User-Agent filtering** with block / allow / require-present
|
|
10
|
+
- **Burst protection** — concurrent in-flight cap per IP (slowloris cover)
|
|
11
|
+
- **Payload limits** — body + header byte caps
|
|
12
|
+
- **Pluggable storage** — in-memory (default) or Redis (atomic Lua scripts) — bring your own adapter for anything else
|
|
13
|
+
- **Per-route overrides** via decorators
|
|
14
|
+
- **Standard rate-limit headers** (draft-7 by default, draft-6 supported)
|
|
15
|
+
|
|
16
|
+
No geo-fencing in v1. No dependency on `@nestjs/throttler` — the engine is standalone.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
From the public npm registry:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install nestjs-shield
|
|
24
|
+
# optional, only if you use Redis storage
|
|
25
|
+
npm install ioredis
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or from GitHub Packages (published as `@yassinos-coder/nestjs-shield`) — first create a project-local `.npmrc` mapping the scope to GitHub Packages and a personal access token with `read:packages`:
|
|
29
|
+
|
|
30
|
+
```ini
|
|
31
|
+
# .npmrc
|
|
32
|
+
@yassinos-coder:registry=https://npm.pkg.github.com
|
|
33
|
+
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
GITHUB_TOKEN=ghp_xxx npm install @yassinos-coder/nestjs-shield
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Node ≥ 18, NestJS 9 / 10 / 11.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
### Option A — global guard via `forRoot` (recommended)
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// app.module.ts
|
|
48
|
+
import { Module } from '@nestjs/common';
|
|
49
|
+
import { ShieldModule } from 'nestjs-shield';
|
|
50
|
+
|
|
51
|
+
@Module({
|
|
52
|
+
imports: [
|
|
53
|
+
ShieldModule.forRoot({
|
|
54
|
+
rateLimit: { algorithm: 'token-bucket', limit: 60, ttl: 60_000 },
|
|
55
|
+
autoBan: { threshold: 10, window: 60_000, banDuration: 5 * 60_000, escalate: true },
|
|
56
|
+
blacklist: { ips: ['10.10.10.10'] },
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
export class AppModule {}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Option B — middleware-style, configured from `main.ts`
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// main.ts
|
|
67
|
+
import { NestFactory } from '@nestjs/core';
|
|
68
|
+
import { Shield } from 'nestjs-shield';
|
|
69
|
+
import { AppModule } from './app.module';
|
|
70
|
+
|
|
71
|
+
async function bootstrap() {
|
|
72
|
+
const app = await NestFactory.create(AppModule);
|
|
73
|
+
Shield.applyTo(app, {
|
|
74
|
+
rateLimit: { limit: 60, ttl: 60_000 },
|
|
75
|
+
burst: { maxConcurrent: 50 },
|
|
76
|
+
});
|
|
77
|
+
await app.listen(3000);
|
|
78
|
+
}
|
|
79
|
+
bootstrap();
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
You can use both together — when you do, `applyTo` resolves the engine already created by `ShieldModule` (one config drives both).
|
|
83
|
+
|
|
84
|
+
### `forRootAsync` with ConfigService
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
ShieldModule.forRootAsync({
|
|
88
|
+
imports: [ConfigModule],
|
|
89
|
+
inject: [ConfigService],
|
|
90
|
+
useFactory: (cfg: ConfigService) => ({
|
|
91
|
+
rateLimit: {
|
|
92
|
+
limit: cfg.get<number>('RATE_LIMIT', 60),
|
|
93
|
+
ttl: cfg.get<number>('RATE_TTL', 60_000),
|
|
94
|
+
},
|
|
95
|
+
storage: { type: 'redis', client: new Redis(cfg.get('REDIS_URL')!) },
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Configuration reference
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
interface ShieldConfig {
|
|
104
|
+
enabled?: boolean; // default true
|
|
105
|
+
trustProxy?: boolean | number; // false; number = trust N proxies left of the client
|
|
106
|
+
ipResolver?: (req) => string; // your own IP extraction
|
|
107
|
+
|
|
108
|
+
storage?:
|
|
109
|
+
| 'memory'
|
|
110
|
+
| { type: 'memory'; maxKeys?: number } // default 100_000
|
|
111
|
+
| { type: 'redis'; client: Redis; keyPrefix?: string }
|
|
112
|
+
| ShieldStorage; // bring your own
|
|
113
|
+
|
|
114
|
+
rateLimit?: {
|
|
115
|
+
algorithm?: 'token-bucket' | 'sliding-window' | 'sliding-window-log' | 'fixed-window' | 'leaky-bucket';
|
|
116
|
+
limit: number;
|
|
117
|
+
ttl: number; // ms
|
|
118
|
+
keyBy?: 'ip' | { header: string } | ((req) => string);
|
|
119
|
+
skip?: (req) => boolean;
|
|
120
|
+
headers?: boolean; // default true
|
|
121
|
+
standardHeaders?: 'draft-6' | 'draft-7';
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
whitelist?: { ips?: string[]; cidrs?: string[] };
|
|
125
|
+
blacklist?: { ips?: string[]; cidrs?: string[]; statusCode?: number };
|
|
126
|
+
|
|
127
|
+
autoBan?: {
|
|
128
|
+
threshold: number; // # of 429/403s before ban
|
|
129
|
+
window: number; // ms — count window
|
|
130
|
+
banDuration: number; // ms — initial ban
|
|
131
|
+
escalate?: boolean; // 2× each repeat ban, default true
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
slowDown?: {
|
|
135
|
+
delayAfter: number;
|
|
136
|
+
delayMs: number | ((hit: number) => number);
|
|
137
|
+
maxDelayMs?: number;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
userAgent?: {
|
|
141
|
+
block?: (string | RegExp)[];
|
|
142
|
+
allow?: (string | RegExp)[]; // overrides block when matched
|
|
143
|
+
requirePresent?: boolean;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
burst?: { maxConcurrent: number };
|
|
147
|
+
|
|
148
|
+
payload?: { maxBodyBytes?: number; maxHeaderBytes?: number };
|
|
149
|
+
|
|
150
|
+
response?: {
|
|
151
|
+
rateLimit429?: { message?: string; code?: string; includeRetryAfter?: boolean };
|
|
152
|
+
blocked403?: { message?: string; code?: string };
|
|
153
|
+
payload413?: { message?: string; code?: string };
|
|
154
|
+
onReject?: (req, res, info) => void; // observability
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Algorithms
|
|
160
|
+
|
|
161
|
+
All algorithms share the same `{ limit, ttl }` knobs and the same storage interface, so switching is one config line.
|
|
162
|
+
|
|
163
|
+
| Algorithm | Storage cost | Burst behavior | Smoothness | Use when |
|
|
164
|
+
| --- | --- | --- | --- | --- |
|
|
165
|
+
| `token-bucket` (default) | 2 small numbers per IP | Allows initial burst up to `limit`, then drips | Smooth | General-purpose bursty APIs |
|
|
166
|
+
| `sliding-window` | 2 counters | Cannot 2× at window edges | Smooth | When fairness matters and storage is precious |
|
|
167
|
+
| `sliding-window-log` | one sorted set | Exact | Exact | Strict compliance, smaller scale |
|
|
168
|
+
| `fixed-window` | 1 counter | Allows up to 2× at window boundary | Stepped | Cheapest, very large scale, edge-burst OK |
|
|
169
|
+
| `leaky-bucket` | 2 small numbers per IP | Queue overflow rejects | Very smooth | Outbound shaping, traffic smoothing |
|
|
170
|
+
|
|
171
|
+
## Pipeline order
|
|
172
|
+
|
|
173
|
+
Every request runs the chain in this fixed order, short-circuiting on the first reject:
|
|
174
|
+
|
|
175
|
+
1. `whitelist` — allow ⇒ skip everything else
|
|
176
|
+
2. `blacklist` — block ⇒ 403
|
|
177
|
+
3. `auto-ban` — banned ⇒ 403 with `Retry-After`
|
|
178
|
+
4. `user-agent` — blocked ⇒ 403
|
|
179
|
+
5. `payload` — too large ⇒ 413
|
|
180
|
+
6. `burst` — too many in-flight ⇒ 429
|
|
181
|
+
7. `rate-limit` — over limit ⇒ 429, also feeds auto-ban
|
|
182
|
+
8. `slow-down` — in soft zone ⇒ delay, then allow
|
|
183
|
+
|
|
184
|
+
## Decorators (per-route overrides)
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import {
|
|
188
|
+
RateLimit, SkipShield, Blacklist, Whitelist,
|
|
189
|
+
SlowDown, MaxPayload, BurstLimit, UserAgentPolicy,
|
|
190
|
+
} from 'nestjs-shield';
|
|
191
|
+
|
|
192
|
+
@Controller('api')
|
|
193
|
+
export class ApiController {
|
|
194
|
+
@Get('hot')
|
|
195
|
+
@RateLimit({ algorithm: 'sliding-window-log', limit: 5, ttl: 10_000 })
|
|
196
|
+
hot() {}
|
|
197
|
+
|
|
198
|
+
@Get('health')
|
|
199
|
+
@SkipShield() // bypass all checks
|
|
200
|
+
health() {}
|
|
201
|
+
|
|
202
|
+
@Get('mixed')
|
|
203
|
+
@SkipShield('rate-limit', 'slow-down') // skip specific layers only
|
|
204
|
+
mixed() {}
|
|
205
|
+
|
|
206
|
+
@Post('upload')
|
|
207
|
+
@MaxPayload({ maxBodyBytes: 1_000_000 })
|
|
208
|
+
upload() {}
|
|
209
|
+
|
|
210
|
+
@Get('strict-ua')
|
|
211
|
+
@UserAgentPolicy({ block: [/curl/i], requirePresent: true })
|
|
212
|
+
strictUa() {}
|
|
213
|
+
|
|
214
|
+
@Get('vip')
|
|
215
|
+
@Whitelist({ cidrs: ['192.168.0.0/16'] })
|
|
216
|
+
vip() {}
|
|
217
|
+
|
|
218
|
+
@Get('blocked-net')
|
|
219
|
+
@Blacklist({ cidrs: ['203.0.113.0/24'] })
|
|
220
|
+
blocked() {}
|
|
221
|
+
|
|
222
|
+
@Get('long')
|
|
223
|
+
@BurstLimit({ maxConcurrent: 2 })
|
|
224
|
+
long() {}
|
|
225
|
+
|
|
226
|
+
@Get('slow')
|
|
227
|
+
@SlowDown({ delayAfter: 5, delayMs: 250 })
|
|
228
|
+
slow() {}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Decorators merge over the global config — anything you omit falls through to the global setting.
|
|
233
|
+
|
|
234
|
+
## Storage adapters
|
|
235
|
+
|
|
236
|
+
### Memory (default)
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
ShieldModule.forRoot({
|
|
240
|
+
storage: { type: 'memory', maxKeys: 200_000 }, // LRU evicts oldest 10% when full
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Per-process. Counters reset on restart. Fine for single-instance services.
|
|
245
|
+
|
|
246
|
+
### Redis (multi-pod)
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import Redis from 'ioredis';
|
|
250
|
+
|
|
251
|
+
ShieldModule.forRoot({
|
|
252
|
+
storage: {
|
|
253
|
+
type: 'redis',
|
|
254
|
+
client: new Redis(process.env.REDIS_URL!),
|
|
255
|
+
keyPrefix: 'myapp', // optional
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
All algorithms use atomic Lua scripts loaded via `defineCommand`, so token-bucket math is race-free across pods on the same key.
|
|
261
|
+
|
|
262
|
+
### Bring your own
|
|
263
|
+
|
|
264
|
+
Implement `ShieldStorage` (15 methods, all small) and pass it as `storage: yourAdapter`.
|
|
265
|
+
|
|
266
|
+
## Standard headers
|
|
267
|
+
|
|
268
|
+
Default is RFC draft-7:
|
|
269
|
+
|
|
270
|
+
```http
|
|
271
|
+
RateLimit-Limit: 60
|
|
272
|
+
RateLimit-Remaining: 41
|
|
273
|
+
RateLimit-Reset: 23
|
|
274
|
+
RateLimit-Policy: 60;w=23
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
On 429, also `Retry-After: <seconds>`. Switch to draft-6 (`X-RateLimit-*`) with `rateLimit.standardHeaders = 'draft-6'`.
|
|
278
|
+
|
|
279
|
+
## Auto-ban
|
|
280
|
+
|
|
281
|
+
Every 429/403 emitted by the rate-limit, blacklist, or UA layer increments a violation counter for the offending IP, scoped by `autoBan.window`. When the counter crosses `autoBan.threshold`, the IP is placed in a temporary ban for `autoBan.banDuration`. If `escalate: true` (default), each subsequent ban doubles the previous duration.
|
|
282
|
+
|
|
283
|
+
Subsequent requests from a banned IP are rejected via a cheap `GET` on a `shield:ban:<ip>` key before any other check runs.
|
|
284
|
+
|
|
285
|
+
## Slow-down
|
|
286
|
+
|
|
287
|
+
After `slowDown.delayAfter` requests in the rate-limit window, additional requests get an artificial delay computed from `delayMs` (number or function of the over-count), capped by `maxDelayMs`. The hard rate-limit still applies — slow-down only smooths approach to the cliff.
|
|
288
|
+
|
|
289
|
+
## Behind a proxy
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
ShieldModule.forRoot({
|
|
293
|
+
trustProxy: 1, // trust 1 hop — picks XFF[length - 2]
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Or provide a custom `ipResolver`. For Cloudflare, pulling `cf-connecting-ip` is a one-liner.
|
|
298
|
+
|
|
299
|
+
## Observability
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
response: {
|
|
303
|
+
onReject: (req, res, info) => {
|
|
304
|
+
metrics.increment('shield.reject', { layer: info.layer, status: info.status });
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`info` is `{ layer, ip, reason, status, retryAfterMs? }`. The hook is wrapped in try/catch and never breaks the pipeline.
|
|
310
|
+
|
|
311
|
+
## Example
|
|
312
|
+
|
|
313
|
+
A runnable end-to-end example lives in [`example/`](./example) — see its README.
|
|
314
|
+
|
|
315
|
+
## Publishing
|
|
316
|
+
|
|
317
|
+
This repo publishes to two registries with one workflow ([.github/workflows/publish.yml](.github/workflows/publish.yml)):
|
|
318
|
+
|
|
319
|
+
| Registry | Name there | Auth |
|
|
320
|
+
| --- | --- | --- |
|
|
321
|
+
| npm public (`registry.npmjs.org`) | `nestjs-shield` | `NPM_TOKEN` repo secret |
|
|
322
|
+
| GitHub Packages (`npm.pkg.github.com`) | `@yassinos-coder/nestjs-shield` | auto `GITHUB_TOKEN` |
|
|
323
|
+
|
|
324
|
+
The workflow runs on each published GitHub Release, on pushes of `v*.*.*` tags, and via manual dispatch (where you can pick `npm`, `github`, or `both`). The GitHub Packages job renames the package on the fly with `npm pkg set name=@yassinos-coder/nestjs-shield` so the same source tree maps cleanly to both registries.
|
|
325
|
+
|
|
326
|
+
### One-time setup
|
|
327
|
+
|
|
328
|
+
1. **`NPM_TOKEN` repo secret** — required only for the npm public job.
|
|
329
|
+
- Go to [npmjs.com → Access Tokens](https://www.npmjs.com/settings/yassinoscoder/tokens) → *Generate New Token* → *Classic Token* → *Automation* (skips 2FA).
|
|
330
|
+
- In the GitHub repo: *Settings → Secrets and variables → Actions → New repository secret* → name `NPM_TOKEN`, paste the token.
|
|
331
|
+
2. **`GITHUB_TOKEN`** — nothing to do. GitHub Actions provides this automatically.
|
|
332
|
+
|
|
333
|
+
### Releasing
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
# bump version + tag
|
|
337
|
+
npm version 1.0.1
|
|
338
|
+
git push --follow-tags
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Or create a GitHub Release from the UI — the workflow fires on `release: published` too.
|
|
342
|
+
|
|
343
|
+
### Manual local publish (alternative)
|
|
344
|
+
|
|
345
|
+
If you'd rather publish from your machine instead of CI:
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
# npm public
|
|
349
|
+
npm login # browser flow
|
|
350
|
+
npm publish --access public
|
|
351
|
+
|
|
352
|
+
# GitHub Packages — needs a PAT with write:packages
|
|
353
|
+
echo "//npm.pkg.github.com/:_authToken=ghp_xxx" >> ~/.npmrc
|
|
354
|
+
npm pkg set name=@yassinos-coder/nestjs-shield
|
|
355
|
+
npm publish --registry=https://npm.pkg.github.com --access public
|
|
356
|
+
npm pkg set name=nestjs-shield # revert
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## License
|
|
360
|
+
|
|
361
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FixedWindow = void 0;
|
|
4
|
+
class FixedWindow {
|
|
5
|
+
static async check(storage, key, limit, ttlMs) {
|
|
6
|
+
return storage.fixedWindow(key, ttlMs, limit);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
exports.FixedWindow = FixedWindow;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LeakyBucket = void 0;
|
|
4
|
+
class LeakyBucket {
|
|
5
|
+
static async check(storage, key, limit, ttlMs) {
|
|
6
|
+
const leakPerMs = limit / ttlMs;
|
|
7
|
+
return storage.leakyBucket(key, limit, leakPerMs);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
exports.LeakyBucket = LeakyBucket;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SlidingWindowCounter = void 0;
|
|
4
|
+
class SlidingWindowCounter {
|
|
5
|
+
static async check(storage, key, limit, ttlMs) {
|
|
6
|
+
return storage.slidingWindowCounter(key, ttlMs, limit);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
exports.SlidingWindowCounter = SlidingWindowCounter;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SlidingWindowLog = void 0;
|
|
4
|
+
class SlidingWindowLog {
|
|
5
|
+
static async check(storage, key, limit, ttlMs) {
|
|
6
|
+
return storage.slidingWindowLog(key, ttlMs, limit);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
exports.SlidingWindowLog = SlidingWindowLog;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TokenBucket = void 0;
|
|
4
|
+
class TokenBucket {
|
|
5
|
+
static async check(storage, key, limit, ttlMs) {
|
|
6
|
+
const refillPerMs = limit / ttlMs;
|
|
7
|
+
return storage.consumeToken(key, limit, refillPerMs, 1);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
exports.TokenBucket = TokenBucket;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ShieldConfig } from './shield.types';
|
|
2
|
+
export interface NestAppLike {
|
|
3
|
+
use: (...handlers: unknown[]) => unknown;
|
|
4
|
+
get?: <T = unknown>(token: unknown) => T;
|
|
5
|
+
}
|
|
6
|
+
export declare class Shield {
|
|
7
|
+
static applyTo(app: NestAppLike, config?: ShieldConfig): void;
|
|
8
|
+
}
|
package/dist/apply-to.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Shield = void 0;
|
|
4
|
+
const shield_engine_1 = require("./shield.engine");
|
|
5
|
+
const shield_middleware_1 = require("./shield.middleware");
|
|
6
|
+
const shield_constants_1 = require("./shield.constants");
|
|
7
|
+
const memory_storage_1 = require("./storage/memory.storage");
|
|
8
|
+
const redis_storage_1 = require("./storage/redis.storage");
|
|
9
|
+
function buildStorage(option) {
|
|
10
|
+
if (!option || option === 'memory')
|
|
11
|
+
return new memory_storage_1.MemoryStorage();
|
|
12
|
+
if (typeof option === 'object' && 'type' in option) {
|
|
13
|
+
if (option.type === 'memory')
|
|
14
|
+
return new memory_storage_1.MemoryStorage({ maxKeys: option.maxKeys });
|
|
15
|
+
if (option.type === 'redis') {
|
|
16
|
+
return new redis_storage_1.RedisStorage({
|
|
17
|
+
client: option.client,
|
|
18
|
+
keyPrefix: option.keyPrefix,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return option;
|
|
23
|
+
}
|
|
24
|
+
class Shield {
|
|
25
|
+
static applyTo(app, config = {}) {
|
|
26
|
+
let engine = null;
|
|
27
|
+
if (typeof app.get === 'function') {
|
|
28
|
+
try {
|
|
29
|
+
engine = app.get(shield_constants_1.SHIELD_ENGINE);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
engine = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!engine) {
|
|
36
|
+
const storage = buildStorage(config.storage);
|
|
37
|
+
engine = new shield_engine_1.ShieldEngine(config, storage);
|
|
38
|
+
}
|
|
39
|
+
app.use((0, shield_middleware_1.createShieldMiddleware)(engine));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
exports.Shield = Shield;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ShieldStorage } from '../storage/shield-storage.interface';
|
|
2
|
+
import type { AutoBanConfig, CheckOutcome } from '../shield.types';
|
|
3
|
+
export declare class AutoBanCheck {
|
|
4
|
+
static check(storage: ShieldStorage, ip: string, config?: AutoBanConfig): Promise<CheckOutcome>;
|
|
5
|
+
static recordViolation(storage: ShieldStorage, ip: string, config?: AutoBanConfig): Promise<void>;
|
|
6
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AutoBanCheck = void 0;
|
|
4
|
+
const shield_constants_1 = require("../shield.constants");
|
|
5
|
+
class AutoBanCheck {
|
|
6
|
+
static async check(storage, ip, config) {
|
|
7
|
+
if (!config)
|
|
8
|
+
return { allowed: true };
|
|
9
|
+
const banKey = `${shield_constants_1.KEY_BAN}:${ip}`;
|
|
10
|
+
const raw = await storage.get(banKey);
|
|
11
|
+
if (!raw)
|
|
12
|
+
return { allowed: true };
|
|
13
|
+
const expiresAt = Number(raw);
|
|
14
|
+
const remaining = Math.max(0, expiresAt - Date.now());
|
|
15
|
+
if (remaining <= 0) {
|
|
16
|
+
await storage.delete(banKey);
|
|
17
|
+
return { allowed: true };
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
allowed: false,
|
|
21
|
+
layer: 'auto-ban',
|
|
22
|
+
status: 403,
|
|
23
|
+
reason: 'IP temporarily banned for repeated violations',
|
|
24
|
+
retryAfterMs: remaining,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
static async recordViolation(storage, ip, config) {
|
|
28
|
+
if (!config)
|
|
29
|
+
return;
|
|
30
|
+
const violationsKey = `${shield_constants_1.KEY_VIOLATIONS}:${ip}`;
|
|
31
|
+
const { count } = await storage.increment(violationsKey, config.window);
|
|
32
|
+
if (count < config.threshold)
|
|
33
|
+
return;
|
|
34
|
+
const banCountKey = `${shield_constants_1.KEY_BAN_COUNT}:${ip}`;
|
|
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;
|
|
38
|
+
const expiresAt = Date.now() + duration;
|
|
39
|
+
await storage.set(`${shield_constants_1.KEY_BAN}:${ip}`, String(expiresAt), duration);
|
|
40
|
+
await storage.delete(violationsKey);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
exports.AutoBanCheck = AutoBanCheck;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BlacklistCheck = void 0;
|
|
4
|
+
const ip_util_1 = require("../utils/ip.util");
|
|
5
|
+
class BlacklistCheck {
|
|
6
|
+
static run(ip, config) {
|
|
7
|
+
if (!config)
|
|
8
|
+
return { allowed: true };
|
|
9
|
+
const hit = ip_util_1.IpUtil.matches(ip, config.ips, config.cidrs);
|
|
10
|
+
if (!hit)
|
|
11
|
+
return { allowed: true };
|
|
12
|
+
return {
|
|
13
|
+
allowed: false,
|
|
14
|
+
layer: 'blacklist',
|
|
15
|
+
status: config.statusCode ?? 403,
|
|
16
|
+
reason: 'IP is blacklisted',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
exports.BlacklistCheck = BlacklistCheck;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ShieldStorage } from '../storage/shield-storage.interface';
|
|
2
|
+
import type { BurstConfig, CheckOutcome } from '../shield.types';
|
|
3
|
+
export declare class BurstCheck {
|
|
4
|
+
static check(storage: ShieldStorage, ip: string, config?: BurstConfig): Promise<CheckOutcome>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BurstCheck = void 0;
|
|
4
|
+
const shield_constants_1 = require("../shield.constants");
|
|
5
|
+
class BurstCheck {
|
|
6
|
+
static async check(storage, ip, config) {
|
|
7
|
+
if (!config)
|
|
8
|
+
return { allowed: true };
|
|
9
|
+
const key = `${shield_constants_1.KEY_BURST}:${ip}`;
|
|
10
|
+
const current = await storage.incrementConcurrent(key);
|
|
11
|
+
if (current > config.maxConcurrent) {
|
|
12
|
+
await storage.decrementConcurrent(key);
|
|
13
|
+
return {
|
|
14
|
+
allowed: false,
|
|
15
|
+
layer: 'burst',
|
|
16
|
+
status: 429,
|
|
17
|
+
reason: `Concurrent request limit ${config.maxConcurrent} exceeded`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
allowed: true,
|
|
22
|
+
release: async () => {
|
|
23
|
+
await storage.decrementConcurrent(key);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.BurstCheck = BurstCheck;
|