@idevconn/allowlist-guard 0.0.0 → 0.1.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
@@ -2,6 +2,17 @@
2
2
 
3
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`).
4
4
 
5
+ > [!IMPORTANT]
6
+ > **What this guard does NOT protect against.**
7
+ > It decides by the caller's **IP address only**. It does **not** stop Postman, curl, scripts or Playwright/Puppeteer **coming from an allowed IP** — to the server those requests look exactly like your own front end:
8
+ >
9
+ > - `Origin`, `Referer`, `User-Agent` and `Sec-Fetch-*` headers are trivially forged by Postman/curl, so header checks give no protection.
10
+ > - Playwright/Puppeteer drive a real Chromium; headers and fingerprint are indistinguishable from a human's browser.
11
+ > - A secret embedded in a front-end bundle (API key, HMAC) is extractable in a minute.
12
+ > - Anyone with a valid login can replay requests from a script.
13
+ >
14
+ > The allowlist only helps when your legitimate clients have **known, stable IPs** (office, VPN, your own servers): a request from anywhere else gets `403`. For an **open** API (any user from the internet) an IP list is the wrong tool — use authentication, per-account rate limits and quotas, CAPTCHA / bot protection at the edge (e.g. Cloudflare Turnstile / Bot Management), and registration controls. Treat this guard as an outer perimeter, not as bot protection.
15
+
5
16
  ## Features
6
17
 
7
18
  - IPv4 + IPv6, single addresses and CIDR ranges; IPv4-mapped IPv6 (`::ffff:1.2.3.4`) is normalised.
@@ -19,21 +30,25 @@ Global NestJS guard that restricts routes by **source IP / CIDR**. Two independe
19
30
  npm install @idevconn/allowlist-guard
20
31
  ```
21
32
 
22
- Peer dependencies: `@nestjs/common`, `@nestjs/core` (10 or 11), `reflect-metadata`, `rxjs`; `fastify` only if you use the `/fastify` subpath.
33
+ Peer dependencies: `@nestjs/common`, `@nestjs/core` (10 or 11), `reflect-metadata`, `rxjs`. The `/fastify` subpath has no `fastify` dependency (it is typed structurally).
23
34
 
24
35
  ## Usage
25
36
 
37
+ ### Minimal (NestJS on Express, the default adapter)
38
+
26
39
  ```ts
40
+ // app.module.ts
27
41
  import { Module } from "@nestjs/common";
28
42
  import { IpAllowlistModule } from "@idevconn/allowlist-guard";
43
+ import { AuthModule } from "./auth/auth.module";
29
44
 
30
45
  @Module({
31
46
  imports: [
32
47
  // Import BEFORE the module that registers your auth guard, so a blocked IP
33
48
  // is rejected before any authentication work.
34
49
  IpAllowlistModule.forRoot({
35
- protected: process.env.API_IP_ALLOWLIST, // "203.0.113.5, 10.0.0.0/8"
36
- public: process.env.API_PUBLIC_IP_ALLOWLIST, // login, lead capture, ...
50
+ protected: "203.0.113.5, 10.0.0.0/8, 2001:db8::/32", // authenticated routes
51
+ public: "198.51.100.0/24", // @Public routes: login, lead capture, ...
37
52
  }),
38
53
  AuthModule,
39
54
  ],
@@ -41,6 +56,22 @@ import { IpAllowlistModule } from "@idevconn/allowlist-guard";
41
56
  export class AppModule {}
42
57
  ```
43
58
 
59
+ ```ts
60
+ // main.ts
61
+ import { NestFactory } from "@nestjs/core";
62
+ import type { NestExpressApplication } from "@nestjs/platform-express";
63
+ import { AppModule } from "./app.module";
64
+
65
+ async function bootstrap() {
66
+ const app = await NestFactory.create<NestExpressApplication>(AppModule);
67
+ app.set("trust proxy", 1); // behind ONE reverse proxy; see "Getting the real client IP"
68
+ await app.listen(3000);
69
+ }
70
+ bootstrap();
71
+ ```
72
+
73
+ ### Options
74
+
44
75
  | Option | Meaning |
45
76
  | ------------------- | ---------------------------------------------------------------------------------------------------- |
