@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 +154 -26
- package/dist/fastify.cjs.map +1 -1
- package/dist/fastify.d.cts +19 -3
- package/dist/fastify.d.ts +19 -3
- package/dist/fastify.js.map +1 -1
- package/package.json +1 -7
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
|
|
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:
|
|
36
|
-
public:
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
@
|
|
62
|
-
|
|
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
|
-
|
|
163
|
+
`@SkipIpAllowlist()` also works on a whole controller class (e.g. a health check controller).
|
|
66
164
|
|
|
67
|
-
|
|
165
|
+
### Using your own "public" decorator
|
|
68
166
|
|
|
69
|
-
|
|
70
|
-
- **Fastify:**
|
|
167
|
+
If your project marks open routes with a different metadata key, tell the guard:
|
|
71
168
|
|
|
72
|
-
|
|
73
|
-
|
|
169
|
+
```ts
|
|
170
|
+
export const OPEN_ROUTE = "openRoute";
|
|
171
|
+
export const Open = () => SetMetadata(OPEN_ROUTE, true);
|
|
74
172
|
|
|
75
|
-
|
|
76
|
-
|
|
173
|
+
IpAllowlistModule.forRoot({ public: "198.51.100.0/24", publicMetadataKey: OPEN_ROUTE });
|
|
174
|
+
```
|
|
77
175
|
|
|
78
|
-
|
|
176
|
+
### Alerting on blocked requests
|
|
79
177
|
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
+
### Using the matcher on its own
|
|
85
188
|
|
|
86
189
|
```ts
|
|
87
|
-
import { parseIpAllowlist
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/fastify.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/fastify/index.ts","../src/ip-matcher.ts"],"sourcesContent":["import
|
|
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":[]}
|
package/dist/fastify.d.cts
CHANGED
|
@@ -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:
|
|
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:
|
|
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.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/fastify/index.ts"],"sourcesContent":["import
|
|
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.
|
|
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",
|