Skip to content

Commit 566a6ce

Browse files
Octo8080Xbartlomiejuclaude
authored
feat: add IP filter middleware (#3035)
Closes #3011 --------- Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e0ee53d commit 566a6ce

8 files changed

Lines changed: 315 additions & 0 deletions

File tree

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@std/collections": "jsr:@std/collections@^1.1.2",
4949
"@std/dotenv": "jsr:@std/dotenv@^0.225.5",
5050
"@std/http": "jsr:@std/http@^1.0.15",
51+
"@std/net": "jsr:@std/net@^1.0.6",
5152
"@std/uuid": "jsr:@std/uuid@^1.0.7",
5253
"@supabase/postgrest-js": "npm:@supabase/postgrest-js@^1.21.4",
5354
"@types/mime-db": "npm:@types/mime-db@^1.43.6",

deno.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/latest/plugins/ip-filter.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
description: "Restrict access by IP address with the ipFilter middleware"
3+
---
4+
5+
The `ipFilter()` middleware restricts access based on the client's IP address.
6+
It supports deny lists, allow lists, and CIDR subnet matching. Deny rules always
7+
take precedence over allow rules.
8+
9+
```ts main.ts
10+
import { App, ipFilter } from "fresh";
11+
12+
const app = new App()
13+
.use(ipFilter({
14+
denyList: ["192.168.1.10", "10.0.0.0/8"],
15+
allowList: ["192.168.1.0/24"],
16+
}))
17+
.get("/", () => new Response("hello"));
18+
```
19+
20+
## Deny list
21+
22+
Block specific IPs or subnets. Any request from a matching address receives a
23+
403 Forbidden response:
24+
25+
```ts main.ts
26+
import { App, ipFilter } from "fresh";
27+
28+
const app = new App()
29+
.use(ipFilter({
30+
denyList: ["192.168.1.10", "2001:db8::1", "10.0.0.0/8"],
31+
}));
32+
```
33+
34+
## Allow list
35+
36+
When an allow list is provided, only matching IPs are permitted. All other
37+
addresses are blocked:
38+
39+
```ts main.ts
40+
import { App, ipFilter } from "fresh";
41+
42+
const app = new App()
43+
.use(ipFilter({
44+
allowList: ["203.0.113.0/24", "2001:db8::/32"],
45+
}));
46+
```
47+
48+
## Combined rules
49+
50+
When both lists are provided, deny rules are checked first. An IP that appears
51+
in both lists is blocked:
52+
53+
```ts main.ts
54+
import { App, ipFilter } from "fresh";
55+
56+
const app = new App()
57+
.use(ipFilter({
58+
denyList: ["192.168.1.10"],
59+
allowList: ["192.168.1.0/24"],
60+
}));
61+
```
62+
63+
In this example, all of `192.168.1.0/24` is allowed except `192.168.1.10`.
64+
65+
## Custom blocked response
66+
67+
By default, blocked requests receive a `403 Forbidden` response. Use the
68+
`onBlocked` callback to customize this:
69+
70+
```ts main.ts
71+
import { App, ipFilter } from "fresh";
72+
73+
const app = new App()
74+
.use(ipFilter({
75+
denyList: ["10.0.0.0/8"],
76+
}, {
77+
onBlocked: (remote, ctx) => {
78+
console.log(`Blocked ${remote.addr} from ${ctx.url.pathname}`);
79+
return new Response("Access denied", { status: 401 });
80+
},
81+
}));
82+
```
83+
84+
The `onBlocked` callback receives:
85+
86+
- `remote.addr` -- the client's IP address
87+
- `remote.type` -- `"IPv4"` or `"IPv6"`
88+
- `ctx` -- the request [context](/docs/concepts/context)

docs/toc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const toc: RawTableOfContents = {
8787
["cors", "cors", "link:latest"],
8888
["csrf", "csrf", "link:latest"],
8989
["csp", "csp", "link:latest"],
90+
["ip-filter", "ipFilter", "link:latest"],
9091
["trailing-slashes", "trailingSlashes", "link:latest"],
9192
],
9293
},