46
77
  | `protected` | Comma-separated string or array of IPs/CIDRs for non-public routes. Unset/blank = not enforced. |
@@ -51,51 +82,148 @@ export class AppModule {}
51
82
 
52
83
  **The two lists are independent.** Setting only `protected` leaves `@Public` routes (login, lead capture, ...) open to everyone, and vice versa. Set both if you want the whole surface restricted; exempt individual routes with `@SkipIpAllowlist()`.
53
84
 
54
- Exempt a route:
85
+ ### Configuration from the environment (`@nestjs/config`)
86
+
87
+ `forRoot()` takes plain values, so read them from `process.env`. `ConfigModule.forRoot()` loads `.env` synchronously, so list it **before** the allowlist module:
55
88
 
56
89
  ```ts
90
+ @Module({
91
+ imports: [
92
+ ConfigModule.forRoot({ isGlobal: true }),
93
+ IpAllowlistModule.forRoot({
94
+ protected: process.env.API_IP_ALLOWLIST, // unset/blank => not enforced
95
+ public: process.env.API_PUBLIC_IP_ALLOWLIST,
96
+ allowLoopback: process.env.API_IP_ALLOWLIST_ALLOW_LOOPBACK !== "false",
97
+ }),
98
+ AuthModule,
99
+ ],
100
+ })
101
+ export class AppModule {}
102
+ ```
103
+
104
+ ```bash
105
+ # .env
106
+ API_IP_ALLOWLIST=203.0.113.5,10.0.0.0/8
107
+ API_PUBLIC_IP_ALLOWLIST=198.51.100.0/24
108
+ ```
109
+
110
+ A malformed entry (`API_IP_ALLOWLIST=203.0.113.5,banana`) throws at boot naming the option, so a typo never silently opens or closes the API.
111
+
112
+ ### NestJS on Fastify
113
+
114
+ ```ts
115
+ // main.ts
116
+ import { NestFactory } from "@nestjs/core";
117
+ import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify";
118
+ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
119
+ import { buildTrustProxy, parseIpAllowlist, restrictRoutesToAllowlist } from "@idevconn/allowlist-guard/fastify";
120
+ import { AppModule } from "./app.module";
121
+
122
+ async function bootstrap() {
123
+ const app = await NestFactory.create<NestFastifyApplication>(
124
+ AppModule,
125
+ new FastifyAdapter({
126
+ // One trusted hop, and only when the socket peer is one of YOUR proxies.
127
+ trustProxy: buildTrustProxy(process.env.TRUSTED_PROXIES),
128
+ }),
129
+ );
130
+
131
+ // Swagger UI is a Fastify route outside Nest's guard pipeline: restrict it too,
132
+ // BEFORE SwaggerModule.setup registers it.
133
+ const allowed = parseIpAllowlist(process.env.API_IP_ALLOWLIST);
134
+ if (allowed) {
135
+ restrictRoutesToAllowlist(app.getHttpAdapter().getInstance(), allowed, "/api/docs");
136
+ }
137
+ const document = SwaggerModule.createDocument(app, new DocumentBuilder().build());
138
+ SwaggerModule.setup("api/docs", app, document);
139
+
140
+ await app.listen(3000, "0.0.0.0");
141
+ }
142
+ bootstrap();
143
+ ```
144
+
145
+ ### Exempting routes
146
+
147
+ ```ts
148
+ import { Controller, Post, SetMetadata } from "@nestjs/common";
57
149
  import { SkipIpAllowlist } from "@idevconn/allowlist-guard";
58
150
 
59
- @Public()
60
- @SkipIpAllowlist() // authenticated by an HMAC signature instead
61
- @Post("webhooks/:provider")
62
- receive() {}
151
+ const Public = () => SetMetadata("isPublic", true); // your existing @Public()
152
+
153
+ @Controller("webhooks")
154
+ export class WebhooksController {
155
+ // Providers call from IPs you cannot pin; authenticate by HMAC signature instead.
156
+ @Public()
157
+ @SkipIpAllowlist()
158
+ @Post(":provider")
159
+ receive() {}
160
+ }
63
161
  ```
64
162
 
65
- ## Getting the real client IP (read this)
163
+ `@SkipIpAllowlist()` also works on a whole controller class (e.g. a health check controller).
66
164
 
