@idevconn/allowlist-guard 0.1.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,6 +1,6 @@
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
4
 
5
5
  > [!IMPORTANT]
6
6
  > **What this guard does NOT protect against.**
@@ -11,12 +11,13 @@ Global NestJS guard that restricts routes by **source IP / CIDR**. Two independe
11
11
  > - A secret embedded in a front-end bundle (API key, HMAC) is extractable in a minute.
12
12
  > - Anyone with a valid login can replay requests from a script.
13
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. Treat this guard as an outer perimeter, not as bot protection.
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.
15
15
 
16
16
  ## Features
17
17
 
18
18
  - IPv4 + IPv6, single addresses and CIDR ranges; IPv4-mapped IPv6 (`::ffff:1.2.3.4`) is normalised.
19
- - 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.
20
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).
21
22
  - `@SkipIpAllowlist()` exempts a route or controller (e.g. HMAC-signed provider webhooks whose IPs you cannot pin).
22
23
  - A malformed entry throws at boot naming the option — a typo never silently becomes "everyone allowed" or "everyone blocked".
@@ -76,9 +77,10 @@ bootstrap();
76
77
  | ------------------- | ---------------------------------------------------------------------------------------------------- |
77
78
  | `protected` | Comma-separated string or array of IPs/CIDRs for non-public routes. Unset/blank = not enforced. |
78
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. |
79
81
  | `publicMetadataKey` | Metadata key marking a route public. Default `'isPublic'` (the common `@Public()` decorator's key). |
80
82
  | `allowLoopback` | Default `true`. Set `false` if a reverse proxy runs on the same host (see below). |
81
- | `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`. |
82
84
 
83
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()`.
84
86
 
@@ -131,8 +133,9 @@ async function bootstrap() {
131
133
  // Swagger UI is a Fastify route outside Nest's guard pipeline: restrict it too,
132
134
  // BEFORE SwaggerModule.setup registers it.
133
135
  const allowed = parseIpAllowlist(process.env.API_IP_ALLOWLIST);
134
- if (allowed) {
135
- restrictRoutesToAllowlist(app.getHttpAdapter().getInstance(), allowed, "/api/docs");
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);
136
139
  }
137
140
  const document = SwaggerModule.createDocument(app, new DocumentBuilder().build());
138
141
  SwaggerModule.setup("api/docs", app, document);
@@ -142,6 +145,58 @@ async function bootstrap() {
142
145
  bootstrap();
143
146
  ```
144
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
+
145
200
  ### Exempting routes
146
201
 
147
202
  ```ts
@@ -160,7 +215,7 @@ export class WebhooksController {
160
215
  }
161
216
  ```
162
217
 
163
- `@SkipIpAllowlist()` also works on a whole controller class (e.g. a health check controller).
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.
164
219
 
165
220
  ### Using your own "public" decorator
166
221
 
@@ -180,7 +235,8 @@ IpAllowlistModule.forRoot({ public: "198.51.100.0/24", publicMetadataKey: OPEN_R
180
235
  ```ts
181
236
  IpAllowlistModule.forRoot({
182
237
  protected: process.env.API_IP_ALLOWLIST,
183
- onBlocked: (ip) => metrics.increment("ip_allowlist.blocked", { ip: ip ?? "unknown" }),
238
+ onBlocked: (ip, reason) =>
239
+ metrics.increment("ip_allowlist.blocked", { ip: ip ?? "unknown", reason }), // "denied" | "not_allowed"
184
240
  });
185
241
  ```
186
242
 
@@ -193,11 +249,15 @@ const isAllowed = parseIpAllowlist("203.0.113.5, 10.0.0.0/8", "myList");
193
249
  isAllowed?.("10.4.5.6"); // true (returns null when the list is blank = not enforced)
194
250
  isAllowed?.("::ffff:203.0.113.5"); // true (IPv4-mapped IPv6 is normalised)
195
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
196
256
  ```
197
257
 
198
258
  ### Local development and tests
199
259
 
200
- Leave the lists 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`).
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`).
201
261
 
202
262
  ## Getting the real client IP (read this)
203
263
 
@@ -219,7 +279,7 @@ Without any trust-proxy setting `req.ip` is the proxy's own address for every cl
219
279
 
220
280
  ## Routes outside Nest (Swagger UI)
221
281
 
222
- Fastify-native routes never reach Nest guards; `restrictRoutesToAllowlist` (see the Fastify example) applies the same list to them. It matches on the resolved route (`routeOptions.url`), not the raw URL, so percent-encoded paths such as `/api/%64ocs` cannot slip past.
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.
223
283
 
224
284
  ## Limits
225
285
 
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 { 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 */\nexport function restrictRoutesToAllowlist(\n instance: FastifyHookHost,\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;;;AD3DO,SAAS,gBACd,gBAC2C;AAC3C,QAAM,UAAU,iBAAiB,gBAAgB,gBAAgB;AACjE,SAAO,CAAC,SAAS,QAAQ,QAAQ,MAAM,CAAC,WAAW,QAAQ,OAAO;AACpE;AA2BO,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":[]}
@@ -38,7 +38,9 @@ interface FastifyHookHost {
38
38
  * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw
39
39
  * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`
40
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.
41
43
  */
42
- declare function restrictRoutesToAllowlist(instance: FastifyHookHost, allowed: IpMatcher, routePrefix: string): void;
44
+ declare function restrictRoutesToAllowlist(instance: FastifyHookHost, allowed: IpMatcher | null, routePrefix: string, denied?: IpMatcher | null): void;
43
45
 
44
46
  export { type FastifyHookHost, IpMatcher, buildTrustProxy, restrictRoutesToAllowlist };
package/dist/fastify.d.ts CHANGED
@@ -38,7 +38,9 @@ interface FastifyHookHost {
38
38
  * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw
39
39
  * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`
40
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.
41
43
  */
42
- declare function restrictRoutesToAllowlist(instance: FastifyHookHost, allowed: IpMatcher, routePrefix: string): void;
44
+ declare function restrictRoutesToAllowlist(instance: FastifyHookHost, allowed: IpMatcher | null, routePrefix: string, denied?: IpMatcher | null): void;
43
45
 
44
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 { 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 */\nexport function restrictRoutesToAllowlist(\n instance: FastifyHookHost,\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":";;;;;AAaO,SAAS,gBACd,gBAC2C;AAC3C,QAAM,UAAU,iBAAiB,gBAAgB,gBAAgB;AACjE,SAAO,CAAC,SAAS,QAAQ,QAAQ,MAAM,CAAC,WAAW,QAAQ,OAAO;AACpE;AA2BO,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.1.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",