packages/fresh/deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@std/http": "jsr:@std/http@^1.0.21",
2222
"@std/jsonc": "jsr:@std/jsonc@^1.0.2",
2323
"@std/media-types": "jsr:@std/media-types@^1.1.0",
24+
"@std/net": "jsr:@std/net@^1.0.6",
2425
"@std/path": "jsr:@std/path@^1.1.2",
2526
"@std/semver": "jsr:@std/semver@^1.0.6",
2627
"@std/uuid": "jsr:@std/uuid@^1.0.9",
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { Context } from "../context.ts";
2+
import type { Middleware } from "./mod.ts";
3+
import { isIPv4, matchSubnets } from "@std/net/unstable-ip";
4+
5+
/**
6+
* Configuration rules for IP restriction middleware.
7+
*/
8+
export interface IpFilterRules {
9+
/**
10+
* List of IP addresses or CIDR blocks to deny access.
11+
* If an IP matches any entry in this list, access will be blocked.
12+
*
13+
* @example ["192.168.1.10", "10.0.0.0/8", "2001:db8::1"]
14+
*/
15+
denyList?: string[];
16+
17+
/**
18+
* List of IP addresses or CIDR blocks to allow access.
19+
* If specified, only IPs matching entries in this list will be allowed.
20+
* If empty or undefined, all IPs are allowed (unless in denyList).
21+
*
22+
* @example ["192.168.1.0/24", "203.0.113.0/24", "2001:db8::/32"]
23+
*/
24+
allowList?: string[];
25+
}
26+
27+
export interface IpFilterOptions {
28+
/**
29+
* Called when a request is blocked by the IP filter.
30+
*
31+
* The function receives the remote information and the current request
32+
* context and should return a Response (or a Promise resolving to one)
33+
* which will be sent back to the client. If not provided, a default
34+
* 403 Forbidden response will be used.
35+
*
36+
* Parameters:
37+
* - `remote.addr` - the remote IP address as a string.
38+
* - `remote.type` - the network family: "IPv4" or "IPv6".
39+
* - `ctx` - the request `Context` which can be used to inspect the
40+
* request or produce a custom response.
41+
*
42+
* @example
43+
* ```ts
44+
* const options: IpFilterOptions = {
45+
* onBlocked: (remote, ctx) => {
46+
* console.log(`Blocked ${remote.addr} (${remote.type})`, ctx.url);
47+
* return new Response("Access denied", { status: 401 });
48+
* },
49+
* };
50+
* ```
51+
*/
52+
onBlocked?: <State>(
53+
remote: {
54+
addr: string;
55+
type: Deno.NetworkInterfaceInfo["family"];
56+
},
57+
ctx: Context<State>,
58+
) => Response | Promise<Response>;
59+
}
60+
61+
/**
62+
* IP restriction Middleware for Fresh.
63+
*
64+
* @param rules Deny and allow rules object.
65+
* @param options Options for the IP Restriction middleware.
66+
* @returns The middleware handler function.
67+
*
68+
* Filters rule priority is denyList, then allowList.
69+
* If an IP is in the denyList, it will be blocked regardless of the allowList.
70+
*
71+
* @example Basic usage (with defaults)
72+
* ```ts
73+
* const app = new App<State>()
74+
*
75+
* app.use(ipFilter({
76+
* denyList: ["192.168.1.10", "2001:db8::1"]
77+
* }));
78+
* ```
79+
*
80+
* @example Custom blocked handler
81+
* ```ts
82+
* app.use(ipFilter({
83+
* denyList: ["192.168.1.10", "2001:db8::1"]
84+
* }, {
85+
* onBlocked: (remote, ctx) => {
86+
* console.log(
87+
* `Request URL: ${ctx.url}, Blocked IP: ${remote.addr} of type ${remote.type}`,
88+
* );
89+
* return new Response("Access denied", { status: 401 });
90+
* },
91+
* }));
92+
* ```
93+
*/
94+
export function ipFilter<State>(
95+
rules: IpFilterRules,
96+
options?: IpFilterOptions,
97+
): Middleware<State> {
98+
const onBlock = options?.onBlocked ??
99+
(() => new Response("Forbidden", { status: 403 }));
100+
return function ipFilter<State>(ctx: Context<State>) {
101+
if (
102+
ctx.info.remoteAddr.transport !== "udp" &&
103+
ctx.info.remoteAddr.transport !== "tcp"
104+
) {
105+
return ctx.next();
106+
}
107+
108+
const addr = ctx.info.remoteAddr.hostname;
109+
const type = isIPv4(addr) ? "IPv4" : "IPv6";
110+
111+
if (matchSubnets(addr, rules.denyList || [])) {
112+
return onBlock({ addr, type }, ctx);
113+
}
114+
115+
if (
116+
(rules.allowList || []).length === 0 ||
117+
matchSubnets(addr, rules.allowList || [])
118+
) {
119+
return ctx.next();
120+
}
121+
122+
return onBlock({ addr, type }, ctx);
123+
};
124+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { App } from "../app.ts";
2+
import type { Context } from "../context.ts";
3+
import {
4+
ipFilter,
5+
type IpFilterOptions,
6+
type IpFilterRules,
7+
} from "./ip_filter.ts";
8+
import { expect } from "@std/expect";
9+
10+
function testHandler<T>(
11+
remoteAddr: string,
12+
ipFilterRules: IpFilterRules,
13+
options?: IpFilterOptions,
14+
): (request: Request) => Promise<Response> {
15+
function remoteHostOverride(ctx: Context<T>) {
16+
(ctx.info.remoteAddr as { hostname: string }).hostname = remoteAddr;
17+
return ctx.next();
18+
}
19+
20+
return new App<T>()
21+
.use(remoteHostOverride)
22+
.use(ipFilter(ipFilterRules, options))
23+
.all("/", () => new Response("hello"))
24+
.handler();
25+
}
26+
27+
async function createTest(
28+
addr: string,
29+
ipFilterRules: IpFilterRules,
30+
options?: IpFilterOptions,
31+
): Promise<number> {
32+
const handler = testHandler(addr, ipFilterRules, options);
33+
34+
const res = await handler(new Request("https://localhost/"));
35+
36+
return res.status;
37+
}
38+
39+
Deno.test("ipFilter - no option", async () => {
40+
expect(await createTest("192.168.1.10", {})).toBe(200);
41+
});
42+
Deno.test("ipFilter - deny only", async () => {
43+
const ipFilterRules = {
44+
denyList: ["192.168.1.10", "2001:db8::1"],
45+
};
46+
47+
expect(await createTest("192.168.1.10", ipFilterRules)).toBe(403);
48+
expect(await createTest("192.168.1.11", ipFilterRules)).toBe(200);
49+
expect(await createTest("2001:db8::1", ipFilterRules)).toBe(403);
50+
expect(await createTest("2001:db8::2", ipFilterRules)).toBe(200);
51+
});
52+
53+
Deno.test("ipFilter - allow only", async () => {
54+
const ipFilterRules = {
55+
allowList: ["192.168.1.10", "2001:db8::1"],
56+
};
57+
expect(await createTest("192.168.1.10", ipFilterRules)).toBe(200);
58+
expect(await createTest("192.168.1.11", ipFilterRules)).toBe(403);
59+
expect(await createTest("2001:db8::1", ipFilterRules)).toBe(200);
60+
expect(await createTest("2001:db8::2", ipFilterRules)).toBe(403);
61+
});
62+
63+
// When both allow and deny are set, deny takes precedence
64+
Deno.test("ipFilter - allow and deny", async () => {
65+
const ipFilterRules = {
66+
denyList: ["192.168.1.10", "2001:db8::1"],
67+
allowList: ["192.168.1.10", "2001:db8::1"],
68+
};
69+
expect(await createTest("192.168.1.10", ipFilterRules)).toBe(403);
70+
expect(await createTest("2001:db8::1", ipFilterRules)).toBe(403);
71+
});
72+
73+
Deno.test("ipFilter - subnet mask", async () => {
74+
const ipFilterRules = {
75+
denyList: ["192.168.1.0/24"],
76+
};
77+
expect(await createTest("192.168.1.10", ipFilterRules)).toBe(403);
78+
});
79+
80+
Deno.test("ipFilter - custom onBlocked", async () => {
81+
const ipFilterRules = {
82+
denyList: ["192.168.1.0/24"],
83+
};
84+
85+
const options: IpFilterOptions = {
86+
onBlocked: () => {
87+
return new Response("custom blocked", { status: 401 });
88+
},
89+
};
90+
91+
expect(await createTest("192.168.1.10", ipFilterRules, options))
92+
.toBe(401);
93+
});

packages/fresh/src/mod.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export type { Middleware, MiddlewareFn } from "./middlewares/mod.ts";
1313
export { staticFiles } from "./middlewares/static_files.ts";
1414
export { csrf, type CsrfOptions } from "./middlewares/csrf.ts";
1515
export { cors, type CORSOptions } from "./middlewares/cors.ts";
16+
export {
17+
ipFilter,
18+
type IpFilterOptions,
19+
type IpFilterRules,
20+
} from "./middlewares/ip_filter.ts";
1621
export { csp, type CSPOptions } from "./middlewares/csp.ts";
1722
export type { FreshConfig, ResolvedFreshConfig } from "./config.ts";
1823
export type { Context, FreshContext, Island } from "./context.ts";

0 commit comments

Comments
 (0)