@idevconn/allowlist-guard 0.0.0 → 0.2.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
@@ -1,11 +1,23 @@
1
1
  # @idevconn/allowlist-guard
2
2
 
3
- Global NestJS guard that restricts routes by **source IP / CIDR**. Two independent allowlists: one for protected (authenticated) routes, one for public ("open") routes. Works on Fastify and Express. No runtime dependencies (uses `node:net` `BlockList`).
3
+ Global NestJS guard that restricts routes by **source IP / CIDR**. Two independent allowlists (one for protected/authenticated routes, one for public/"open" routes) plus a global **denylist** to block specific addresses. Works on Fastify and Express. No runtime dependencies (uses `node:net` `BlockList`).
4
+
5
+ > [!IMPORTANT]
6
+ > **What this guard does NOT protect against.**
7
+ > It decides by the caller's **IP address only**. It does **not** stop Postman, curl, scripts or Playwright/Puppeteer **coming from an allowed IP** — to the server those requests look exactly like your own front end:
8
+ >
9
+ > - `Origin`, `Referer`, `User-Agent` and `Sec-Fetch-*` headers are trivially forged by Postman/curl, so header checks give no protection.
10
+ > - Playwright/Puppeteer drive a real Chromium; headers and fingerprint are indistinguishable from a human's browser.
11
+ > - A secret embedded in a front-end bundle (API key, HMAC) is extractable in a minute.
12
+ > - Anyone with a valid login can replay requests from a script.
13
+ >
14
+ > The allowlist only helps when your legitimate clients have **known, stable IPs** (office, VPN, your own servers): a request from anywhere else gets `403`. For an **open** API (any user from the internet) an IP list is the wrong tool — use authentication, per-account rate limits and quotas, CAPTCHA / bot protection at the edge (e.g. Cloudflare Turnstile / Bot Management), and registration controls. The denylist is not a substitute either: banned clients just change address. Treat this guard as an outer perimeter, not as bot protection.
4
15
 
5
16
  ## Features
6
17
 
7
18
  - IPv4 + IPv6, single addresses and CIDR ranges; IPv4-mapped IPv6 (`::ffff:1.2.3.4`) is normalised.
8
- - Separate lists for `protected` and `public` routes. A list that is unset/blank is **not enforced**, so adopting the guard is non-breaking.
19
+ - **Allow** (`protected`, `public`) and **deny** (`deny`) lists: "only these", "everyone except these", or both. Deny always wins.
20
+ - Separate allowlists for `protected` and `public` routes. A list that is unset/blank is **not enforced**, so adopting the guard is non-breaking.
9
21
  - Loopback (`127.0.0.0/8`, `::1`) is allowed once a list is set (local dev, health probes) — disable with `allowLoopback: false` (see below).
10
22
  - `@SkipIpAllowlist()` exempts a route or controller (e.g. HMAC-signed provider webhooks whose IPs you cannot pin).
11
23
  - A malformed entry throws at boot naming the option — a typo never silently becomes "everyone allowed" or "everyone blocked".
@@ -19,21 +31,25 @@ Global NestJS guard that restricts routes by **source IP / CIDR**. Two independe
19
31
  npm install @idevconn/allowlist-guard
20
32
  ```
21
33
 
22
- Peer dependencies: `@nestjs/common`, `@nestjs/core` (10 or 11), `reflect-metadata`, `rxjs`; `fastify` only if you use the `/fastify` subpath.
34
+ Peer dependencies: `@nestjs/common`, `@nestjs/core` (10 or 11), `reflect-metadata`, `rxjs`. The `/fastify` subpath has no `fastify` dependency (it is typed structurally).
23
35
 
24
36
  ## Usage
25
37
 
38
+ ### Minimal (NestJS on Express, the default adapter)
39
+
26
40
  ```ts
41
+ // app.module.ts
27
42
  import { Module } from "@nestjs/common";
28
43
  import { IpAllowlistModule } from "@idevconn/allowlist-guard";
44
+ import { AuthModule } from "./auth/auth.module";
29
45
 
