@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 +70 -10
- package/dist/fastify.cjs +3 -2
- package/dist/fastify.cjs.map +1 -1
- package/dist/fastify.d.cts +3 -1
- package/dist/fastify.d.ts +3 -1
- package/dist/fastify.js +3 -2
- package/dist/fastify.js.map +1 -1
- package/dist/index.cjs +18 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -2
- package/dist/index.d.ts +15 -2
- package/dist/index.js +18 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/fastify.cjs.map
CHANGED
|
@@ -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
|
|
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":[]}
|
package/dist/fastify.d.cts
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.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
|
-
|
|
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
|
}
|
package/dist/fastify.js.map
CHANGED
|
@@ -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
|
|
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.
|
|
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(
|
|
148
|
+
const last = this.lastLogged.get(logKey);
|
|
141
149
|
if (last !== void 0 && now - last < LOG_INTERVAL_MS) return;
|
|
142
|
-
this.lastLogged.delete(
|
|
143
|
-
this.lastLogged.set(
|
|
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(
|
|
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
|
};
|
package/dist/index.cjs.map
CHANGED
|
@@ -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.
|
|
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(
|
|
75
|
+
const last = this.lastLogged.get(logKey);
|
|
68
76
|
if (last !== void 0 && now - last < LOG_INTERVAL_MS) return;
|
|
69
|
-
this.lastLogged.delete(
|
|
70
|
-
this.lastLogged.set(
|
|
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(
|
|
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
|
|
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.
|
|
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",
|