67
- The guard reads `request.ip` and **never** parses `X-Forwarded-For` itself. Behind a reverse proxy/load balancer, configure your framework's trust-proxy setting so `req.ip` is the real client:
165
+ ### Using your own "public" decorator
68
166
 
69
- - **Express:** `app.set("trust proxy", 1)`.
70
- - **Fastify:**
167
+ If your project marks open routes with a different metadata key, tell the guard:
71
168
 
72
- ```ts
73
- import { buildTrustProxy } from "@idevconn/allowlist-guard/fastify";
169
+ ```ts
170
+ export const OPEN_ROUTE = "openRoute";
171
+ export const Open = () => SetMetadata(OPEN_ROUTE, true);
74
172
 
75
- new FastifyAdapter({ trustProxy: buildTrustProxy(process.env.TRUSTED_PROXIES) });
76
- ```
173
+ IpAllowlistModule.forRoot({ public: "198.51.100.0/24", publicMetadataKey: OPEN_ROUTE });
174
+ ```
77
175
 
78
- `buildTrustProxy()` trusts one hop. Hop 0 is the socket peer, so **if the app port is reachable directly, any client can forge `X-Forwarded-For` and pass the allowlist**. Either make the port reachable only via your proxy, or pass `trustedProxies` (the proxy's IP/CIDR) so a direct client's header is ignored.
176
+ ### Alerting on blocked requests
79
177
 
80
- Without any trust-proxy setting `req.ip` is the proxy's own address for every client. If that proxy runs on the **same host**, that address is loopback — which is allowed by default, so the allowlist would be wide open. Set `allowLoopback: false` in that setup (and keep trust-proxy configured).
178
+ `onBlocked` runs on **every** blocked request (the default log line is rate-limited to one per IP per minute), so feed metrics from it:
81
179
 
82
- ## Routes outside Nest (Swagger UI)
180
+ ```ts
181
+ IpAllowlistModule.forRoot({
182
+ protected: process.env.API_IP_ALLOWLIST,
183
+ onBlocked: (ip) => metrics.increment("ip_allowlist.blocked", { ip: ip ?? "unknown" }),
184
+ });
185
+ ```
83
186
 
84
- Fastify-native routes never reach Nest guards. Restrict them with the same list, **before** registering them:
187
+ ### Using the matcher on its own
85
188
 
86
189
  ```ts
87
- import { parseIpAllowlist, restrictRoutesToAllowlist } from "@idevconn/allowlist-guard/fastify";
190
+ import { parseIpAllowlist } from "@idevconn/allowlist-guard";
191
+
192
+ const isAllowed = parseIpAllowlist("203.0.113.5, 10.0.0.0/8", "myList");
193
+ isAllowed?.("10.4.5.6"); // true (returns null when the list is blank = not enforced)
194
+ isAllowed?.("::ffff:203.0.113.5"); // true (IPv4-mapped IPv6 is normalised)
195
+ isAllowed?.("8.8.8.8"); // false
196
+ ```
197
+
198
+ ### Local development and tests
199
+
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`).
201
+
202
+ ## Getting the real client IP (read this)
203
+
204
+ The guard reads `request.ip` and **never** parses `X-Forwarded-For` itself. Behind a reverse proxy/load balancer, configure your framework's trust-proxy setting so `req.ip` is the real client:
205
+
206
+ - **Express:** `app.set("trust proxy", 1)` (one proxy in front).
207
+ - **Fastify:** `buildTrustProxy(...)` from `@idevconn/allowlist-guard/fastify` (see the Fastify example above). It trusts one hop. Hop 0 is the socket peer, so **if the app port is reachable directly, any client can forge `X-Forwarded-For` and pass the allowlist**. Either make the port reachable only via your proxy, or pass `trustedProxies` (the proxy's IP/CIDR) so a direct client's header is ignored.
208
+
209
+ Make the proxy **overwrite** the header with the address it saw, not append to a client-supplied one. nginx:
88
210
 
89
- const allowed = parseIpAllowlist(process.env.API_IP_ALLOWLIST);
90
- if (allowed) restrictRoutesToAllowlist(app.getHttpAdapter().getInstance(), allowed, "/api/docs");
91
- SwaggerModule.setup("api/docs", app, document);
211
+ ```nginx
212
+ location /api/ {
213
+ proxy_pass http://127.0.0.1:3000;
214
+ proxy_set_header X-Forwarded-For $remote_addr; # overwrite, do not use $proxy_add_x_forwarded_for
215
+ }
92
216
  ```
93
217
 
94
- It matches on the resolved route (`routeOptions.url`), not the raw URL, so percent-encoded paths such as `/api/%64ocs` cannot slip past.
218
+ Without any trust-proxy setting `req.ip` is the proxy's own address for every client. If that proxy runs on the **same host**, that address is loopback — which is allowed by default, so the allowlist would be wide open. Set `allowLoopback: false` in that setup (and keep trust-proxy configured). Behind a CDN (e.g. Cloudflare) the proxy chain has two hops; configure the trusted hop count/CIDRs of your CDN accordingly rather than trusting arbitrary headers.
219
+
220
+ ## Routes outside Nest (Swagger UI)
221
+
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.
95
223
 
96
224
  ## Limits
97
225
 
98
- An IP allowlist only helps when your legitimate clients have known IPs. It cannot tell a browser from Postman/Playwright on the same IP — use authentication, rate limiting and bot protection for open APIs.
226
+ See the notice at the top: an IP allowlist is a network perimeter, not bot protection. It cannot tell a browser from Postman or Playwright on the same IP.
99
227
 
100
228
  ## License
101
229
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/fastify/index.ts","../src/ip-matcher.ts"],"sourcesContent":["import type { FastifyInstance } from \"fastify\";\nimport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n\n/**\n * Fastify `trustProxy` function equal to Express's `trust proxy = 1`: trusts\n * only hop 0 (the socket peer, i.e. your reverse proxy), so a client cannot\n * walk past it with extra X-Forwarded-For entries.\n *\n * Hop 0 is the socket peer itself: if the app port is reachable directly (not\n * only through the proxy), ANY client is hop 0 and can forge X-Forwarded-For,\n * defeating the allowlist. Pass `trustedProxies` (IPs/CIDRs of your proxy) to\n * trust hop 0 only when the peer is one of them; a direct client's header is\n * then ignored. Loopback is always included.\n */\nexport function buildTrustProxy(\n trustedProxies?: string | readonly string[],\n): (address: string, hop: number) => boolean {\n const matcher = parseIpAllowlist(trustedProxies, \"trustedProxies\");\n return (address, hop) => hop === 0 && (!matcher || matcher(address));\n}\n\n/**\n * Restricts Fastify-native routes that live outside Nest's guard pipeline\n * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE\n * those routes are added.\n *\n * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw\n * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`\n * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.\n */\nexport function restrictRoutesToAllowlist(\n instance: FastifyInstance,\n allowed: IpMatcher,\n routePrefix: string,\n): void {\n instance.addHook(\"onRequest\", (req, reply, done) => {\n const route = req.routeOptions?.url ?? \"\";\n if (route.startsWith(routePrefix) && !allowed(req.ip)) {\n void reply.code(403).send({ statusCode: 403, message: \"ip_not_allowed\" });\n return;\n }\n done();\n });\n}\n\nexport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n","import { BlockList, isIP } from \"node:net\";\n\nexport type IpMatcher = (ip: string | undefined) => boolean;\n\nexport interface ParseOptions {\n /**\n * Always allow loopback (`127.0.0.0/8`, `::1`) once a list is set. Default `true`\n * (local dev, health probes). Set `false` when a reverse proxy runs on the SAME\n * host and `req.ip` may fall back to its loopback address — otherwise a proxy\n * without trust-proxy configured makes every client look like 127.0.0.1.\n */\n allowLoopback?: boolean;\n}\n\nconst LOOPBACK = [\"127.0.0.0/8\", \"::1\"] as const;\n\n/** `::ffff:1.2.3.4` (what a dual-stack socket reports for IPv4 peers) → `1.2.3.4`. */\nexport function normalizeIp(ip: string): string {\n const mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i.exec(ip);\n return mapped?.[1] ?? ip;\n}\n\nfunction addEntry(list: BlockList, raw: string): void {\n const [addr = \"\", prefix, ...rest] = raw.split(\"/\");\n const family = isIP(addr);\n // A zone id (`fe80::1%eth0`) is accepted by isIP but ignored by BlockList.check,\n // so `::1%x` would match loopback — never allow one in a list or as a client IP.\n if (!family || addr.includes(\"%\") || rest.length > 0) {\n throw new Error(`invalid IP/CIDR \"${raw}\"`);\n }\n const type = family === 4 ? \"ipv4\" : \"ipv6\";\n if (prefix === undefined) {\n list.addAddress(addr, type);\n return;\n }\n const bits = Number(prefix);\n const max = family === 4 ? 32 : 128;\n if (!/^\\d+$/.test(prefix) || bits > max) throw new Error(`invalid IP/CIDR \"${raw}\"`);\n list.addSubnet(addr, bits, type);\n}\n\n/**\n * Builds a matcher from a comma-separated string or an array of IPs/CIDRs\n * (IPv4 + IPv6). Returns `null` when the list is unset/blank (= not enforced).\n * Loopback is part of an enforced list by default (see `allowLoopback`). Throws on a malformed entry — a typo must not silently turn\n * into \"everyone allowed\" or \"everyone blocked\". `label` names the option in\n * the error message.\n */\nexport function parseIpAllowlist(\n value: string | readonly string[] | undefined,\n label = \"allowlist\",\n { allowLoopback = true }: ParseOptions = {},\n): IpMatcher | null {\n const raw = typeof value === \"string\" ? value.split(\",\") : (value ?? []);\n const entries = raw.map((s) => s.trim()).filter(Boolean);\n if (entries.length === 0) return null;\n\n const list = new BlockList();\n for (const entry of [...(allowLoopback ? LOOPBACK : []), ...entries]) {\n try {\n addEntry(list, entry);\n } catch (err) {\n throw new Error(`${label}: ${(err as Error).message}`, { cause: err });\n }\n }\n return (ip) => {\n if (!ip || ip.includes(\"%\")) return false;\n const addr = normalizeIp(ip);\n const family = isIP(addr);\n if (!family) return false;\n return list.check(addr, family === 4 ? \"ipv4\" : \"ipv6\");\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,sBAAgC;AAchC,IAAM,WAAW,CAAC,eAAe,KAAK;AAG/B,SAAS,YAAY,IAAoB;AAC9C,QAAM,SAAS,sCAAsC,KAAK,EAAE;AAC5D,SAAO,SAAS,CAAC,KAAK;AACxB;AAEA,SAAS,SAAS,MAAiB,KAAmB;AACpD,QAAM,CAAC,OAAO,IAAI,QAAQ,GAAG,IAAI,IAAI,IAAI,MAAM,GAAG;AAClD,QAAM,aAAS,sBAAK,IAAI;AAGxB,MAAI,CAAC,UAAU,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,GAAG;AACpD,UAAM,IAAI,MAAM,oBAAoB,GAAG,GAAG;AAAA,EAC5C;AACA,QAAM,OAAO,WAAW,IAAI,SAAS;AACrC,MAAI,WAAW,QAAW;AACxB,SAAK,WAAW,MAAM,IAAI;AAC1B;AAAA,EACF;AACA,QAAM,OAAO,OAAO,MAAM;AAC1B,QAAM,MAAM,WAAW,IAAI,KAAK;AAChC,MAAI,CAAC,QAAQ,KAAK,MAAM,KAAK,OAAO,IAAK,OAAM,IAAI,MAAM,oBAAoB,GAAG,GAAG;AACnF,OAAK,UAAU,MAAM,MAAM,IAAI;AACjC;AASO,SAAS,iBACd,OACA,QAAQ,aACR,EAAE,gBAAgB,KAAK,IAAkB,CAAC,GACxB;AAClB,QAAM,MAAM,OAAO,UAAU,WAAW,MAAM,MAAM,GAAG,IAAK,SAAS,CAAC;AACtE,QAAM,UAAU,IAAI,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AACvD,MAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,QAAM,OAAO,IAAI,0BAAU;AAC3B,aAAW,SAAS,CAAC,GAAI,gBAAgB,WAAW,CAAC,GAAI,GAAG,OAAO,GAAG;AACpE,QAAI;AACF,eAAS,MAAM,KAAK;AAAA,IACtB,SAAS,KAAK;AACZ,YAAM,IAAI,MAAM,GAAG,KAAK,KAAM,IAAc,OAAO,IAAI,EAAE,OAAO,IAAI,CAAC;AAAA,IACvE;AAAA,EACF;AACA,SAAO,CAAC,OAAO;AACb,QAAI,CAAC,MAAM,GAAG,SAAS,GAAG,EAAG,QAAO;AACpC,UAAM,OAAO,YAAY,EAAE;AAC3B,UAAM,aAAS,sBAAK,IAAI;AACxB,QAAI,CAAC,OAAQ,QAAO;AACpB,WAAO,KAAK,MAAM,MAAM,WAAW,IAAI,SAAS,MAAM;AAAA,EACxD;AACF;;;AD1DO,SAAS,gBACd,gBAC2C;AAC3C,QAAM,UAAU,iBAAiB,gBAAgB,gBAAgB;AACjE,SAAO,CAAC,SAAS,QAAQ,QAAQ,MAAM,CAAC,WAAW,QAAQ,OAAO;AACpE;AAWO,SAAS,0BACd,UACA,SACA,aACM;AACN,WAAS,QAAQ,aAAa,CAAC,KAAK,OAAO,SAAS;AAClD,UAAM,QAAQ,IAAI,cAAc,OAAO;AACvC,QAAI,MAAM,WAAW,WAAW,KAAK,CAAC,QAAQ,IAAI,EAAE,GAAG;AACrD,WAAK,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,YAAY,KAAK,SAAS,iBAAiB,CAAC;AACxE;AAAA,IACF;AACA,SAAK;AAAA,EACP,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../src/fastify/index.ts","../src/ip-matcher.ts"],"sourcesContent":["import { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n\n/**\n * Fastify `trustProxy` function equal to Express's `trust proxy = 1`: trusts\n * only hop 0 (the socket peer, i.e. your reverse proxy), so a client cannot\n * walk past it with extra X-Forwarded-For entries.\n *\n * Hop 0 is the socket peer itself: if the app port is reachable directly (not\n * only through the proxy), ANY client is hop 0 and can forge X-Forwarded-For,\n * defeating the allowlist. Pass `trustedProxies` (IPs/CIDRs of your proxy) to\n * trust hop 0 only when the peer is one of them; a direct client's header is\n * then ignored. Loopback is always included.\n */\nexport function buildTrustProxy(\n trustedProxies?: string | readonly string[],\n): (address: string, hop: number) => boolean {\n const matcher = parseIpAllowlist(trustedProxies, \"trustedProxies\");\n return (address, hop) => hop === 0 && (!matcher || matcher(address));\n}\n\n/**\n * Structural slice of a Fastify instance — deliberately not `FastifyInstance`, so\n * it type-checks even when the app and `@nestjs/platform-fastify` resolve two\n * different copies of `fastify`.\n */\nexport interface FastifyHookHost {\n addHook(\n name: \"onRequest\",\n hook: (\n req: { ip: string; routeOptions?: { url?: string } },\n reply: { code(statusCode: number): { send(payload: unknown): unknown } },\n done: () => void,\n ) => void,\n ): unknown;\n}\n\n/**\n * Restricts Fastify-native routes that live outside Nest's guard pipeline\n * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE\n * those routes are added.\n *\n * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw\n * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`\n * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.\n */\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,4 +1,3 @@
1
- import { FastifyInstance } from 'fastify';
2
1
  import { I as IpMatcher } from './ip-matcher-D4MA1kIq.cjs';
3
2
  export { p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.cjs';
4
3
 
@@ -14,6 +13,23 @@ export { p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.cjs';
14
13
  * then ignored. Loopback is always included.
15
14
  */
16
15
  declare function buildTrustProxy(trustedProxies?: string | readonly string[]): (address: string, hop: number) => boolean;
16
+ /**
17
+ * Structural slice of a Fastify instance — deliberately not `FastifyInstance`, so
18
+ * it type-checks even when the app and `@nestjs/platform-fastify` resolve two
19
+ * different copies of `fastify`.
20
+ */
21
+ interface FastifyHookHost {
22
+ addHook(name: "onRequest", hook: (req: {
23
+ ip: string;
24
+ routeOptions?: {
25
+ url?: string;
26
+ };
27
+ }, reply: {
28
+ code(statusCode: number): {
29
+ send(payload: unknown): unknown;
30
+ };
31
+ }, done: () => void) => void): unknown;
32
+ }
17
33
  /**
18
34
  * Restricts Fastify-native routes that live outside Nest's guard pipeline
19
35
  * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE
@@ -23,6 +39,6 @@ declare function buildTrustProxy(trustedProxies?: string | readonly string[]): (
23
39
  * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`
24
40
  * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.
25
41
  */
26
- declare function restrictRoutesToAllowlist(instance: FastifyInstance, allowed: IpMatcher, routePrefix: string): void;
42
+ declare function restrictRoutesToAllowlist(instance: FastifyHookHost, allowed: IpMatcher, routePrefix: string): void;
27
43
 
28
- export { IpMatcher, buildTrustProxy, restrictRoutesToAllowlist };
44
+ export { type FastifyHookHost, IpMatcher, buildTrustProxy, restrictRoutesToAllowlist };
package/dist/fastify.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { FastifyInstance } from 'fastify';
2
1
  import { I as IpMatcher } from './ip-matcher-D4MA1kIq.js';
3
2
  export { p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.js';
4
3
 
@@ -14,6 +13,23 @@ export { p as parseIpAllowlist } from './ip-matcher-D4MA1kIq.js';
14
13
  * then ignored. Loopback is always included.
15
14
  */
16
15
  declare function buildTrustProxy(trustedProxies?: string | readonly string[]): (address: string, hop: number) => boolean;
16
+ /**
17
+ * Structural slice of a Fastify instance — deliberately not `FastifyInstance`, so
18
+ * it type-checks even when the app and `@nestjs/platform-fastify` resolve two
19
+ * different copies of `fastify`.
20
+ */
21
+ interface FastifyHookHost {
22
+ addHook(name: "onRequest", hook: (req: {
23
+ ip: string;
24
+ routeOptions?: {
25
+ url?: string;
26
+ };
27
+ }, reply: {
28
+ code(statusCode: number): {
29
+ send(payload: unknown): unknown;
30
+ };
31
+ }, done: () => void) => void): unknown;
32
+ }
17
33
  /**
18
34
  * Restricts Fastify-native routes that live outside Nest's guard pipeline
19
35
  * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE
@@ -23,6 +39,6 @@ declare function buildTrustProxy(trustedProxies?: string | readonly string[]): (
23
39
  * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`
24
40
  * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.
25
41
  */
26
- declare function restrictRoutesToAllowlist(instance: FastifyInstance, allowed: IpMatcher, routePrefix: string): void;
42
+ declare function restrictRoutesToAllowlist(instance: FastifyHookHost, allowed: IpMatcher, routePrefix: string): void;
27
43
 
28
- export { IpMatcher, buildTrustProxy, restrictRoutesToAllowlist };
44
+ export { type FastifyHookHost, IpMatcher, buildTrustProxy, restrictRoutesToAllowlist };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/fastify/index.ts"],"sourcesContent":["import type { FastifyInstance } from \"fastify\";\nimport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n\n/**\n * Fastify `trustProxy` function equal to Express's `trust proxy = 1`: trusts\n * only hop 0 (the socket peer, i.e. your reverse proxy), so a client cannot\n * walk past it with extra X-Forwarded-For entries.\n *\n * Hop 0 is the socket peer itself: if the app port is reachable directly (not\n * only through the proxy), ANY client is hop 0 and can forge X-Forwarded-For,\n * defeating the allowlist. Pass `trustedProxies` (IPs/CIDRs of your proxy) to\n * trust hop 0 only when the peer is one of them; a direct client's header is\n * then ignored. Loopback is always included.\n */\nexport function buildTrustProxy(\n trustedProxies?: string | readonly string[],\n): (address: string, hop: number) => boolean {\n const matcher = parseIpAllowlist(trustedProxies, \"trustedProxies\");\n return (address, hop) => hop === 0 && (!matcher || matcher(address));\n}\n\n/**\n * Restricts Fastify-native routes that live outside Nest's guard pipeline\n * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE\n * those routes are added.\n *\n * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw\n * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`\n * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.\n */\nexport function restrictRoutesToAllowlist(\n instance: FastifyInstance,\n allowed: IpMatcher,\n routePrefix: string,\n): void {\n instance.addHook(\"onRequest\", (req, reply, done) => {\n const route = req.routeOptions?.url ?? \"\";\n if (route.startsWith(routePrefix) && !allowed(req.ip)) {\n void reply.code(403).send({ statusCode: 403, message: \"ip_not_allowed\" });\n return;\n }\n done();\n });\n}\n\nexport { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n"],"mappings":";;;;;AAcO,SAAS,gBACd,gBAC2C;AAC3C,QAAM,UAAU,iBAAiB,gBAAgB,gBAAgB;AACjE,SAAO,CAAC,SAAS,QAAQ,QAAQ,MAAM,CAAC,WAAW,QAAQ,OAAO;AACpE;AAWO,SAAS,0BACd,UACA,SACA,aACM;AACN,WAAS,QAAQ,aAAa,CAAC,KAAK,OAAO,SAAS;AAClD,UAAM,QAAQ,IAAI,cAAc,OAAO;AACvC,QAAI,MAAM,WAAW,WAAW,KAAK,CAAC,QAAQ,IAAI,EAAE,GAAG;AACrD,WAAK,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,YAAY,KAAK,SAAS,iBAAiB,CAAC;AACxE;AAAA,IACF;AACA,SAAK;AAAA,EACP,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../src/fastify/index.ts"],"sourcesContent":["import { parseIpAllowlist, type IpMatcher } from \"../ip-matcher.js\";\n\n/**\n * Fastify `trustProxy` function equal to Express's `trust proxy = 1`: trusts\n * only hop 0 (the socket peer, i.e. your reverse proxy), so a client cannot\n * walk past it with extra X-Forwarded-For entries.\n *\n * Hop 0 is the socket peer itself: if the app port is reachable directly (not\n * only through the proxy), ANY client is hop 0 and can forge X-Forwarded-For,\n * defeating the allowlist. Pass `trustedProxies` (IPs/CIDRs of your proxy) to\n * trust hop 0 only when the peer is one of them; a direct client's header is\n * then ignored. Loopback is always included.\n */\nexport function buildTrustProxy(\n trustedProxies?: string | readonly string[],\n): (address: string, hop: number) => boolean {\n const matcher = parseIpAllowlist(trustedProxies, \"trustedProxies\");\n return (address, hop) => hop === 0 && (!matcher || matcher(address));\n}\n\n/**\n * Structural slice of a Fastify instance — deliberately not `FastifyInstance`, so\n * it type-checks even when the app and `@nestjs/platform-fastify` resolve two\n * different copies of `fastify`.\n */\nexport interface FastifyHookHost {\n addHook(\n name: \"onRequest\",\n hook: (\n req: { ip: string; routeOptions?: { url?: string } },\n reply: { code(statusCode: number): { send(payload: unknown): unknown } },\n done: () => void,\n ) => void,\n ): unknown;\n}\n\n/**\n * Restricts Fastify-native routes that live outside Nest's guard pipeline\n * (e.g. Swagger UI under `/api/docs`) with the same allowlist. Register BEFORE\n * those routes are added.\n *\n * Matches on the ROUTE Fastify resolved (`routeOptions.url`), not the raw\n * `req.url`: Fastify percent-decodes the path before routing, so `/api/%64ocs`\n * reaches the docs handler while `req.url.startsWith('/api/docs')` is false.\n */\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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idevconn/allowlist-guard",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "description": "Global NestJS guard that restricts routes by source IP/CIDR, with separate allowlists for protected and public routes. Works on Fastify and Express.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "iDEVconn",
@@ -67,15 +67,9 @@
67
67
  "peerDependencies": {
68
68
  "@nestjs/common": "^10.0.0 || ^11.0.0",
69
69
  "@nestjs/core": "^10.0.0 || ^11.0.0",
70
- "fastify": ">=4.0.0",
71
70
  "reflect-metadata": "^0.1.13 || ^0.2.0",
72
71
  "rxjs": "^7.0.0"
73
72
  },
74
- "peerDependenciesMeta": {
75
- "fastify": {
76
- "optional": true
77
- }
78
- },
79
73
  "devDependencies": {
80
74
  "@changesets/cli": "^3.0.1",
81
75
  "@nestjs/common": "^11.2.3",