30
46
  @Module({
31
47
  imports: [
32
48
  // Import BEFORE the module that registers your auth guard, so a blocked IP
33
49
  // is rejected before any authentication work.
34
50
  IpAllowlistModule.forRoot({
35
- protected: process.env.API_IP_ALLOWLIST, // "203.0.113.5, 10.0.0.0/8"
36
- public: process.env.API_PUBLIC_IP_ALLOWLIST, // login, lead capture, ...
51
+ protected: "203.0.113.5, 10.0.0.0/8, 2001:db8::/32", // authenticated routes
52
+ public: "198.51.100.0/24", // @Public routes: login, lead capture, ...
37
53
  }),
38
54
  AuthModule,
39
55
  ],
@@ -41,61 +57,233 @@ import { IpAllowlistModule } from "@idevconn/allowlist-guard";
41
57
  export class AppModule {}
42
58
  ```
43
59
 
60
+ ```ts
61
+ // main.ts
62
+ import { NestFactory } from "@nestjs/core";
63
+ import type { NestExpressApplication } from "@nestjs/platform-express";
64
+ import { AppModule } from "./app.module";
65
+
66
+ async function bootstrap() {
67
+ const app = await NestFactory.create<NestExpressApplication>(AppModule);
68
+ app.set("trust proxy", 1); // behind ONE reverse proxy; see "Getting the real client IP"
69
+ await app.listen(3000);
70
+ }
71
+ bootstrap();
72
+ ```
73
+
74
+ ### Options
75
+
44
76
  | Option | Meaning |
45
77
  | ------------------- | ---------------------------------------------------------------------------------------------------- |
46
78
  | `protected` | Comma-separated string or array of IPs/CIDRs for non-public routes. Unset/blank = not enforced. |
47
79
  | `public` | Same, for public routes. |
80
+ | `deny` | Denylist for **all** routes: comma-separated string or array of IPs/CIDRs that are always rejected. Wins over the allowlists. Unset/blank = nothing denied. |
48
81
  | `publicMetadataKey` | Metadata key marking a route public. Default `'isPublic'` (the common `@Public()` decorator's key). |
49
82
  | `allowLoopback` | Default `true`. Set `false` if a reverse proxy runs on the same host (see below). |
50
- | `onBlocked` | `(ip) => void`, called on every blocked request. Default: a rate-limited Nest `Logger.warn`. |
83
+ | `onBlocked` | `(ip, reason) => void`, called on every blocked request; `reason` is `"denied"` or `"not_allowed"`. Default: a rate-limited Nest `Logger.warn`. |
51
84
 
52
85
  **The two lists are independent.** Setting only `protected` leaves `@Public` routes (login, lead capture, ...) open to everyone, and vice versa. Set both if you want the whole surface restricted; exempt individual routes with `@SkipIpAllowlist()`.
53
86
 
54
- Exempt a route:
87
+ ### Configuration from the environment (`@nestjs/config`)
88
+
89
+ `forRoot()` takes plain values, so read them from `process.env`. `ConfigModule.forRoot()` loads `.env` synchronously, so list it **before** the allowlist module:
55
90
 
56
91
  ```ts
92
+ @Module({
93
+ imports: [
94
+ ConfigModule.forRoot({ isGlobal: true }),
95
+ IpAllowlistModule.forRoot({
96
+ protected: process.env.API_IP_ALLOWLIST, // unset/blank => not enforced
97
+ public: process.env.API_PUBLIC_IP_ALLOWLIST,
98
+ allowLoopback: process.env.API_IP_ALLOWLIST_ALLOW_LOOPBACK !== "false",
99
+ }),
100
+ AuthModule,
101
+ ],
102
+ })
103
+ export class AppModule {}
104
+ ```
105
+
106
+ ```bash
107
+ # .env
108
+ API_IP_ALLOWLIST=203.0.113.5,10.0.0.0/8
109
+ API_PUBLIC_IP_ALLOWLIST=198.51.100.0/24
110
+ ```
111
+
112
+ A malformed entry (`API_IP_ALLOWLIST=203.0.113.5,banana`) throws at boot naming the option, so a typo never silently opens or closes the API.
113
+
114
+ ### NestJS on Fastify
115
+
116
+ ```ts
117
+ // main.ts
118
+ import { NestFactory } from "@nestjs/core";
119
+ import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify";
120
+ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
121
+ import { buildTrustProxy, parseIpAllowlist, restrictRoutesToAllowlist } from "@idevconn/allowlist-guard/fastify";
122
+ import { AppModule } from "./app.module";
123
+
124
+ async function bootstrap() {
125
+ const app = await NestFactory.create<NestFastifyApplication>(
126
+ AppModule,
127
+ new FastifyAdapter({
128
+ // One trusted hop, and only when the socket peer is one of YOUR proxies.
129
+ trustProxy: buildTrustProxy(process.env.TRUSTED_PROXIES),
130
+ }),
131
+ );
132
+
133
+ // Swagger UI is a Fastify route outside Nest's guard pipeline: restrict it too,
134
+ // BEFORE SwaggerModule.setup registers it.
135
+ const allowed = parseIpAllowlist(process.env.API_IP_ALLOWLIST);
136
+ const denied = parseIpAllowlist(process.env.API_IP_DENYLIST, "deny", { allowLoopback: false });
137
+ if (allowed || denied) {
138
+ restrictRoutesToAllowlist(app.getHttpAdapter().getInstance(), allowed, "/api/docs", denied);
139
+ }
140
+ const document = SwaggerModule.createDocument(app, new DocumentBuilder().build());
141
+ SwaggerModule.setup("api/docs", app, document);
142
+
143
+ await app.listen(3000, "0.0.0.0");
144
+ }
145
+ bootstrap();
146
+ ```
147
+
148
+ ### What gets blocked: allow vs deny
149
+
150
+ | Configured | Result |
151
+ | ---------------------------------------- | ---------------------------------------------------------------------------------------------- |
152
+ | nothing | Guard off: everyone is let in (today's behaviour). |
153
+ | `deny` only | **Everyone except the listed IPs/CIDRs** ("block these, allow the rest"). |
154
+ | `protected` and/or `public` only | **Only the listed IPs**, per route class (the other class stays open). |
155
+ | allowlist(s) + `deny` | Allowed IPs get in, **except** any that are also denied. Deny always wins. |
156
+
157
+ Order of checks per request: RPC contexts are exempt → **`deny`** (every route, including `@SkipIpAllowlist` ones) → `@SkipIpAllowlist()` → the route's allowlist (`public` for public routes, otherwise `protected`).
158
+
159
+ ### Blocking specific IPs (denylist)
160
+
161
+ Let everyone in, but reject some addresses:
162
+
163
+ ```ts
164
+ IpAllowlistModule.forRoot({
165
+ // IPs and CIDRs (IPv4 + IPv6), comma-separated string or array.
166
+ deny: "203.0.113.5, 198.51.100.0/24, 2001:db8::/32",
167
+ });
168
+ ```
169
+
170
+ Combine with an allowlist — e.g. a whole office range except one compromised host:
171
+
172
+ ```ts
173
+ IpAllowlistModule.forRoot({
174
+ protected: "10.0.0.0/8",
175
+ deny: ["10.1.2.3"], // inside the allowed range, still blocked
176
+ });
177
+ ```
178
+
179
+ From the environment:
180
+
181
+ ```ts
182
+ IpAllowlistModule.forRoot({
183
+ protected: process.env.API_IP_ALLOWLIST,
184
+ public: process.env.API_PUBLIC_IP_ALLOWLIST,
185
+ deny: process.env.API_IP_DENYLIST, // "203.0.113.5,198.51.100.0/24"
186
+ });
187
+ ```
188
+
189
+ Notes:
190
+
191
+ - `deny` applies to **every** route, including ones marked `@SkipIpAllowlist()` (e.g. webhooks): a banned address is banned everywhere. Be careful not to list a range your providers call from.
192
+ - Loopback is **never** denied implicitly (only if you list it). It is also not allowed implicitly by `deny` alone, because without an allowlist everything not denied is allowed anyway.
193
+ - A malformed entry throws at boot (`deny: invalid IP/CIDR "banana"`), so a typo never silently disables the ban.
194
+ - Identify why a request was blocked in `onBlocked((ip, reason) => ...)`: `"denied"` vs `"not_allowed"`. The default log line differs too (`denylisted IP` / `non-allowlisted IP`).
195
+ - Both outcomes return the same `403 ip_not_allowed`.
196
+ - Without an allowlist the guard can only judge requests that carry a client IP (HTTP). Non-HTTP contexts such as GraphQL/WebSocket have no resolvable `req.ip` here, so a deny-only setup cannot ban them (with an allowlist they are denied, fail closed).
197
+
198
+ **Limits of IP bans.** An address changes with a VPN, proxy or mobile network, and a ban on a shared address (office, carrier NAT) blocks everyone behind it. To stop a specific *person*, suspend their account; use the denylist for abusive addresses, scanners and floods. For permanent, high-volume blocking prefer the edge (nginx `deny`, Cloudflare WAF, firewall), which stops traffic before it reaches your app.
199
+
200
+ ### Exempting routes
201
+
202
+ ```ts
203
+ import { Controller, Post, SetMetadata } from "@nestjs/common";
57
204
  import { SkipIpAllowlist } from "@idevconn/allowlist-guard";
58
205
 
59
- @Public()
60
- @SkipIpAllowlist() // authenticated by an HMAC signature instead
61
- @Post("webhooks/:provider")
62
- receive() {}
206
+ const Public = () => SetMetadata("isPublic", true); // your existing @Public()
207
+
208
+ @Controller("webhooks")
209
+ export class WebhooksController {
210
+ // Providers call from IPs you cannot pin; authenticate by HMAC signature instead.
211
+ @Public()
212
+ @SkipIpAllowlist()
213
+ @Post(":provider")
214
+ receive() {}
215
+ }
63
216
  ```
64
217
 
65
- ## Getting the real client IP (read this)
218
+ `@SkipIpAllowlist()` also works on a whole controller class (e.g. a health check controller). It exempts a route from the **allowlists only**; the `deny` list still applies.
66
219
 
67
- The guard reads `request.ip` and **never** parses `X-Forwarded-For` itself. Behind a reverse proxy/load balancer, configure your framework's trust-proxy setting so `req.ip` is the real client:
220
+ ### Using your own "public" decorator
68
221
 
69
- - **Express:** `app.set("trust proxy", 1)`.
70
- - **Fastify:**
222
+ If your project marks open routes with a different metadata key, tell the guard:
71
223
 
72
- ```ts
73
- import { buildTrustProxy } from "@idevconn/allowlist-guard/fastify";
224
+ ```ts
225
+ export const OPEN_ROUTE = "openRoute";
226
+ export const Open = () => SetMetadata(OPEN_ROUTE, true);
74
227
 
75
- new FastifyAdapter({ trustProxy: buildTrustProxy(process.env.TRUSTED_PROXIES) });
76
- ```
228
+ IpAllowlistModule.forRoot({ public: "198.51.100.0/24", publicMetadataKey: OPEN_ROUTE });
229
+ ```
77
230
 
78
- `buildTrustProxy()` trusts one hop. Hop 0 is the socket peer, so **if the app port is reachable directly, any client can forge `X-Forwarded-For` and pass the allowlist**. Either make the port reachable only via your proxy, or pass `trustedProxies` (the proxy's IP/CIDR) so a direct client's header is ignored.
231
+ ### Alerting on blocked requests
79
232
 
80
- Without any trust-proxy setting `req.ip` is the proxy's own address for every client. If that proxy runs on the **same host**, that address is loopback — which is allowed by default, so the allowlist would be wide open. Set `allowLoopback: false` in that setup (and keep trust-proxy configured).
233
+ `onBlocked` runs on **every** blocked request (the default log line is rate-limited to one per IP per minute), so feed metrics from it:
81
234
 
82
- ## Routes outside Nest (Swagger UI)
235
+ ```ts
236
+ IpAllowlistModule.forRoot({
237
+ protected: process.env.API_IP_ALLOWLIST,
238
+ onBlocked: (ip, reason) =>
239
+ metrics.increment("ip_allowlist.blocked", { ip: ip ?? "unknown", reason }), // "denied" | "not_allowed"
240
+ });
241
+ ```
83
242
 
84
- Fastify-native routes never reach Nest guards. Restrict them with the same list, **before** registering them:
243
+ ### Using the matcher on its own
85
244
 
86
245
  ```ts
87
- import { parseIpAllowlist, restrictRoutesToAllowlist } from "@idevconn/allowlist-guard/fastify";
246
+ import { parseIpAllowlist } from "@idevconn/allowlist-guard";
247
+
248
+ const isAllowed = parseIpAllowlist("203.0.113.5, 10.0.0.0/8", "myList");
249
+ isAllowed?.("10.4.5.6"); // true (returns null when the list is blank = not enforced)
250
+ isAllowed?.("::ffff:203.0.113.5"); // true (IPv4-mapped IPv6 is normalised)
251
+ isAllowed?.("8.8.8.8"); // false
252
+
253
+ // A denylist matcher: no implicit loopback.
254
+ const isBanned = parseIpAllowlist("203.0.113.5", "deny", { allowLoopback: false });
255
+ isBanned?.("203.0.113.5"); // true
256
+ ```
257
+
258
+ ### Local development and tests
259
+
260
+ Leave the lists (`protected`, `public`, `deny`) unset: nothing is enforced, so `localhost`, Postman and e2e tests work as usual. With a list set, loopback (`127.0.0.1`, `::1`) is still allowed unless `allowLoopback: false`. In e2e tests either leave the lists unset or set `protected` to a test IP and send requests from it (Fastify `app.inject({ remoteAddress })`; Express with `trust proxy` + `X-Forwarded-For`).
88
261
 
89
- const allowed = parseIpAllowlist(process.env.API_IP_ALLOWLIST);
90
- if (allowed) restrictRoutesToAllowlist(app.getHttpAdapter().getInstance(), allowed, "/api/docs");
91
- SwaggerModule.setup("api/docs", app, document);
262
+ ## Getting the real client IP (read this)
263
+
264
+ The guard reads `request.ip` and **never** parses `X-Forwarded-For` itself. Behind a reverse proxy/load balancer, configure your framework's trust-proxy setting so `req.ip` is the real client:
265
+
266
+ - **Express:** `app.set("trust proxy", 1)` (one proxy in front).
267
+ - **Fastify:** `buildTrustProxy(...)` from `@idevconn/allowlist-guard/fastify` (see the Fastify example above). It trusts one hop. Hop 0 is the socket peer, so **if the app port is reachable directly, any client can forge `X-Forwarded-For` and pass the allowlist**. Either make the port reachable only via your proxy, or pass `trustedProxies` (the proxy's IP/CIDR) so a direct client's header is ignored.
268
+
269
+ Make the proxy **overwrite** the header with the address it saw, not append to a client-supplied one. nginx:
270
+
271
+ ```nginx
272
+ location /api/ {
273
+ proxy_pass http://127.0.0.1:3000;
274
+ proxy_set_header X-Forwarded-For $remote_addr; # overwrite, do not use $proxy_add_x_forwarded_for
275
+ }
92
276
  ```
93
277
 
94
- It matches on the resolved route (`routeOptions.url`), not the raw URL, so percent-encoded paths such as `/api/%64ocs` cannot slip past.
278
+ Without any trust-proxy setting `req.ip` is the proxy's own address for every client. If that proxy runs on the **same host**, that address is loopback — which is allowed by default, so the allowlist would be wide open. Set `allowLoopback: false` in that setup (and keep trust-proxy configured). Behind a CDN (e.g. Cloudflare) the proxy chain has two hops; configure the trusted hop count/CIDRs of your CDN accordingly rather than trusting arbitrary headers.
279
+
280
+ ## Routes outside Nest (Swagger UI)
281
+
282
+ Fastify-native routes never reach Nest guards; `restrictRoutesToAllowlist(instance, allowed, routePrefix, denied?)` (see the Fastify example) applies the same lists to them. Pass `null` as `allowed` to use only a denylist. It matches on the resolved route (`routeOptions.url`), not the raw URL, so percent-encoded paths such as `/api/%64ocs` cannot slip past.
95
283
 
96
284
  ## Limits
97
285
 
98
- An IP allowlist only helps when your legitimate clients have known IPs. It cannot tell a browser from Postman/Playwright on the same IP — use authentication, rate limiting and bot protection for open APIs.
286
+ See the notice at the top: an IP allowlist is a network perimeter, not bot protection. It cannot tell a browser from Postman or Playwright on the same IP.
99
287
 
100
288
  ## License
101
289
 
package/dist/fastify.cjs CHANGED
@@ -75,10 +75,11 @@ function buildTrustProxy(trustedProxies) {
75
75
  const matcher = parseIpAllowlist(trustedProxies, "trustedProxies");
76
76
  return (address, hop) => hop === 0 && (!matcher || matcher(address));
77
77
  }
78
- function restrictRoutesToAllowlist(instance, allowed, routePrefix) {
78
+ function restrictRoutesToAllowlist(instance, allowed, routePrefix, denied = null) {
79
79
  instance.addHook("onRequest", (req, reply, done) => {
80
80
  const route = req.routeOptions?.url ?? "";
81
- if (route.startsWith(routePrefix) && !allowed(req.ip)) {
81
+ const blocked = route.startsWith(routePrefix) && (denied?.(req.ip) || allowed && !allowed(req.ip));
82
+ if (blocked) {
82
83
  void reply.code(403).send({ statusCode: 403, message: "ip_not_allowed" });
83
84
  return;
84
85
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/fastify/index.ts","../src/ip-matcher.ts"],"sourcesContent":["import type { FastifyInstance } from \"fastify\";\nimport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n\n/**\n * Fastify `trustProxy` function equal to Express's `trust proxy = 1`: trusts\n * only hop 0 (the socket peer, i.e. your reverse proxy), so a client cannot\n * walk past it with extra X-Forwarded-For entries.\n *\n * Hop 0 is the socket peer itself: if the app port is reachable directly (not\n * only through the proxy), ANY client is hop 0 and can forge X-Forwarded-For,\n * defeating the allowlist. Pass `trustedProxies` (IPs/CIDRs of your proxy) to\n * trust hop 0 only when the peer is one of them; a direct client's header is\n * then ignored. Loopback is always included.\n */\nexport function buildTrustProxy(\n trustedProxies?: string | readonly string[],\n): (address: string, hop: number) => boolean {\n const matcher = parseIpAllowlist(trustedProxies, \"trustedProxies\");\n return (address, hop) => hop === 0 && (!matcher || matcher(address));\n}\n\n/**\n * Restricts Fastify-native routes that live outside Nest's guard pipeline\n * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE\n * those routes are added.\n *\n * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw\n * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`\n * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.\n */\nexport function restrictRoutesToAllowlist(\n instance: FastifyInstance,\n allowed: IpMatcher,\n routePrefix: string,\n): void {\n instance.addHook(\"onRequest\", (req, reply, done) => {\n const route = req.routeOptions?.url ?? \"\";\n if (route.startsWith(routePrefix) && !allowed(req.ip)) {\n void reply.code(403).send({ statusCode: 403, message: \"ip_not_allowed\" });\n return;\n }\n done();\n });\n}\n\nexport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n","import { BlockList, isIP } from \"node:net\";\n\nexport type IpMatcher = (ip: string | undefined) => boolean;\n\nexport interface ParseOptions {\n /**\n * Always allow loopback (`127.0.0.0/8`, `::1`) once a list is set. Default `true`\n * (local dev, health probes). Set `false` when a reverse proxy runs on the SAME\n * host and `req.ip` may fall back to its loopback address — otherwise a proxy\n * without trust-proxy configured makes every client look like 127.0.0.1.\n */\n allowLoopback?: boolean;\n}\n\nconst LOOPBACK = [\"127.0.0.0/8\", \"::1\"] as const;\n\n/** `::ffff:1.2.3.4` (what a dual-stack socket reports for IPv4 peers) → `1.2.3.4`. */\nexport function normalizeIp(ip: string): string {\n const mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i.exec(ip);\n return mapped?.[1] ?? ip;\n}\n\nfunction addEntry(list: BlockList, raw: string): void {\n const [addr = \"\", prefix, ...rest] = raw.split(\"/\");\n const family = isIP(addr);\n // A zone id (`fe80::1%eth0`) is accepted by isIP but ignored by BlockList.check,\n // so `::1%x` would match loopback — never allow one in a list or as a client IP.\n if (!family || addr.includes(\"%\") || rest.length > 0) {\n throw new Error(`invalid IP/CIDR \"${raw}\"`);\n }\n const type = family === 4 ? \"ipv4\" : \"ipv6\";\n if (prefix === undefined) {\n list.addAddress(addr, type);\n return;\n }\n const bits = Number(prefix);\n const max = family === 4 ? 32 : 128;\n if (!/^\\d+$/.test(prefix) || bits > max) throw new Error(`invalid IP/CIDR \"${raw}\"`);\n list.addSubnet(addr, bits, type);\n}\n\n/**\n * Builds a matcher from a comma-separated string or an array of IPs/CIDRs\n * (IPv4 + IPv6). Returns `null` when the list is unset/blank (= not enforced).\n * Loopback is part of an enforced list by default (see `allowLoopback`). Throws on a malformed entry — a typo must not silently turn\n * into \"everyone allowed\" or \"everyone blocked\". `label` names the option in\n * the error message.\n */\nexport function parseIpAllowlist(\n value: string | readonly string[] | undefined,\n label = \"allowlist\",\n { allowLoopback = true }: ParseOptions = {},\n): IpMatcher | null {\n const raw = typeof value === \"string\" ? value.split(\",\") : (value ?? []);\n const entries = raw.map((s) => s.trim()).filter(Boolean);\n if (entries.length === 0) return null;\n\n const list = new BlockList();\n for (const entry of [...(allowLoopback ? LOOPBACK : []), ...entries]) {\n try {\n addEntry(list, entry);\n } catch (err) {\n throw new Error(`${label}: ${(err as Error).message}`, { cause: err });\n }\n }\n return (ip) => {\n if (!ip || ip.includes(\"%\")) return false;\n const addr = normalizeIp(ip);\n const family = isIP(addr);\n if (!family) return false;\n return list.check(addr, family === 4 ? \"ipv4\" : \"ipv6\");\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,sBAAgC;AAchC,IAAM,WAAW,CAAC,eAAe,KAAK;AAG/B,SAAS,YAAY,IAAoB;AAC9C,QAAM,SAAS,sCAAsC,KAAK,EAAE;AAC5D,SAAO,SAAS,CAAC,KAAK;AACxB;AAEA,SAAS,SAAS,MAAiB,KAAmB;AACpD,QAAM,CAAC,OAAO,IAAI,QAAQ,GAAG,IAAI,IAAI,IAAI,MAAM,GAAG;AAClD,QAAM,aAAS,sBAAK,IAAI;AAGxB,MAAI,CAAC,UAAU,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,GAAG;AACpD,UAAM,IAAI,MAAM,oBAAoB,GAAG,GAAG;AAAA,EAC5C;AACA,QAAM,OAAO,WAAW,IAAI,SAAS;AACrC,MAAI,WAAW,QAAW;AACxB,SAAK,WAAW,MAAM,IAAI;AAC1B;AAAA,EACF;AACA,QAAM,OAAO,OAAO,MAAM;AAC1B,QAAM,MAAM,WAAW,IAAI,KAAK;AAChC,MAAI,CAAC,QAAQ,KAAK,MAAM,KAAK,OAAO,IAAK,OAAM,IAAI,MAAM,oBAAoB,GAAG,GAAG;AACnF,OAAK,UAAU,MAAM,MAAM,IAAI;AACjC;AASO,SAAS,iBACd,OACA,QAAQ,aACR,EAAE,gBAAgB,KAAK,IAAkB,CAAC,GACxB;AAClB,QAAM,MAAM,OAAO,UAAU,WAAW,MAAM,MAAM,GAAG,IAAK,SAAS,CAAC;AACtE,QAAM,UAAU,IAAI,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AACvD,MAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,QAAM,OAAO,IAAI,0BAAU;AAC3B,aAAW,SAAS,CAAC,GAAI,gBAAgB,WAAW,CAAC,GAAI,GAAG,OAAO,GAAG;AACpE,QAAI;AACF,eAAS,MAAM,KAAK;AAAA,IACtB,SAAS,KAAK;AACZ,YAAM,IAAI,MAAM,GAAG,KAAK,KAAM,IAAc,OAAO,IAAI,EAAE,OAAO,IAAI,CAAC;AAAA,IACvE;AAAA,EACF;AACA,SAAO,CAAC,OAAO;AACb,QAAI,CAAC,MAAM,GAAG,SAAS,GAAG,EAAG,QAAO;AACpC,UAAM,OAAO,YAAY,EAAE;AAC3B,UAAM,aAAS,sBAAK,IAAI;AACxB,QAAI,CAAC,OAAQ,QAAO;AACpB,WAAO,KAAK,MAAM,MAAM,WAAW,IAAI,SAAS,MAAM;AAAA,EACxD;AACF;;;AD1DO,SAAS,gBACd,gBAC2C;AAC3C,QAAM,UAAU,iBAAiB,gBAAgB,gBAAgB;AACjE,SAAO,CAAC,SAAS,QAAQ,QAAQ,MAAM,CAAC,WAAW,QAAQ,OAAO;AACpE;AAWO,SAAS,0BACd,UACA,SACA,aACM;AACN,WAAS,QAAQ,aAAa,CAAC,KAAK,OAAO,SAAS;AAClD,UAAM,QAAQ,IAAI,cAAc,OAAO;AACvC,QAAI,MAAM,WAAW,WAAW,KAAK,CAAC,QAAQ,IAAI,EAAE,GAAG;AACrD,WAAK,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,YAAY,KAAK,SAAS,iBAAiB,CAAC;AACxE;AAAA,IACF;AACA,SAAK;AAAA,EACP,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../src/fastify/index.ts","../src/ip-matcher.ts"],"sourcesContent":["import { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n\n/**\n * Fastify `trustProxy` function equal to Express's `trust proxy = 1`: trusts\n * only hop 0 (the socket peer, i.e. your reverse proxy), so a client cannot\n * walk past it with extra X-Forwarded-For entries.\n *\n * Hop 0 is the socket peer itself: if the app port is reachable directly (not\n * only through the proxy), ANY client is hop 0 and can forge X-Forwarded-For,\n * defeating the allowlist. Pass `trustedProxies` (IPs/CIDRs of your proxy) to\n * trust hop 0 only when the peer is one of them; a direct client's header is\n * then ignored. Loopback is always included.\n */\nexport function buildTrustProxy(\n trustedProxies?: string | readonly string[],\n): (address: string, hop: number) => boolean {\n const matcher = parseIpAllowlist(trustedProxies, \"trustedProxies\");\n return (address, hop) => hop === 0 && (!matcher || matcher(address));\n}\n\n/**\n * Structural slice of a Fastify instance — deliberately not `FastifyInstance`, so\n * it type-checks even when the app and `@nestjs/platform-fastify` resolve two\n * different copies of `fastify`.\n */\nexport interface FastifyHookHost {\n addHook(\n name: \"onRequest\",\n hook: (\n req: { ip: string; routeOptions?: { url?: string } },\n reply: { code(statusCode: number): { send(payload: unknown): unknown } },\n done: () => void,\n ) => void,\n ): unknown;\n}\n\n/**\n * Restricts Fastify-native routes that live outside Nest's guard pipeline\n * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE\n * those routes are added.\n *\n * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw\n * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`\n * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.\n *\n * `allowed` rejects IPs not on it (pass `null` to skip); `denied` rejects IPs on it.\n */\nexport function restrictRoutesToAllowlist(\n instance: FastifyHookHost,\n allowed: IpMatcher | null,\n routePrefix: string,\n denied: IpMatcher | null = null,\n): void {\n instance.addHook(\"onRequest\", (req, reply, done) => {\n const route = req.routeOptions?.url ?? \"\";\n const blocked = route.startsWith(routePrefix) && (denied?.(req.ip) || (allowed && !allowed(req.ip)));\n if (blocked) {\n void reply.code(403).send({ statusCode: 403, message: \"ip_not_allowed\" });\n return;\n }\n done();\n });\n}\n\nexport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n","import { BlockList, isIP } from \"node:net\";\n\nexport type IpMatcher = (ip: string | undefined) => boolean;\n\nexport interface ParseOptions {\n /**\n * Always allow loopback (`127.0.0.0/8`, `::1`) once a list is set. Default `true`\n * (local dev, health probes). Set `false` when a reverse proxy runs on the SAME\n * host and `req.ip` may fall back to its loopback address — otherwise a proxy\n * without trust-proxy configured makes every client look like 127.0.0.1.\n */\n allowLoopback?: boolean;\n}\n\nconst LOOPBACK = [\"127.0.0.0/8\", \"::1\"] as const;\n\n/** `::ffff:1.2.3.4` (what a dual-stack socket reports for IPv4 peers) → `1.2.3.4`. */\nexport function normalizeIp(ip: string): string {\n const mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i.exec(ip);\n return mapped?.[1] ?? ip;\n}\n\nfunction addEntry(list: BlockList, raw: string): void {\n const [addr = \"\", prefix, ...rest] = raw.split(\"/\");\n const family = isIP(addr);\n // A zone id (`fe80::1%eth0`) is accepted by isIP but ignored by BlockList.check,\n // so `::1%x` would match loopback — never allow one in a list or as a client IP.\n if (!family || addr.includes(\"%\") || rest.length > 0) {\n throw new Error(`invalid IP/CIDR \"${raw}\"`);\n }\n const type = family === 4 ? \"ipv4\" : \"ipv6\";\n if (prefix === undefined) {\n list.addAddress(addr, type);\n return;\n }\n const bits = Number(prefix);\n const max = family === 4 ? 32 : 128;\n if (!/^\\d+$/.test(prefix) || bits > max) throw new Error(`invalid IP/CIDR \"${raw}\"`);\n list.addSubnet(addr, bits, type);\n}\n\n/**\n * Builds a matcher from a comma-separated string or an array of IPs/CIDRs\n * (IPv4 + IPv6). Returns `null` when the list is unset/blank (= not enforced).\n * Loopback is part of an enforced list by default (see `allowLoopback`). Throws on a malformed entry — a typo must not silently turn\n * into \"everyone allowed\" or \"everyone blocked\". `label` names the option in\n * the error message.\n */\nexport function parseIpAllowlist(\n value: string | readonly string[] | undefined,\n label = \"allowlist\",\n { allowLoopback = true }: ParseOptions = {},\n): IpMatcher | null {\n const raw = typeof value === \"string\" ? value.split(\",\") : (value ?? []);\n const entries = raw.map((s) => s.trim()).filter(Boolean);\n if (entries.length === 0) return null;\n\n const list = new BlockList();\n for (const entry of [...(allowLoopback ? LOOPBACK : []), ...entries]) {\n try {\n addEntry(list, entry);\n } catch (err) {\n throw new Error(`${label}: ${(err as Error).message}`, { cause: err });\n }\n }\n return (ip) => {\n if (!ip || ip.includes(\"%\")) return false;\n const addr = normalizeIp(ip);\n const family = isIP(addr);\n if (!family) return false;\n return list.check(addr, family === 4 ? \"ipv4\" : \"ipv6\");\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,sBAAgC;AAchC,IAAM,WAAW,CAAC,eAAe,KAAK;AAG/B,SAAS,YAAY,IAAoB;AAC9C,QAAM,SAAS,sCAAsC,KAAK,EAAE;AAC5D,SAAO,SAAS,CAAC,KAAK;AACxB;AAEA,SAAS,SAAS,MAAiB,KAAmB;AACpD,QAAM,CAAC,OAAO,IAAI,QAAQ,GAAG,IAAI,IAAI,IAAI,MAAM,GAAG;AAClD,QAAM,aAAS,sBAAK,IAAI;AAGxB,MAAI,CAAC,UAAU,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,GAAG;AACpD,UAAM,IAAI,MAAM,oBAAoB,GAAG,GAAG;AAAA,EAC5C;AACA,QAAM,OAAO,WAAW,IAAI,SAAS;AACrC,MAAI,WAAW,QAAW;AACxB,SAAK,WAAW,MAAM,IAAI;AAC1B;AAAA,EACF;AACA,QAAM,OAAO,OAAO,MAAM;AAC1B,QAAM,MAAM,WAAW,IAAI,KAAK;AAChC,MAAI,CAAC,QAAQ,KAAK,MAAM,KAAK,OAAO,IAAK,OAAM,IAAI,MAAM,oBAAoB,GAAG,GAAG;AACnF,OAAK,UAAU,MAAM,MAAM,IAAI;AACjC;AASO,SAAS,iBACd,OACA,QAAQ,aACR,EAAE,gBAAgB,KAAK,IAAkB,CAAC,GACxB;AAClB,QAAM,MAAM,OAAO,UAAU,WAAW,MAAM,MAAM,GAAG,IAAK,SAAS,CAAC;AACtE,QAAM,UAAU,IAAI,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AACvD,MAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,QAAM,OAAO,IAAI,0BAAU;AAC3B,aAAW,SAAS,CAAC,GAAI,gBAAgB,WAAW,CAAC,GAAI,GAAG,OAAO,GAAG;AACpE,QAAI;AACF,eAAS,MAAM,KAAK;AAAA,IACtB,SAAS,KAAK;AACZ,YAAM,IAAI,MAAM,GAAG,KAAK,KAAM,IAAc,OAAO,IAAI,EAAE,OAAO,IAAI,CAAC;AAAA,IACvE;AAAA,EACF;AACA,SAAO,CAAC,OAAO;AACb,QAAI,CAAC,MAAM,GAAG,SAAS,GAAG,EAAG,QAAO;AACpC,UAAM,OAAO,YAAY,EAAE;AAC3B,UAAM,aAAS,sBAAK,IAAI;AACxB,QAAI,CAAC,OAAQ,QAAO;AACpB,WAAO,KAAK,MAAM,MAAM,WAAW,IAAI,SAAS,MAAM;AAAA,EACxD;AACF;;;AD3DO,SAAS,gBACd,gBAC2C;AAC3C,QAAM,UAAU,iBAAiB,gBAAgB,gBAAgB;AACjE,SAAO,CAAC,SAAS,QAAQ,QAAQ,MAAM,CAAC,WAAW,QAAQ,OAAO;AACpE;AA6BO,SAAS,0BACd,UACA,SACA,aACA,SAA2B,MACrB;AACN,WAAS,QAAQ,aAAa,CAAC,KAAK,OAAO,SAAS;AAClD,UAAM,QAAQ,IAAI,cAAc,OAAO;AACvC,UAAM,UAAU,MAAM,WAAW,WAAW,MAAM,SAAS,IAAI,EAAE,KAAM,WAAW,CAAC,QAAQ,IAAI,EAAE;AACjG,QAAI,SAAS;AACX,WAAK,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,YAAY,KAAK,SAAS,iBAAiB,CAAC;AACxE;AAAA,IACF;AACA,SAAK;AAAA,EACP,CAAC;AACH;","names":[]}
@@ -1,4 +1,3 @@
1
- import { FastifyInstance } from 'fastify';
2
1
  import { I as IpMatcher } from './ip-matcher-D4MA1kIq.cjs';
3
2
  export { p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.cjs';
4
3
 
@@ -14,6 +13,23 @@ export { p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.cjs';
14
13
  * then ignored. Loopback is always included.
15
14
  */
16
15
  declare function buildTrustProxy(trustedProxies?: string | readonly string[]): (address: string, hop: number) => boolean;
16
+ /**
17
+ * Structural slice of a Fastify instance — deliberately not `FastifyInstance`, so
18
+ * it type-checks even when the app and `@nestjs/platform-fastify` resolve two
19
+ * different copies of `fastify`.
20
+ */
21
+ interface FastifyHookHost {
22
+ addHook(name: "onRequest", hook: (req: {
23
+ ip: string;
24
+ routeOptions?: {
25
+ url?: string;
26
+ };
27
+ }, reply: {
28
+ code(statusCode: number): {
29
+ send(payload: unknown): unknown;
30
+ };
31
+ }, done: () => void) => void): unknown;
32
+ }
17
33
  /**
18
34
  * Restricts Fastify-native routes that live outside Nest's guard pipeline
19
35
  * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE
@@ -22,7 +38,9 @@ declare function buildTrustProxy(trustedProxies?: string | readonly string[]): (
22
38
  * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw
23
39
  * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`
24
40
  * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.
41
+ *
42
+ * `allowed` rejects IPs not on it (pass `null` to skip); `denied` rejects IPs on it.
25
43
  */
26
- declare function restrictRoutesToAllowlist(instance: FastifyInstance, allowed: IpMatcher, routePrefix: string): void;
44
+ declare function restrictRoutesToAllowlist(instance: FastifyHookHost, allowed: IpMatcher | null, routePrefix: string, denied?: IpMatcher | null): void;
27
45
 
28
- export { IpMatcher, buildTrustProxy, restrictRoutesToAllowlist };
46
+ export { type FastifyHookHost, IpMatcher, buildTrustProxy, restrictRoutesToAllowlist };
package/dist/fastify.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { FastifyInstance } from 'fastify';
2
1
  import { I as IpMatcher } from './ip-matcher-D4MA1kIq.js';
3
2
  export { p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.js';
4
3
 
@@ -14,6 +13,23 @@ export { p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.js';
14
13
  * then ignored. Loopback is always included.
15
14
  */
16
15
  declare function buildTrustProxy(trustedProxies?: string | readonly string[]): (address: string, hop: number) => boolean;
16
+ /**
17
+ * Structural slice of a Fastify instance — deliberately not `FastifyInstance`, so
18
+ * it type-checks even when the app and `@nestjs/platform-fastify` resolve two
19
+ * different copies of `fastify`.
20
+ */
21
+ interface FastifyHookHost {
22
+ addHook(name: "onRequest", hook: (req: {
23
+ ip: string;
24
+ routeOptions?: {
25
+ url?: string;
26
+ };
27
+ }, reply: {
28
+ code(statusCode: number): {
29
+ send(payload: unknown): unknown;
30
+ };
31
+ }, done: () => void) => void): unknown;
32
+ }
17
33
  /**
18
34
  * Restricts Fastify-native routes that live outside Nest's guard pipeline
19
35
  * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE
@@ -22,7 +38,9 @@ declare function buildTrustProxy(trustedProxies?: string | readonly string[]): (
22
38
  * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw
23
39
  * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`
24
40
  * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.
41
+ *
42
+ * `allowed` rejects IPs not on it (pass `null` to skip); `denied` rejects IPs on it.
25
43
  */
26
- declare function restrictRoutesToAllowlist(instance: FastifyInstance, allowed: IpMatcher, routePrefix: string): void;
44
+ declare function restrictRoutesToAllowlist(instance: FastifyHookHost, allowed: IpMatcher | null, routePrefix: string, denied?: IpMatcher | null): void;
27
45
 
28
- export { IpMatcher, buildTrustProxy, restrictRoutesToAllowlist };
46
+ export { type FastifyHookHost, IpMatcher, buildTrustProxy, restrictRoutesToAllowlist };
package/dist/fastify.js CHANGED
@@ -7,10 +7,11 @@ function buildTrustProxy(trustedProxies) {
7
7
  const matcher = parseIpAllowlist(trustedProxies, "trustedProxies");
8
8
  return (address, hop) => hop === 0 && (!matcher || matcher(address));
9
9
  }
10
- function restrictRoutesToAllowlist(instance, allowed, routePrefix) {
10
+ function restrictRoutesToAllowlist(instance, allowed, routePrefix, denied = null) {
11
11
  instance.addHook("onRequest", (req, reply, done) => {
12
12
  const route = req.routeOptions?.url ?? "";
13
- if (route.startsWith(routePrefix) && !allowed(req.ip)) {
13
+ const blocked = route.startsWith(routePrefix) && (denied?.(req.ip) || allowed && !allowed(req.ip));
14
+ if (blocked) {
14
15
  void reply.code(403).send({ statusCode: 403, message: "ip_not_allowed" });
15
16
  return;
16
17
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/fastify/index.ts"],"sourcesContent":["import type { FastifyInstance } from \"fastify\";\nimport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n\n/**\n * Fastify `trustProxy` function equal to Express's `trust proxy = 1`: trusts\n * only hop 0 (the socket peer, i.e. your reverse proxy), so a client cannot\n * walk past it with extra X-Forwarded-For entries.\n *\n * Hop 0 is the socket peer itself: if the app port is reachable directly (not\n * only through the proxy), ANY client is hop 0 and can forge X-Forwarded-For,\n * defeating the allowlist. Pass `trustedProxies` (IPs/CIDRs of your proxy) to\n * trust hop 0 only when the peer is one of them; a direct client's header is\n * then ignored. Loopback is always included.\n */\nexport function buildTrustProxy(\n trustedProxies?: string | readonly string[],\n): (address: string, hop: number) => boolean {\n const matcher = parseIpAllowlist(trustedProxies, \"trustedProxies\");\n return (address, hop) => hop === 0 && (!matcher || matcher(address));\n}\n\n/**\n * Restricts Fastify-native routes that live outside Nest's guard pipeline\n * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE\n * those routes are added.\n *\n * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw\n * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`\n * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.\n */\nexport function restrictRoutesToAllowlist(\n instance: FastifyInstance,\n allowed: IpMatcher,\n routePrefix: string,\n): void {\n instance.addHook(\"onRequest\", (req, reply, done) => {\n const route = req.routeOptions?.url ?? \"\";\n if (route.startsWith(routePrefix) && !allowed(req.ip)) {\n void reply.code(403).send({ statusCode: 403, message: \"ip_not_allowed\" });\n return;\n }\n done();\n });\n}\n\nexport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n"],"mappings":";;;;;AAcO,SAAS,gBACd,gBAC2C;AAC3C,QAAM,UAAU,iBAAiB,gBAAgB,gBAAgB;AACjE,SAAO,CAAC,SAAS,QAAQ,QAAQ,MAAM,CAAC,WAAW,QAAQ,OAAO;AACpE;AAWO,SAAS,0BACd,UACA,SACA,aACM;AACN,WAAS,QAAQ,aAAa,CAAC,KAAK,OAAO,SAAS;AAClD,UAAM,QAAQ,IAAI,cAAc,OAAO;AACvC,QAAI,MAAM,WAAW,WAAW,KAAK,CAAC,QAAQ,IAAI,EAAE,GAAG;AACrD,WAAK,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,YAAY,KAAK,SAAS,iBAAiB,CAAC;AACxE;AAAA,IACF;AACA,SAAK;AAAA,EACP,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../src/fastify/index.ts"],"sourcesContent":["import { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n\n/**\n * Fastify `trustProxy` function equal to Express's `trust proxy = 1`: trusts\n * only hop 0 (the socket peer, i.e. your reverse proxy), so a client cannot\n * walk past it with extra X-Forwarded-For entries.\n *\n * Hop 0 is the socket peer itself: if the app port is reachable directly (not\n * only through the proxy), ANY client is hop 0 and can forge X-Forwarded-For,\n * defeating the allowlist. Pass `trustedProxies` (IPs/CIDRs of your proxy) to\n * trust hop 0 only when the peer is one of them; a direct client's header is\n * then ignored. Loopback is always included.\n */\nexport function buildTrustProxy(\n trustedProxies?: string | readonly string[],\n): (address: string, hop: number) => boolean {\n const matcher = parseIpAllowlist(trustedProxies, \"trustedProxies\");\n return (address, hop) => hop === 0 && (!matcher || matcher(address));\n}\n\n/**\n * Structural slice of a Fastify instance — deliberately not `FastifyInstance`, so\n * it type-checks even when the app and `@nestjs/platform-fastify` resolve two\n * different copies of `fastify`.\n */\nexport interface FastifyHookHost {\n addHook(\n name: \"onRequest\",\n hook: (\n req: { ip: string; routeOptions?: { url?: string } },\n reply: { code(statusCode: number): { send(payload: unknown): unknown } },\n done: () => void,\n ) => void,\n ): unknown;\n}\n\n/**\n * Restricts Fastify-native routes that live outside Nest's guard pipeline\n * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE\n * those routes are added.\n *\n * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw\n * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`\n * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.\n *\n * `allowed` rejects IPs not on it (pass `null` to skip); `denied` rejects IPs on it.\n */\nexport function restrictRoutesToAllowlist(\n instance: FastifyHookHost,\n allowed: IpMatcher | null,\n routePrefix: string,\n denied: IpMatcher | null = null,\n): void {\n instance.addHook(\"onRequest\", (req, reply, done) => {\n const route = req.routeOptions?.url ?? \"\";\n const blocked = route.startsWith(routePrefix) && (denied?.(req.ip) || (allowed && !allowed(req.ip)));\n if (blocked) {\n void reply.code(403).send({ statusCode: 403, message: \"ip_not_allowed\" });\n return;\n }\n done();\n });\n}\n\nexport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n"],"mappings":";;;;;AAaO,SAAS,gBACd,gBAC2C;AAC3C,QAAM,UAAU,iBAAiB,gBAAgB,gBAAgB;AACjE,SAAO,CAAC,SAAS,QAAQ,QAAQ,MAAM,CAAC,WAAW,QAAQ,OAAO;AACpE;AA6BO,SAAS,0BACd,UACA,SACA,aACA,SAA2B,MACrB;AACN,WAAS,QAAQ,aAAa,CAAC,KAAK,OAAO,SAAS;AAClD,UAAM,QAAQ,IAAI,cAAc,OAAO;AACvC,UAAM,UAAU,MAAM,WAAW,WAAW,MAAM,SAAS,IAAI,EAAE,KAAM,WAAW,CAAC,QAAQ,IAAI,EAAE;AACjG,QAAI,SAAS;AACX,WAAK,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,YAAY,KAAK,SAAS,iBAAiB,CAAC;AACxE;AAAA,IACF;AACA,SAAK;AAAA,EACP,CAAC;AACH;","names":[]}
package/dist/index.cjs CHANGED
@@ -110,6 +110,7 @@ var IpAllowlistGuard = class {
110
110
  const parse = { allowLoopback: options.allowLoopback ?? true };
111
111
  this.protectedMatcher = parseIpAllowlist(options.protected, "protected", parse);
112
112
  this.publicMatcher = parseIpAllowlist(options.public, "public", parse);
113
+ this.denyMatcher = parseIpAllowlist(options.deny, "deny", { allowLoopback: false });
113
114
  this.publicKey = options.publicMetadataKey ?? "isPublic";
114
115
  }
115
116
  reflector;
@@ -117,36 +118,45 @@ var IpAllowlistGuard = class {
117
118
  logger = new import_common2.Logger(IpAllowlistGuard.name);
118
119
  protectedMatcher;
119
120
  publicMatcher;
121
+ denyMatcher;
120
122
  publicKey;
121
123
  lastLogged = /* @__PURE__ */ new Map();
122
124
  canActivate(ctx) {
123
125
  const type = ctx.getType();
124
126
  if (type === "rpc") return true;
127
+ if (!this.protectedMatcher && !this.publicMatcher && !this.denyMatcher) return true;
128
+ const ip = type === "http" ? ctx.switchToHttp().getRequest().ip : void 0;
129
+ if (this.denyMatcher?.(ip)) return this.reject(ip, "denied");
125
130
  if (!this.protectedMatcher && !this.publicMatcher) return true;
126
131
  const targets = [ctx.getHandler(), ctx.getClass()];
127
132
  if (this.reflector.getAllAndOverride(SKIP_IP_ALLOWLIST_KEY, targets)) return true;
128
133
  const isPublic = this.reflector.getAllAndOverride(this.publicKey, targets);
129
134
  const matcher = isPublic ? this.publicMatcher : this.protectedMatcher;
130
135
  if (!matcher) return true;
131
- const ip = type === "http" ? ctx.switchToHttp().getRequest().ip : void 0;
132
136
  if (matcher(ip)) return true;
133
- this.notifyBlocked(ip);
137
+ return this.reject(ip, "not_allowed");
138
+ }
139
+ reject(ip, reason) {
140
+ this.notifyBlocked(ip, reason);
134
141
  throw new import_common2.ForbiddenException("ip_not_allowed");
135
142
  }
136
- notifyBlocked(ip) {
137
- this.options.onBlocked?.(ip);
143
+ notifyBlocked(ip, reason) {
144
+ this.options.onBlocked?.(ip, reason);
138
145
  const key = ip && (0, import_node_net2.isIP)(ip) ? ip : "invalid";
146
+ const logKey = `${reason}:${key}`;
139
147
  const now = Date.now();
140
- const last = this.lastLogged.get(key);
148
+ const last = this.lastLogged.get(logKey);
141
149
  if (last !== void 0 && now - last < LOG_INTERVAL_MS) return;
142
- this.lastLogged.delete(key);
143
- this.lastLogged.set(key, now);
150
+ this.lastLogged.delete(logKey);
151
+ this.lastLogged.set(logKey, now);
144
152
  if (this.lastLogged.size > MAX_TRACKED_IPS) {
145
153
  const oldest = this.lastLogged.keys().next().value;
146
154
  if (oldest !== void 0) this.lastLogged.delete(oldest);
147
155
  }
148
156
  if (!this.options.onBlocked) {
149
- this.logger.warn(`Blocked request from non-allowlisted IP ${key}`);
157
+ this.logger.warn(
158
+ reason === "denied" ? `Blocked request from denylisted IP ${key}` : `Blocked request from non-allowlisted IP ${key}`
159
+ );
150
160
  }
151
161
  }
152
162
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/ip-allowlist.module.ts","../src/ip-allowlist.guard.ts","../src/options.ts","../src/ip-matcher.ts","../src/skip-ip-allowlist.decorator.ts"],"sourcesContent":["export { IpAllowlistModule } from \"./ip-allowlist.module.js\";\nexport { IpAllowlistGuard } from \"./ip-allowlist.guard.js\";\nexport { SkipIpAllowlist, SKIP_IP_ALLOWLIST_KEY } from \"./skip-ip-allowlist.decorator.js\";\nexport { IP_ALLOWLIST_OPTIONS, type IpAllowlistOptions } from \"./options.js\";\nexport { parseIpAllowlist, normalizeIp, type IpMatcher } from \"./ip-matcher.js\";\n","import { Module, type DynamicModule } from \"@nestjs/common\";\nimport { APP_GUARD } from \"@nestjs/core\";\nimport { IpAllowlistGuard } from \"./ip-allowlist.guard.js\";\nimport { IP_ALLOWLIST_OPTIONS, type IpAllowlistOptions } from \"./options.js\";\n\n@Module({})\nexport class IpAllowlistModule {\n /**\n * Registers the guard globally (APP_GUARD). Import this module BEFORE the\n * module that registers your auth guard so a blocked IP is rejected before\n * any authentication work.\n */\n static forRoot(options: IpAllowlistOptions = {}): DynamicModule {\n return {\n module: IpAllowlistModule,\n providers: [\n { provide: IP_ALLOWLIST_OPTIONS, useValue: options },\n { provide: APP_GUARD, useClass: IpAllowlistGuard },\n ],\n };\n }\n}\n","import {\n ForbiddenException,\n Inject,\n Injectable,\n Logger,\n type CanActivate,\n type ExecutionContext,\n} from \"@nestjs/common\";\nimport { Reflector } from \"@nestjs/core\";\nimport { isIP } from \"node:net\";\nimport { IP_ALLOWLIST_OPTIONS, type IpAllowlistOptions } from \"./options.js\";\nimport { parseIpAllowlist, type IpMatcher } from \"./ip-matcher.js\";\nimport { SKIP_IP_ALLOWLIST_KEY } from \"./skip-ip-allowlist.decorator.js\";\n\n// Default log line is rate-limited per IP; the map is bounded by evicting the OLDEST\n// entry, so rotating through many IPs can never switch logging off.\nconst LOG_INTERVAL_MS = 60_000;\nconst MAX_TRACKED_IPS = 1000;\n\n/**\n * Restricts routes by source IP. Reads `request.ip`, which Express/Fastify\n * resolve through their own `trust proxy` setting — configure it so `req.ip`\n * is the real client behind your proxy (see `buildTrustProxy` in the\n * `/fastify` entry). `X-Forwarded-For` is never read here.\n */\n@Injectable()\nexport class IpAllowlistGuard implements CanActivate {\n private readonly logger = new Logger(IpAllowlistGuard.name);\n private readonly protectedMatcher: IpMatcher | null;\n private readonly publicMatcher: IpMatcher | null;\n private readonly publicKey: string;\n private readonly lastLogged = new Map<string, number>();\n\n constructor(\n @Inject(Reflector) private readonly reflector: Reflector,\n @Inject(IP_ALLOWLIST_OPTIONS) private readonly options: IpAllowlistOptions,\n ) {\n const parse = { allowLoopback: options.allowLoopback ?? true };\n this.protectedMatcher = parseIpAllowlist(options.protected, \"protected\", parse);\n this.publicMatcher = parseIpAllowlist(options.public, \"public\", parse);\n this.publicKey = options.publicMetadataKey ?? \"isPublic\";\n }\n\n canActivate(ctx: ExecutionContext): boolean {\n // Only microservice (RPC) traffic is exempt: it has no client IP and is internal\n // transport. Any other context type (graphql, ws, ...) is NOT skipped — it has no\n // resolvable `req.ip` here, so it is denied when a list is enforced (fail closed).\n const type = ctx.getType<string>();\n if (type === \"rpc\") return true;\n if (!this.protectedMatcher && !this.publicMatcher) return true;\n\n const targets = [ctx.getHandler(), ctx.getClass()];\n if (this.reflector.getAllAndOverride<boolean>(SKIP_IP_ALLOWLIST_KEY, targets)) return true;\n\n const isPublic = this.reflector.getAllAndOverride<boolean>(this.publicKey, targets);\n const matcher = isPublic ? this.publicMatcher : this.protectedMatcher;\n if (!matcher) return true;\n\n const ip = type === \"http\" ? ctx.switchToHttp().getRequest<{ ip?: string }>().ip : undefined;\n if (matcher(ip)) return true;\n\n this.notifyBlocked(ip);\n throw new ForbiddenException(\"ip_not_allowed\");\n }\n\n private notifyBlocked(ip: string | undefined): void {\n this.options.onBlocked?.(ip);\n\n // Never log attacker-controlled text: only a well-formed IP is echoed.\n const key = ip && isIP(ip) ? ip : \"invalid\";\n const now = Date.now();\n const last = this.lastLogged.get(key);\n if (last !== undefined && now - last < LOG_INTERVAL_MS) return;\n this.lastLogged.delete(key);\n this.lastLogged.set(key, now);\n if (this.lastLogged.size > MAX_TRACKED_IPS) {\n const oldest = this.lastLogged.keys().next().value;\n if (oldest !== undefined) this.lastLogged.delete(oldest);\n }\n if (!this.options.onBlocked) {\n this.logger.warn(`Blocked request from non-allowlisted IP ${key}`);\n }\n }\n}\n","export const IP_ALLOWLIST_OPTIONS = Symbol(\"IP_ALLOWLIST_OPTIONS\");\n\nexport interface IpAllowlistOptions {\n /**\n * Allowlist for protected (non-public) routes: comma-separated string or\n * array of IPs/CIDRs. Unset/blank = not enforced.\n */\n protected?: string | readonly string[];\n /**\n * Allowlist for public (\"open\") routes, i.e. routes carrying the metadata key\n * below. Unset/blank = not enforced.\n */\n public?: string | readonly string[];\n /**\n * Metadata key that marks a route as public. Defaults to `'isPublic'`, the\n * key used by the common `@Public()` decorator (`SetMetadata('isPublic', true)`).\n */\n publicMetadataKey?: string;\n /**\n * Always allow loopback once a list is set. Default `true`. Set `false` if a\n * reverse proxy runs on the same host and `req.ip` could be its loopback address.\n */\n allowLoopback?: boolean;\n /**\n * Called on EVERY blocked request (feed your metrics/alerting from here).\n * The default Nest Logger warning is rate-limited per IP; this hook is not.\n */\n onBlocked?: (ip: string | undefined) => void;\n}\n","import { BlockList, isIP } from \"node:net\";\n\nexport type IpMatcher = (ip: string | undefined) => boolean;\n\nexport interface ParseOptions {\n /**\n * Always allow loopback (`127.0.0.0/8`, `::1`) once a list is set. Default `true`\n * (local dev, health probes). Set `false` when a reverse proxy runs on the SAME\n * host and `req.ip` may fall back to its loopback address — otherwise a proxy\n * without trust-proxy configured makes every client look like 127.0.0.1.\n */\n allowLoopback?: boolean;\n}\n\nconst LOOPBACK = [\"127.0.0.0/8\", \"::1\"] as const;\n\n/** `::ffff:1.2.3.4` (what a dual-stack socket reports for IPv4 peers) → `1.2.3.4`. */\nexport function normalizeIp(ip: string): string {\n const mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i.exec(ip);\n return mapped?.[1] ?? ip;\n}\n\nfunction addEntry(list: BlockList, raw: string): void {\n const [addr = \"\", prefix, ...rest] = raw.split(\"/\");\n const family = isIP(addr);\n // A zone id (`fe80::1%eth0`) is accepted by isIP but ignored by BlockList.check,\n // so `::1%x` would match loopback — never allow one in a list or as a client IP.\n if (!family || addr.includes(\"%\") || rest.length > 0) {\n throw new Error(`invalid IP/CIDR \"${raw}\"`);\n }\n const type = family === 4 ? \"ipv4\" : \"ipv6\";\n if (prefix === undefined) {\n list.addAddress(addr, type);\n return;\n }\n const bits = Number(prefix);\n const max = family === 4 ? 32 : 128;\n if (!/^\\d+$/.test(prefix) || bits > max) throw new Error(`invalid IP/CIDR \"${raw}\"`);\n list.addSubnet(addr, bits, type);\n}\n\n/**\n * Builds a matcher from a comma-separated string or an array of IPs/CIDRs\n * (IPv4 + IPv6). Returns `null` when the list is unset/blank (= not enforced).\n * Loopback is part of an enforced list by default (see `allowLoopback`). Throws on a malformed entry — a typo must not silently turn\n * into \"everyone allowed\" or \"everyone blocked\". `label` names the option in\n * the error message.\n */\nexport function parseIpAllowlist(\n value: string | readonly string[] | undefined,\n label = \"allowlist\",\n { allowLoopback = true }: ParseOptions = {},\n): IpMatcher | null {\n const raw = typeof value === \"string\" ? value.split(\",\") : (value ?? []);\n const entries = raw.map((s) => s.trim()).filter(Boolean);\n if (entries.length === 0) return null;\n\n const list = new BlockList();\n for (const entry of [...(allowLoopback ? LOOPBACK : []), ...entries]) {\n try {\n addEntry(list, entry);\n } catch (err) {\n throw new Error(`${label}: ${(err as Error).message}`, { cause: err });\n }\n }\n return (ip) => {\n if (!ip || ip.includes(\"%\")) return false;\n const addr = normalizeIp(ip);\n const family = isIP(addr);\n if (!family) return false;\n return list.check(addr, family === 4 ? \"ipv4\" : \"ipv6\");\n };\n}\n","import { SetMetadata } from \"@nestjs/common\";\n\nexport const SKIP_IP_ALLOWLIST_KEY = \"skipIpAllowlist\";\n\n/**\n * Exempts a route (or controller) from the guard. For callers whose IPs cannot\n * be pinned down and that authenticate by other means (e.g. provider webhooks\n * verified by an HMAC signature).\n */\nexport const SkipIpAllowlist = (): MethodDecorator & ClassDecorator =>\n SetMetadata(SKIP_IP_ALLOWLIST_KEY, true);\n"],"mappings":"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","names":["import_common","import_core","import_common","import_node_net"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/ip-allowlist.module.ts","../src/ip-allowlist.guard.ts","../src/options.ts","../src/ip-matcher.ts","../src/skip-ip-allowlist.decorator.ts"],"sourcesContent":["export { IpAllowlistModule } from \"./ip-allowlist.module.js\";\nexport { IpAllowlistGuard } from \"./ip-allowlist.guard.js\";\nexport { SkipIpAllowlist, SKIP_IP_ALLOWLIST_KEY } from \"./skip-ip-allowlist.decorator.js\";\nexport { IP_ALLOWLIST_OPTIONS, type BlockReason, type IpAllowlistOptions } from \"./options.js\";\nexport { parseIpAllowlist, normalizeIp, type IpMatcher } from \"./ip-matcher.js\";\n","import { Module, type DynamicModule } from \"@nestjs/common\";\nimport { APP_GUARD } from \"@nestjs/core\";\nimport { IpAllowlistGuard } from \"./ip-allowlist.guard.js\";\nimport { IP_ALLOWLIST_OPTIONS, type IpAllowlistOptions } from \"./options.js\";\n\n@Module({})\nexport class IpAllowlistModule {\n /**\n * Registers the guard globally (APP_GUARD). Import this module BEFORE the\n * module that registers your auth guard so a blocked IP is rejected before\n * any authentication work.\n */\n static forRoot(options: IpAllowlistOptions = {}): DynamicModule {\n return {\n module: IpAllowlistModule,\n providers: [\n { provide: IP_ALLOWLIST_OPTIONS, useValue: options },\n { provide: APP_GUARD, useClass: IpAllowlistGuard },\n ],\n };\n }\n}\n","import {\n ForbiddenException,\n Inject,\n Injectable,\n Logger,\n type CanActivate,\n type ExecutionContext,\n} from \"@nestjs/common\";\nimport { Reflector } from \"@nestjs/core\";\nimport { isIP } from \"node:net\";\nimport { IP_ALLOWLIST_OPTIONS, type BlockReason, type IpAllowlistOptions } from \"./options.js\";\nimport { parseIpAllowlist, type IpMatcher } from \"./ip-matcher.js\";\nimport { SKIP_IP_ALLOWLIST_KEY } from \"./skip-ip-allowlist.decorator.js\";\n\n// Default log line is rate-limited per IP; the map is bounded by evicting the OLDEST\n// entry, so rotating through many IPs can never switch logging off.\nconst LOG_INTERVAL_MS = 60_000;\nconst MAX_TRACKED_IPS = 1000;\n\n/**\n * Restricts routes by source IP. Reads `request.ip`, which Express/Fastify\n * resolve through their own `trust proxy` setting — configure it so `req.ip`\n * is the real client behind your proxy (see `buildTrustProxy` in the\n * `/fastify` entry). `X-Forwarded-For` is never read here.\n */\n@Injectable()\nexport class IpAllowlistGuard implements CanActivate {\n private readonly logger = new Logger(IpAllowlistGuard.name);\n private readonly protectedMatcher: IpMatcher | null;\n private readonly publicMatcher: IpMatcher | null;\n private readonly denyMatcher: IpMatcher | null;\n private readonly publicKey: string;\n private readonly lastLogged = new Map<string, number>();\n\n constructor(\n @Inject(Reflector) private readonly reflector: Reflector,\n @Inject(IP_ALLOWLIST_OPTIONS) private readonly options: IpAllowlistOptions,\n ) {\n const parse = { allowLoopback: options.allowLoopback ?? true };\n this.protectedMatcher = parseIpAllowlist(options.protected, \"protected\", parse);\n this.publicMatcher = parseIpAllowlist(options.public, \"public\", parse);\n // The denylist never adds loopback implicitly: only what the operator lists.\n this.denyMatcher = parseIpAllowlist(options.deny, \"deny\", { allowLoopback: false });\n this.publicKey = options.publicMetadataKey ?? \"isPublic\";\n }\n\n canActivate(ctx: ExecutionContext): boolean {\n // Only microservice (RPC) traffic is exempt: it has no client IP and is internal\n // transport. Any other context type (graphql, ws, ...) is NOT skipped — it has no\n // resolvable `req.ip` here, so it is denied when an allowlist is enforced (fail closed).\n const type = ctx.getType<string>();\n if (type === \"rpc\") return true;\n if (!this.protectedMatcher && !this.publicMatcher && !this.denyMatcher) return true;\n\n const ip = type === \"http\" ? ctx.switchToHttp().getRequest<{ ip?: string }>().ip : undefined;\n\n // Denylist first, on every route (including @SkipIpAllowlist ones): it wins over any allow.\n if (this.denyMatcher?.(ip)) return this.reject(ip, \"denied\");\n\n if (!this.protectedMatcher && !this.publicMatcher) return true;\n const targets = [ctx.getHandler(), ctx.getClass()];\n if (this.reflector.getAllAndOverride<boolean>(SKIP_IP_ALLOWLIST_KEY, targets)) return true;\n\n const isPublic = this.reflector.getAllAndOverride<boolean>(this.publicKey, targets);\n const matcher = isPublic ? this.publicMatcher : this.protectedMatcher;\n if (!matcher) return true;\n if (matcher(ip)) return true;\n\n return this.reject(ip, \"not_allowed\");\n }\n\n private reject(ip: string | undefined, reason: BlockReason): never {\n this.notifyBlocked(ip, reason);\n throw new ForbiddenException(\"ip_not_allowed\");\n }\n\n private notifyBlocked(ip: string | undefined, reason: BlockReason): void {\n this.options.onBlocked?.(ip, reason);\n\n // Never log attacker-controlled text: only a well-formed IP is echoed.\n const key = ip && isIP(ip) ? ip : \"invalid\";\n const logKey = `${reason}:${key}`;\n const now = Date.now();\n const last = this.lastLogged.get(logKey);\n if (last !== undefined && now - last < LOG_INTERVAL_MS) return;\n this.lastLogged.delete(logKey);\n this.lastLogged.set(logKey, now);\n if (this.lastLogged.size > MAX_TRACKED_IPS) {\n const oldest = this.lastLogged.keys().next().value;\n if (oldest !== undefined) this.lastLogged.delete(oldest);\n }\n if (!this.options.onBlocked) {\n this.logger.warn(\n reason === \"denied\"\n ? `Blocked request from denylisted IP ${key}`\n : `Blocked request from non-allowlisted IP ${key}`,\n );\n }\n }\n}\n","export const IP_ALLOWLIST_OPTIONS = Symbol(\"IP_ALLOWLIST_OPTIONS\");\n\n/** Why a request was rejected: on the denylist, or not on the applicable allowlist. */\nexport type BlockReason = \"denied\" | \"not_allowed\";\n\nexport interface IpAllowlistOptions {\n /**\n * Allowlist for protected (non-public) routes: comma-separated string or\n * array of IPs/CIDRs. Unset/blank = not enforced.\n */\n protected?: string | readonly string[];\n /**\n * Allowlist for public (\"open\") routes, i.e. routes carrying the metadata key\n * below. Unset/blank = not enforced.\n */\n public?: string | readonly string[];\n /**\n * Denylist applied to EVERY route (protected and public): comma-separated string\n * or array of IPs/CIDRs that are always rejected. Takes precedence over the\n * allowlists, and — unlike them — also applies to `@SkipIpAllowlist()` routes\n * (a banned address is banned everywhere). Works alone (\"everyone except these\")\n * or together with the allowlists. Loopback is never denied implicitly.\n * Unset/blank = nothing denied.\n */\n deny?: string | readonly string[];\n /**\n * Metadata key that marks a route as public. Defaults to `'isPublic'`, the\n * key used by the common `@Public()` decorator (`SetMetadata('isPublic', true)`).\n */\n publicMetadataKey?: string;\n /**\n * Always allow loopback once a list is set. Default `true`. Set `false` if a\n * reverse proxy runs on the same host and `req.ip` could be its loopback address.\n */\n allowLoopback?: boolean;\n /**\n * Called on EVERY blocked request (feed your metrics/alerting from here).\n * The default Nest Logger warning is rate-limited per IP; this hook is not.\n */\n onBlocked?: (ip: string | undefined, reason: BlockReason) => void;\n}\n","import { BlockList, isIP } from \"node:net\";\n\nexport type IpMatcher = (ip: string | undefined) => boolean;\n\nexport interface ParseOptions {\n /**\n * Always allow loopback (`127.0.0.0/8`, `::1`) once a list is set. Default `true`\n * (local dev, health probes). Set `false` when a reverse proxy runs on the SAME\n * host and `req.ip` may fall back to its loopback address — otherwise a proxy\n * without trust-proxy configured makes every client look like 127.0.0.1.\n */\n allowLoopback?: boolean;\n}\n\nconst LOOPBACK = [\"127.0.0.0/8\", \"::1\"] as const;\n\n/** `::ffff:1.2.3.4` (what a dual-stack socket reports for IPv4 peers) → `1.2.3.4`. */\nexport function normalizeIp(ip: string): string {\n const mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i.exec(ip);\n return mapped?.[1] ?? ip;\n}\n\nfunction addEntry(list: BlockList, raw: string): void {\n const [addr = \"\", prefix, ...rest] = raw.split(\"/\");\n const family = isIP(addr);\n // A zone id (`fe80::1%eth0`) is accepted by isIP but ignored by BlockList.check,\n // so `::1%x` would match loopback — never allow one in a list or as a client IP.\n if (!family || addr.includes(\"%\") || rest.length > 0) {\n throw new Error(`invalid IP/CIDR \"${raw}\"`);\n }\n const type = family === 4 ? \"ipv4\" : \"ipv6\";\n if (prefix === undefined) {\n list.addAddress(addr, type);\n return;\n }\n const bits = Number(prefix);\n const max = family === 4 ? 32 : 128;\n if (!/^\\d+$/.test(prefix) || bits > max) throw new Error(`invalid IP/CIDR \"${raw}\"`);\n list.addSubnet(addr, bits, type);\n}\n\n/**\n * Builds a matcher from a comma-separated string or an array of IPs/CIDRs\n * (IPv4 + IPv6). Returns `null` when the list is unset/blank (= not enforced).\n * Loopback is part of an enforced list by default (see `allowLoopback`). Throws on a malformed entry — a typo must not silently turn\n * into \"everyone allowed\" or \"everyone blocked\". `label` names the option in\n * the error message.\n */\nexport function parseIpAllowlist(\n value: string | readonly string[] | undefined,\n label = \"allowlist\",\n { allowLoopback = true }: ParseOptions = {},\n): IpMatcher | null {\n const raw = typeof value === \"string\" ? value.split(\",\") : (value ?? []);\n const entries = raw.map((s) => s.trim()).filter(Boolean);\n if (entries.length === 0) return null;\n\n const list = new BlockList();\n for (const entry of [...(allowLoopback ? LOOPBACK : []), ...entries]) {\n try {\n addEntry(list, entry);\n } catch (err) {\n throw new Error(`${label}: ${(err as Error).message}`, { cause: err });\n }\n }\n return (ip) => {\n if (!ip || ip.includes(\"%\")) return false;\n const addr = normalizeIp(ip);\n const family = isIP(addr);\n if (!family) return false;\n return list.check(addr, family === 4 ? \"ipv4\" : \"ipv6\");\n };\n}\n","import { SetMetadata } from \"@nestjs/common\";\n\nexport const SKIP_IP_ALLOWLIST_KEY = \"skipIpAllowlist\";\n\n/**\n * Exempts a route (or controller) from the guard. For callers whose IPs cannot\n * be pinned down and that authenticate by other means (e.g. provider webhooks\n * verified by an HMAC signature).\n */\nexport const SkipIpAllowlist = (): MethodDecorator & ClassDecorator =>\n SetMetadata(SKIP_IP_ALLOWLIST_KEY, true);\n"],"mappings":"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","names":["import_common","import_core","import_common","import_node_net"]}
package/dist/index.d.cts CHANGED
@@ -3,6 +3,8 @@ import { Reflector } from '@nestjs/core';
3
3
  export { I as IpMatcher, n as normalizeIp, p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.cjs';
4
4
 
5
5
  declare const IP_ALLOWLIST_OPTIONS: unique symbol;
6
+ /** Why a request was rejected: on the denylist, or not on the applicable allowlist. */
7
+ type BlockReason = "denied" | "not_allowed";
6
8
  interface IpAllowlistOptions {
7
9
  /**
8
10
  * Allowlist for protected (non-public) routes: comma-separated string or
@@ -14,6 +16,15 @@ interface IpAllowlistOptions {
14
16
  * below. Unset/blank = not enforced.
15
17
  */
16
18
  public?: string | readonly string[];
19
+ /**
20
+ * Denylist applied to EVERY route (protected and public): comma-separated string
21
+ * or array of IPs/CIDRs that are always rejected. Takes precedence over the
22
+ * allowlists, and — unlike them — also applies to `@SkipIpAllowlist()` routes
23
+ * (a banned address is banned everywhere). Works alone ("everyone except these")
24
+ * or together with the allowlists. Loopback is never denied implicitly.
25
+ * Unset/blank = nothing denied.
26
+ */
27
+ deny?: string | readonly string[];
17
28
  /**
18
29
  * Metadata key that marks a route as public. Defaults to `'isPublic'`, the
19
30
  * key used by the common `@Public()` decorator (`SetMetadata('isPublic', true)`).
@@ -28,7 +39,7 @@ interface IpAllowlistOptions {
28
39
  * Called on EVERY blocked request (feed your metrics/alerting from here).
29
40
  * The default Nest Logger warning is rate-limited per IP; this hook is not.
30
41
  */
31
- onBlocked?: (ip: string | undefined) => void;
42
+ onBlocked?: (ip: string | undefined, reason: BlockReason) => void;
32
43
  }
33
44
 
34
45
  declare class IpAllowlistModule {
@@ -52,10 +63,12 @@ declare class IpAllowlistGuard implements CanActivate {
52
63
  private readonly logger;
53
64
  private readonly protectedMatcher;
54
65
  private readonly publicMatcher;
66
+ private readonly denyMatcher;
55
67
  private readonly publicKey;
56
68
  private readonly lastLogged;
57
69
  constructor(reflector: Reflector, options: IpAllowlistOptions);
58
70
  canActivate(ctx: ExecutionContext): boolean;
71
+ private reject;
59
72
  private notifyBlocked;
60
73
  }
61
74
 
@@ -67,4 +80,4 @@ declare const SKIP_IP_ALLOWLIST_KEY = "skipIpAllowlist";
67
80
  */
68
81
  declare const SkipIpAllowlist: () => MethodDecorator & ClassDecorator;
69
82
 
70
- export { IP_ALLOWLIST_OPTIONS, IpAllowlistGuard, IpAllowlistModule, type IpAllowlistOptions, SKIP_IP_ALLOWLIST_KEY, SkipIpAllowlist };
83
+ export { type BlockReason, IP_ALLOWLIST_OPTIONS, IpAllowlistGuard, IpAllowlistModule, type IpAllowlistOptions, SKIP_IP_ALLOWLIST_KEY, SkipIpAllowlist };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ import { Reflector } from '@nestjs/core';
3
3
  export { I as IpMatcher, n as normalizeIp, p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.js';
4
4
 
5
5
  declare const IP_ALLOWLIST_OPTIONS: unique symbol;
6
+ /** Why a request was rejected: on the denylist, or not on the applicable allowlist. */
7
+ type BlockReason = "denied" | "not_allowed";
6
8
  interface IpAllowlistOptions {
7
9
  /**
8
10
  * Allowlist for protected (non-public) routes: comma-separated string or
@@ -14,6 +16,15 @@ interface IpAllowlistOptions {
14
16
  * below. Unset/blank = not enforced.
15
17
  */
16
18
  public?: string | readonly string[];
19
+ /**
20
+ * Denylist applied to EVERY route (protected and public): comma-separated string
21
+ * or array of IPs/CIDRs that are always rejected. Takes precedence over the
22
+ * allowlists, and — unlike them — also applies to `@SkipIpAllowlist()` routes
23
+ * (a banned address is banned everywhere). Works alone ("everyone except these")
24
+ * or together with the allowlists. Loopback is never denied implicitly.
25
+ * Unset/blank = nothing denied.
26
+ */
27
+ deny?: string | readonly string[];
17
28
  /**
18
29
  * Metadata key that marks a route as public. Defaults to `'isPublic'`, the
19
30
  * key used by the common `@Public()` decorator (`SetMetadata('isPublic', true)`).
@@ -28,7 +39,7 @@ interface IpAllowlistOptions {
28
39
  * Called on EVERY blocked request (feed your metrics/alerting from here).
29
40
  * The default Nest Logger warning is rate-limited per IP; this hook is not.
30
41
  */
31
- onBlocked?: (ip: string | undefined) => void;
42
+ onBlocked?: (ip: string | undefined, reason: BlockReason) => void;
32
43
  }
33
44
 
34
45
  declare class IpAllowlistModule {
@@ -52,10 +63,12 @@ declare class IpAllowlistGuard implements CanActivate {
52
63
  private readonly logger;
53
64
  private readonly protectedMatcher;
54
65
  private readonly publicMatcher;
66
+ private readonly denyMatcher;
55
67
  private readonly publicKey;
56
68
  private readonly lastLogged;
57
69
  constructor(reflector: Reflector, options: IpAllowlistOptions);
58
70
  canActivate(ctx: ExecutionContext): boolean;
71
+ private reject;
59
72
  private notifyBlocked;
60
73
  }
61
74
 
@@ -67,4 +80,4 @@ declare const SKIP_IP_ALLOWLIST_KEY = "skipIpAllowlist";
67
80
  */
68
81
  declare const SkipIpAllowlist: () => MethodDecorator & ClassDecorator;
69
82
 
70
- export { IP_ALLOWLIST_OPTIONS, IpAllowlistGuard, IpAllowlistModule, type IpAllowlistOptions, SKIP_IP_ALLOWLIST_KEY, SkipIpAllowlist };
83
+ export { type BlockReason, IP_ALLOWLIST_OPTIONS, IpAllowlistGuard, IpAllowlistModule, type IpAllowlistOptions, SKIP_IP_ALLOWLIST_KEY, SkipIpAllowlist };
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ var IpAllowlistGuard = class {
37
37
  const parse = { allowLoopback: options.allowLoopback ?? true };
38
38
  this.protectedMatcher = parseIpAllowlist(options.protected, "protected", parse);
39
39
  this.publicMatcher = parseIpAllowlist(options.public, "public", parse);
40
+ this.denyMatcher = parseIpAllowlist(options.deny, "deny", { allowLoopback: false });
40
41
  this.publicKey = options.publicMetadataKey ?? "isPublic";
41
42
  }
42
43
  reflector;
@@ -44,36 +45,45 @@ var IpAllowlistGuard = class {
44
45
  logger = new Logger(IpAllowlistGuard.name);
45
46
  protectedMatcher;
46
47
  publicMatcher;
48
+ denyMatcher;
47
49
  publicKey;
48
50
  lastLogged = /* @__PURE__ */ new Map();
49
51
  canActivate(ctx) {
50
52
  const type = ctx.getType();
51
53
  if (type === "rpc") return true;
54
+ if (!this.protectedMatcher && !this.publicMatcher && !this.denyMatcher) return true;
55
+ const ip = type === "http" ? ctx.switchToHttp().getRequest().ip : void 0;
56
+ if (this.denyMatcher?.(ip)) return this.reject(ip, "denied");
52
57
  if (!this.protectedMatcher && !this.publicMatcher) return true;
53
58
  const targets = [ctx.getHandler(), ctx.getClass()];
54
59
  if (this.reflector.getAllAndOverride(SKIP_IP_ALLOWLIST_KEY, targets)) return true;
55
60
  const isPublic = this.reflector.getAllAndOverride(this.publicKey, targets);
56
61
  const matcher = isPublic ? this.publicMatcher : this.protectedMatcher;
57
62
  if (!matcher) return true;
58
- const ip = type === "http" ? ctx.switchToHttp().getRequest().ip : void 0;
59
63
  if (matcher(ip)) return true;
60
- this.notifyBlocked(ip);
64
+ return this.reject(ip, "not_allowed");
65
+ }
66
+ reject(ip, reason) {
67
+ this.notifyBlocked(ip, reason);
61
68
  throw new ForbiddenException("ip_not_allowed");
62
69
  }
63
- notifyBlocked(ip) {
64
- this.options.onBlocked?.(ip);
70
+ notifyBlocked(ip, reason) {
71
+ this.options.onBlocked?.(ip, reason);
65
72
  const key = ip && isIP(ip) ? ip : "invalid";
73
+ const logKey = `${reason}:${key}`;
66
74
  const now = Date.now();
67
- const last = this.lastLogged.get(key);
75
+ const last = this.lastLogged.get(logKey);
68
76
  if (last !== void 0 && now - last < LOG_INTERVAL_MS) return;
69
- this.lastLogged.delete(key);
70
- this.lastLogged.set(key, now);
77
+ this.lastLogged.delete(logKey);
78
+ this.lastLogged.set(logKey, now);
71
79
  if (this.lastLogged.size > MAX_TRACKED_IPS) {
72
80
  const oldest = this.lastLogged.keys().next().value;
73
81
  if (oldest !== void 0) this.lastLogged.delete(oldest);
74
82
  }
75
83
  if (!this.options.onBlocked) {
76
- this.logger.warn(`Blocked request from non-allowlisted IP ${key}`);
84
+ this.logger.warn(
85
+ reason === "denied" ? `Blocked request from denylisted IP ${key}` : `Blocked request from non-allowlisted IP ${key}`
86
+ );
77
87
  }
78
88
  }
79
89
  };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/ip-allowlist.module.ts","../src/ip-allowlist.guard.ts","../src/options.ts","../src/skip-ip-allowlist.decorator.ts"],"sourcesContent":["import { Module, type DynamicModule } from \"@nestjs/common\";\nimport { APP_GUARD } from \"@nestjs/core\";\nimport { IpAllowlistGuard } from \"./ip-allowlist.guard.js\";\nimport { IP_ALLOWLIST_OPTIONS, type IpAllowlistOptions } from \"./options.js\";\n\n@Module({})\nexport class IpAllowlistModule {\n /**\n * Registers the guard globally (APP_GUARD). Import this module BEFORE the\n * module that registers your auth guard so a blocked IP is rejected before\n * any authentication work.\n */\n static forRoot(options: IpAllowlistOptions = {}): DynamicModule {\n return {\n module: IpAllowlistModule,\n providers: [\n { provide: IP_ALLOWLIST_OPTIONS, useValue: options },\n { provide: APP_GUARD, useClass: IpAllowlistGuard },\n ],\n };\n }\n}\n","import {\n ForbiddenException,\n Inject,\n Injectable,\n Logger,\n type CanActivate,\n type ExecutionContext,\n} from \"@nestjs/common\";\nimport { Reflector } from \"@nestjs/core\";\nimport { isIP } from \"node:net\";\nimport { IP_ALLOWLIST_OPTIONS, type IpAllowlistOptions } from \"./options.js\";\nimport { parseIpAllowlist, type IpMatcher } from \"./ip-matcher.js\";\nimport { SKIP_IP_ALLOWLIST_KEY } from \"./skip-ip-allowlist.decorator.js\";\n\n// Default log line is rate-limited per IP; the map is bounded by evicting the OLDEST\n// entry, so rotating through many IPs can never switch logging off.\nconst LOG_INTERVAL_MS = 60_000;\nconst MAX_TRACKED_IPS = 1000;\n\n/**\n * Restricts routes by source IP. Reads `request.ip`, which Express/Fastify\n * resolve through their own `trust proxy` setting — configure it so `req.ip`\n * is the real client behind your proxy (see `buildTrustProxy` in the\n * `/fastify` entry). `X-Forwarded-For` is never read here.\n */\n@Injectable()\nexport class IpAllowlistGuard implements CanActivate {\n private readonly logger = new Logger(IpAllowlistGuard.name);\n private readonly protectedMatcher: IpMatcher | null;\n private readonly publicMatcher: IpMatcher | null;\n private readonly publicKey: string;\n private readonly lastLogged = new Map<string, number>();\n\n constructor(\n @Inject(Reflector) private readonly reflector: Reflector,\n @Inject(IP_ALLOWLIST_OPTIONS) private readonly options: IpAllowlistOptions,\n ) {\n const parse = { allowLoopback: options.allowLoopback ?? true };\n this.protectedMatcher = parseIpAllowlist(options.protected, \"protected\", parse);\n this.publicMatcher = parseIpAllowlist(options.public, \"public\", parse);\n this.publicKey = options.publicMetadataKey ?? \"isPublic\";\n }\n\n canActivate(ctx: ExecutionContext): boolean {\n // Only microservice (RPC) traffic is exempt: it has no client IP and is internal\n // transport. Any other context type (graphql, ws, ...) is NOT skipped — it has no\n // resolvable `req.ip` here, so it is denied when a list is enforced (fail closed).\n const type = ctx.getType<string>();\n if (type === \"rpc\") return true;\n if (!this.protectedMatcher && !this.publicMatcher) return true;\n\n const targets = [ctx.getHandler(), ctx.getClass()];\n if (this.reflector.getAllAndOverride<boolean>(SKIP_IP_ALLOWLIST_KEY, targets)) return true;\n\n const isPublic = this.reflector.getAllAndOverride<boolean>(this.publicKey, targets);\n const matcher = isPublic ? this.publicMatcher : this.protectedMatcher;\n if (!matcher) return true;\n\n const ip = type === \"http\" ? ctx.switchToHttp().getRequest<{ ip?: string }>().ip : undefined;\n if (matcher(ip)) return true;\n\n this.notifyBlocked(ip);\n throw new ForbiddenException(\"ip_not_allowed\");\n }\n\n private notifyBlocked(ip: string | undefined): void {\n this.options.onBlocked?.(ip);\n\n // Never log attacker-controlled text: only a well-formed IP is echoed.\n const key = ip && isIP(ip) ? ip : \"invalid\";\n const now = Date.now();\n const last = this.lastLogged.get(key);\n if (last !== undefined && now - last < LOG_INTERVAL_MS) return;\n this.lastLogged.delete(key);\n this.lastLogged.set(key, now);\n if (this.lastLogged.size > MAX_TRACKED_IPS) {\n const oldest = this.lastLogged.keys().next().value;\n if (oldest !== undefined) this.lastLogged.delete(oldest);\n }\n if (!this.options.onBlocked) {\n this.logger.warn(`Blocked request from non-allowlisted IP ${key}`);\n }\n }\n}\n","export const IP_ALLOWLIST_OPTIONS = Symbol(\"IP_ALLOWLIST_OPTIONS\");\n\nexport interface IpAllowlistOptions {\n /**\n * Allowlist for protected (non-public) routes: comma-separated string or\n * array of IPs/CIDRs. Unset/blank = not enforced.\n */\n protected?: string | readonly string[];\n /**\n * Allowlist for public (\"open\") routes, i.e. routes carrying the metadata key\n * below. Unset/blank = not enforced.\n */\n public?: string | readonly string[];\n /**\n * Metadata key that marks a route as public. Defaults to `'isPublic'`, the\n * key used by the common `@Public()` decorator (`SetMetadata('isPublic', true)`).\n */\n publicMetadataKey?: string;\n /**\n * Always allow loopback once a list is set. Default `true`. Set `false` if a\n * reverse proxy runs on the same host and `req.ip` could be its loopback address.\n */\n allowLoopback?: boolean;\n /**\n * Called on EVERY blocked request (feed your metrics/alerting from here).\n * The default Nest Logger warning is rate-limited per IP; this hook is not.\n */\n onBlocked?: (ip: string | undefined) => void;\n}\n","import { SetMetadata } from \"@nestjs/common\";\n\nexport const SKIP_IP_ALLOWLIST_KEY = \"skipIpAllowlist\";\n\n/**\n * Exempts a route (or controller) from the guard. For callers whose IPs cannot\n * be pinned down and that authenticate by other means (e.g. provider webhooks\n * verified by an HMAC signature).\n */\nexport const SkipIpAllowlist = (): MethodDecorator & ClassDecorator =>\n SetMetadata(SKIP_IP_ALLOWLIST_KEY, true);\n"],"mappings":";;;;;;;;AAAA,SAAS,cAAkC;AAC3C,SAAS,iBAAiB;;;ACD1B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AACP,SAAS,iBAAiB;AAC1B,SAAS,YAAY;;;ACTd,IAAM,uBAAuB,uBAAO,sBAAsB;;;ACAjE,SAAS,mBAAmB;AAErB,IAAM,wBAAwB;AAO9B,IAAM,kBAAkB,MAC7B,YAAY,uBAAuB,IAAI;;;AFMzC,IAAM,kBAAkB;AACxB,IAAM,kBAAkB;AASjB,IAAM,mBAAN,MAA8C;AAAA,EAOnD,YACsC,WACW,SAC/C;AAFoC;AACW;AAE/C,UAAM,QAAQ,EAAE,eAAe,QAAQ,iBAAiB,KAAK;AAC7D,SAAK,mBAAmB,iBAAiB,QAAQ,WAAW,aAAa,KAAK;AAC9E,SAAK,gBAAgB,iBAAiB,QAAQ,QAAQ,UAAU,KAAK;AACrE,SAAK,YAAY,QAAQ,qBAAqB;AAAA,EAChD;AAAA,EAPsC;AAAA,EACW;AAAA,EARhC,SAAS,IAAI,OAAO,iBAAiB,IAAI;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa,oBAAI,IAAoB;AAAA,EAYtD,YAAY,KAAgC;AAI1C,UAAM,OAAO,IAAI,QAAgB;AACjC,QAAI,SAAS,MAAO,QAAO;AAC3B,QAAI,CAAC,KAAK,oBAAoB,CAAC,KAAK,cAAe,QAAO;AAE1D,UAAM,UAAU,CAAC,IAAI,WAAW,GAAG,IAAI,SAAS,CAAC;AACjD,QAAI,KAAK,UAAU,kBAA2B,uBAAuB,OAAO,EAAG,QAAO;AAEtF,UAAM,WAAW,KAAK,UAAU,kBAA2B,KAAK,WAAW,OAAO;AAClF,UAAM,UAAU,WAAW,KAAK,gBAAgB,KAAK;AACrD,QAAI,CAAC,QAAS,QAAO;AAErB,UAAM,KAAK,SAAS,SAAS,IAAI,aAAa,EAAE,WAA4B,EAAE,KAAK;AACnF,QAAI,QAAQ,EAAE,EAAG,QAAO;AAExB,SAAK,cAAc,EAAE;AACrB,UAAM,IAAI,mBAAmB,gBAAgB;AAAA,EAC/C;AAAA,EAEQ,cAAc,IAA8B;AAClD,SAAK,QAAQ,YAAY,EAAE;AAG3B,UAAM,MAAM,MAAM,KAAK,EAAE,IAAI,KAAK;AAClC,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,OAAO,KAAK,WAAW,IAAI,GAAG;AACpC,QAAI,SAAS,UAAa,MAAM,OAAO,gBAAiB;AACxD,SAAK,WAAW,OAAO,GAAG;AAC1B,SAAK,WAAW,IAAI,KAAK,GAAG;AAC5B,QAAI,KAAK,WAAW,OAAO,iBAAiB;AAC1C,YAAM,SAAS,KAAK,WAAW,KAAK,EAAE,KAAK,EAAE;AAC7C,UAAI,WAAW,OAAW,MAAK,WAAW,OAAO,MAAM;AAAA,IACzD;AACA,QAAI,CAAC,KAAK,QAAQ,WAAW;AAC3B,WAAK,OAAO,KAAK,2CAA2C,GAAG,EAAE;AAAA,IACnE;AAAA,EACF;AACF;AAzDa,mBAAN;AAAA,EADN,WAAW;AAAA,EASP,0BAAO,SAAS;AAAA,EAChB,0BAAO,oBAAoB;AAAA,GATnB;;;ADpBN,IAAM,oBAAN,MAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAM7B,OAAO,QAAQ,UAA8B,CAAC,GAAkB;AAC9D,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,WAAW;AAAA,QACT,EAAE,SAAS,sBAAsB,UAAU,QAAQ;AAAA,QACnD,EAAE,SAAS,WAAW,UAAU,iBAAiB;AAAA,MACnD;AAAA,IACF;AAAA,EACF;AACF;AAfa,oBAAN;AAAA,EADN,OAAO,CAAC,CAAC;AAAA,GACG;","names":[]}
1
+ {"version":3,"sources":["../src/ip-allowlist.module.ts","../src/ip-allowlist.guard.ts","../src/options.ts","../src/skip-ip-allowlist.decorator.ts"],"sourcesContent":["import { Module, type DynamicModule } from \"@nestjs/common\";\nimport { APP_GUARD } from \"@nestjs/core\";\nimport { IpAllowlistGuard } from \"./ip-allowlist.guard.js\";\nimport { IP_ALLOWLIST_OPTIONS, type IpAllowlistOptions } from \"./options.js\";\n\n@Module({})\nexport class IpAllowlistModule {\n /**\n * Registers the guard globally (APP_GUARD). Import this module BEFORE the\n * module that registers your auth guard so a blocked IP is rejected before\n * any authentication work.\n */\n static forRoot(options: IpAllowlistOptions = {}): DynamicModule {\n return {\n module: IpAllowlistModule,\n providers: [\n { provide: IP_ALLOWLIST_OPTIONS, useValue: options },\n { provide: APP_GUARD, useClass: IpAllowlistGuard },\n ],\n };\n }\n}\n","import {\n ForbiddenException,\n Inject,\n Injectable,\n Logger,\n type CanActivate,\n type ExecutionContext,\n} from \"@nestjs/common\";\nimport { Reflector } from \"@nestjs/core\";\nimport { isIP } from \"node:net\";\nimport { IP_ALLOWLIST_OPTIONS, type BlockReason, type IpAllowlistOptions } from \"./options.js\";\nimport { parseIpAllowlist, type IpMatcher } from \"./ip-matcher.js\";\nimport { SKIP_IP_ALLOWLIST_KEY } from \"./skip-ip-allowlist.decorator.js\";\n\n// Default log line is rate-limited per IP; the map is bounded by evicting the OLDEST\n// entry, so rotating through many IPs can never switch logging off.\nconst LOG_INTERVAL_MS = 60_000;\nconst MAX_TRACKED_IPS = 1000;\n\n/**\n * Restricts routes by source IP. Reads `request.ip`, which Express/Fastify\n * resolve through their own `trust proxy` setting — configure it so `req.ip`\n * is the real client behind your proxy (see `buildTrustProxy` in the\n * `/fastify` entry). `X-Forwarded-For` is never read here.\n */\n@Injectable()\nexport class IpAllowlistGuard implements CanActivate {\n private readonly logger = new Logger(IpAllowlistGuard.name);\n private readonly protectedMatcher: IpMatcher | null;\n private readonly publicMatcher: IpMatcher | null;\n private readonly denyMatcher: IpMatcher | null;\n private readonly publicKey: string;\n private readonly lastLogged = new Map<string, number>();\n\n constructor(\n @Inject(Reflector) private readonly reflector: Reflector,\n @Inject(IP_ALLOWLIST_OPTIONS) private readonly options: IpAllowlistOptions,\n ) {\n const parse = { allowLoopback: options.allowLoopback ?? true };\n this.protectedMatcher = parseIpAllowlist(options.protected, \"protected\", parse);\n this.publicMatcher = parseIpAllowlist(options.public, \"public\", parse);\n // The denylist never adds loopback implicitly: only what the operator lists.\n this.denyMatcher = parseIpAllowlist(options.deny, \"deny\", { allowLoopback: false });\n this.publicKey = options.publicMetadataKey ?? \"isPublic\";\n }\n\n canActivate(ctx: ExecutionContext): boolean {\n // Only microservice (RPC) traffic is exempt: it has no client IP and is internal\n // transport. Any other context type (graphql, ws, ...) is NOT skipped — it has no\n // resolvable `req.ip` here, so it is denied when an allowlist is enforced (fail closed).\n const type = ctx.getType<string>();\n if (type === \"rpc\") return true;\n if (!this.protectedMatcher && !this.publicMatcher && !this.denyMatcher) return true;\n\n const ip = type === \"http\" ? ctx.switchToHttp().getRequest<{ ip?: string }>().ip : undefined;\n\n // Denylist first, on every route (including @SkipIpAllowlist ones): it wins over any allow.\n if (this.denyMatcher?.(ip)) return this.reject(ip, \"denied\");\n\n if (!this.protectedMatcher && !this.publicMatcher) return true;\n const targets = [ctx.getHandler(), ctx.getClass()];\n if (this.reflector.getAllAndOverride<boolean>(SKIP_IP_ALLOWLIST_KEY, targets)) return true;\n\n const isPublic = this.reflector.getAllAndOverride<boolean>(this.publicKey, targets);\n const matcher = isPublic ? this.publicMatcher : this.protectedMatcher;\n if (!matcher) return true;\n if (matcher(ip)) return true;\n\n return this.reject(ip, \"not_allowed\");\n }\n\n private reject(ip: string | undefined, reason: BlockReason): never {\n this.notifyBlocked(ip, reason);\n throw new ForbiddenException(\"ip_not_allowed\");\n }\n\n private notifyBlocked(ip: string | undefined, reason: BlockReason): void {\n this.options.onBlocked?.(ip, reason);\n\n // Never log attacker-controlled text: only a well-formed IP is echoed.\n const key = ip && isIP(ip) ? ip : \"invalid\";\n const logKey = `${reason}:${key}`;\n const now = Date.now();\n const last = this.lastLogged.get(logKey);\n if (last !== undefined && now - last < LOG_INTERVAL_MS) return;\n this.lastLogged.delete(logKey);\n this.lastLogged.set(logKey, now);\n if (this.lastLogged.size > MAX_TRACKED_IPS) {\n const oldest = this.lastLogged.keys().next().value;\n if (oldest !== undefined) this.lastLogged.delete(oldest);\n }\n if (!this.options.onBlocked) {\n this.logger.warn(\n reason === \"denied\"\n ? `Blocked request from denylisted IP ${key}`\n : `Blocked request from non-allowlisted IP ${key}`,\n );\n }\n }\n}\n","export const IP_ALLOWLIST_OPTIONS = Symbol(\"IP_ALLOWLIST_OPTIONS\");\n\n/** Why a request was rejected: on the denylist, or not on the applicable allowlist. */\nexport type BlockReason = \"denied\" | \"not_allowed\";\n\nexport interface IpAllowlistOptions {\n /**\n * Allowlist for protected (non-public) routes: comma-separated string or\n * array of IPs/CIDRs. Unset/blank = not enforced.\n */\n protected?: string | readonly string[];\n /**\n * Allowlist for public (\"open\") routes, i.e. routes carrying the metadata key\n * below. Unset/blank = not enforced.\n */\n public?: string | readonly string[];\n /**\n * Denylist applied to EVERY route (protected and public): comma-separated string\n * or array of IPs/CIDRs that are always rejected. Takes precedence over the\n * allowlists, and — unlike them — also applies to `@SkipIpAllowlist()` routes\n * (a banned address is banned everywhere). Works alone (\"everyone except these\")\n * or together with the allowlists. Loopback is never denied implicitly.\n * Unset/blank = nothing denied.\n */\n deny?: string | readonly string[];\n /**\n * Metadata key that marks a route as public. Defaults to `'isPublic'`, the\n * key used by the common `@Public()` decorator (`SetMetadata('isPublic', true)`).\n */\n publicMetadataKey?: string;\n /**\n * Always allow loopback once a list is set. Default `true`. Set `false` if a\n * reverse proxy runs on the same host and `req.ip` could be its loopback address.\n */\n allowLoopback?: boolean;\n /**\n * Called on EVERY blocked request (feed your metrics/alerting from here).\n * The default Nest Logger warning is rate-limited per IP; this hook is not.\n */\n onBlocked?: (ip: string | undefined, reason: BlockReason) => void;\n}\n","import { SetMetadata } from \"@nestjs/common\";\n\nexport const SKIP_IP_ALLOWLIST_KEY = \"skipIpAllowlist\";\n\n/**\n * Exempts a route (or controller) from the guard. For callers whose IPs cannot\n * be pinned down and that authenticate by other means (e.g. provider webhooks\n * verified by an HMAC signature).\n */\nexport const SkipIpAllowlist = (): MethodDecorator & ClassDecorator =>\n SetMetadata(SKIP_IP_ALLOWLIST_KEY, true);\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idevconn/allowlist-guard",
3
- "version": "0.0.0",
3
+ "version": "0.2.0",
4
4
  "description": "Global NestJS guard that restricts routes by source IP/CIDR, with separate allowlists for protected and public routes. Works on Fastify and Express.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "iDEVconn",
@@ -67,15 +67,9 @@
67
67
  "peerDependencies": {
68
68
  "@nestjs/common": "^10.0.0 || ^11.0.0",
69
69
  "@nestjs/core": "^10.0.0 || ^11.0.0",
70
- "fastify": ">=4.0.0",
71
70
  "reflect-metadata": "^0.1.13 || ^0.2.0",
72
71
  "rxjs": "^7.0.0"
73
72
  },
74
- "peerDependenciesMeta": {
75
- "fastify": {
76
- "optional": true
77
- }
78
- },
79
73
  "devDependencies": {
80
74
  "@changesets/cli": "^3.0.1",
81
75
  "@nestjs/common": "^11.2.3",