-
Notifications
You must be signed in to change notification settings - Fork 742
Feature/add ip restriction middleware #3035
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
571b272
194b59e
8362db1
167feba
7ae6fc3
0eb73c3
72ae3bd
5987fc8
2e93114
2deeb7c
25c2cdb
4e5ae78
caae853
9db23e6
c93fd79
8e79b17
bc7f51e
53612b9
2645569
abd6665
f14e7dc
1468cde
e37ad41
25d5d2d
dfe28d4
a7ab3de
df3b5da
c22c4bf
7236b07
3f3d37d
007e150
4b15afe
4c2f7a6
9ef84c7
5383f74
75805b8
f50aa69
5e2be56
ab623c8
34407e8
ef5ff80
4328d63
4e16d46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import type { Context } from "../context.ts"; | ||
| import type { Middleware } from "./mod.ts"; | ||
| import { isIPv4, matchSubnets } from "@std/net/unstable-ip"; | ||
|
|
||
| /** | ||
| * Configuration rules for IP restriction middleware. | ||
| */ | ||
| export interface IpFilterRules { | ||
| /** | ||
| * List of IP addresses or CIDR blocks to deny access. | ||
| * If an IP matches any entry in this list, access will be blocked. | ||
| * | ||
| * @example ["192.168.1.10", "10.0.0.0/8", "2001:db8::1"] | ||
| */ | ||
| denyList?: string[]; | ||
|
|
||
| /** | ||
| * List of IP addresses or CIDR blocks to allow access. | ||
| * If specified, only IPs matching entries in this list will be allowed. | ||
| * If empty or undefined, all IPs are allowed (unless in denyList). | ||
| * | ||
| * @example ["192.168.1.0/24", "203.0.113.0/24", "2001:db8::/32"] | ||
| */ | ||
| allowList?: string[]; | ||
| } | ||
|
|
||
| export interface ipFilterOptions { | ||
| /** | ||
| * Called when a request is blocked by the IP filter. | ||
| * | ||
| * The function receives the remote information and the current request | ||
| * context and should return a Response (or a Promise resolving to one) | ||
| * which will be sent back to the client. If not provided, a default | ||
| * 403 Forbidden response will be used. | ||
| * | ||
| * Parameters: | ||
| * - `remote.addr` - the remote IP address as a string. | ||
| * - `remote.type` - the network family: "IPv4", "IPv6", or `undefined`. | ||
| * - `ctx` - the request `Context` which can be used to inspect the | ||
| * request or produce a custom response. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const options: ipFilterOptions = { | ||
| * onError: (remote, ctx) => { | ||
| * console.log(`Blocked ${remote.addr} (${remote.type})`, ctx.url); | ||
| * return new Response("Access denied", { status: 401 }); | ||
| * }, | ||
| * }; | ||
| * ``` | ||
| */ | ||
| onError?: <State>( | ||
| remote: { | ||
| addr: string; | ||
| type: Deno.NetworkInterfaceInfo["family"] | undefined; | ||
| }, | ||
| ctx: Context<State>, | ||
| ) => Response | Promise<Response>; | ||
| } | ||
|
|
||
| /** | ||
| * IP restriction Middleware for Fresh. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should explain this filter. I.e. the precedence of rules. So deny rules take precedence over allow rules and traffic not matching any rule is denied by default (implicit deny).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mentioned this matter. Thankyou |
||
| * | ||
| * @param rules Deny and allow rules object. | ||
| * @param options Options for the IP Restriction middleware. | ||
| * @returns The middleware handler function. | ||
| * | ||
| * Filters rule priority is denyList, then allowList. | ||
| * If an IP is in the denyList, it will be blocked regardless of the allowList. | ||
| * | ||
| * @example Basic usage (with defaults) | ||
| * ```ts | ||
| * const app = new App<State>() | ||
| * | ||
| * app.use(ipFilter({ | ||
| * denyList: ["192.168.1.10", "2001:db8::1"] | ||
| * })); | ||
| * ``` | ||
| * | ||
| * @example Custom error handling | ||
| * ```ts | ||
| * const customOnError: ipFilterOptions = { | ||
| * onError: (remote, ctx) => { | ||
| * console.log( | ||
| * `Request URL: ${ctx.url}, Blocked IP: ${remote.addr} of type ${remote.type}`, | ||
| * ); | ||
| * | ||
| * return new Response("custom onError", { status: 401 }); | ||
| * }, | ||
| * }; | ||
| * app.use(ipFilter({ | ||
| * denyList: ["192.168.1.10", "2001:db8::1"] | ||
| * }, customOnError)); | ||
| * ``` | ||
| */ | ||
| export function ipFilter<State>( | ||
| rules: IpFilterRules, | ||
| options?: ipFilterOptions, | ||
| ): Middleware<State> { | ||
| const onBlock = options?.onError ?? | ||
| (() => new Response("Forbidden", { status: 403 })); | ||
| return function ipFilter<State>(ctx: Context<State>) { | ||
| if ( | ||
| ctx.info.remoteAddr.transport !== "udp" && | ||
| ctx.info.remoteAddr.transport !== "tcp" | ||
| ) { | ||
| return ctx.next(); | ||
| } | ||
|
Comment on lines
+103
to
+108
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A good question to ask is "does Fresh allow other traffic besides UDP and TCP?" If the answer is no, this section can be removed, as the conditional will never evaluate to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 'transport' can be one of five options: "tcp", "udp", "unix", "unixpacket", or "vsock". Since 'hostname' is only available for "tcp" and "udp", I believe this handling process is necessary.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, to correct my stance, yes, we should return early if not UDP or TCP traffic, because an IP address isn't being used. But I don't think throwing is the correct behaviour. Instead, we should just pass through. See my suggestion above.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ve set it up so that traffic passes through if it's neither UDP nor TCP. |
||
|
|
||
| const addr = ctx.info.remoteAddr.hostname; | ||
| const type = isIPv4(addr) ? "IPv4" : "IPv6"; | ||
|
|
||
| if (matchSubnets(addr, rules.denyList || [])) { | ||
| return onBlock({ addr, type }, ctx); | ||
| } | ||
|
|
||
| if ( | ||
| (rules.allowList || []).length === 0 || | ||
| matchSubnets(addr, rules.allowList || []) | ||
| ) { | ||
| return ctx.next(); | ||
| } | ||
|
|
||
| return onBlock({ addr, type }, ctx); | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { App } from "../app.ts"; | ||
| import type { Context } from "../context.ts"; | ||
| import { | ||
| ipFilter, | ||
| type ipFilterOptions, | ||
| type IpFilterRules, | ||
| } from "./ip_filter.ts"; | ||
| import { expect } from "@std/expect"; | ||
|
|
||
| function testHandler<T>( | ||
| remortAddr: string, | ||
| ipFilterRules: IpFilterRules, | ||
| options?: ipFilterOptions, | ||
| ): (request: Request) => Promise<Response> { | ||
| function remoteHostOverRide(ctx: Context<T>) { | ||
| (ctx.info.remoteAddr as { hostname: string }).hostname = remortAddr; | ||
| return ctx.next(); | ||
| } | ||
|
|
||
| if (!options) { | ||
| return new App<T>() | ||
| .use(remoteHostOverRide) | ||
| .use(ipFilter(ipFilterRules)) | ||
| .all("/", () => new Response("hello")) | ||
| .handler(); | ||
| } | ||
|
|
||
| return new App<T>() | ||
| .use(remoteHostOverRide) | ||
| .use(ipFilter(ipFilterRules, options)) | ||
| .all("/", () => new Response("hello")) | ||
| .handler(); | ||
| } | ||
|
|
||
| async function createTest( | ||
| addr: string, | ||
| ipFilterRules: IpFilterRules, | ||
| options?: ipFilterOptions, | ||
| ): Promise<number> { | ||
| const handler = testHandler(addr, ipFilterRules, options); | ||
|
|
||
| const res = await handler(new Request("https://localhost/")); | ||
|
|
||
| return res.status; | ||
| } | ||
|
|
||
| Deno.test("ipFilter - no option", async () => { | ||
| expect(await createTest("192.168.1.10", {})).toBe(200); | ||
| }); | ||
| Deno.test("ipFilter - set ipFilterRules deny only", async () => { | ||
| const ipFilterRules = { | ||
| denyList: ["192.168.1.10", "2001:db8::1"], | ||
| }; | ||
|
|
||
| expect(await createTest("192.168.1.10", ipFilterRules)).toBe(403); | ||
| expect(await createTest("192.168.1.11", ipFilterRules)).toBe(200); | ||
| expect(await createTest("2001:db8::1", ipFilterRules)).toBe(403); | ||
| expect(await createTest("2001:db8::2", ipFilterRules)).toBe(200); | ||
| }); | ||
|
|
||
| Deno.test("ipFilter - set ipFilterRules arrow only", async () => { | ||
| const ipFilterRules = { | ||
| allowList: ["192.168.1.10", "2001:db8::1"], | ||
| }; | ||
| expect(await createTest("192.168.1.10", ipFilterRules)).toBe(200); | ||
| expect(await createTest("192.168.1.11", ipFilterRules)).toBe(403); | ||
| expect(await createTest("2001:db8::1", ipFilterRules)).toBe(200); | ||
| expect(await createTest("2001:db8::2", ipFilterRules)).toBe(403); | ||
| }); | ||
|
|
||
| // arrow and deny | ||
| // deny の方が優先されるを英語で | ||
| // When both allow and deny are set, deny takes precedence | ||
| Deno.test("ipFilter - set ipFilterRules arrow and deny", async () => { | ||
| const ipFilterRules = { | ||
| denyList: ["192.168.1.10", "2001:db8::1"], | ||
| allowList: ["192.168.1.10", "2001:db8::1"], | ||
| }; | ||
| expect(await createTest("192.168.1.10", ipFilterRules)).toBe(403); | ||
| expect(await createTest("2001:db8::1", ipFilterRules)).toBe(403); | ||
| }); | ||
|
|
||
| // adapt subnet mask | ||
| Deno.test("ipFilter - adapt subnet mask", async () => { | ||
| const ipFilterRules = { | ||
| denyList: ["192.168.1.0/24"], | ||
| }; | ||
| expect(await createTest("192.168.1.10", ipFilterRules)).toBe(403); | ||
| }); | ||
|
|
||
| // onError | ||
| Deno.test("ipFilter - custom onError", async () => { | ||
| const ipFilterRules = { | ||
| denyList: ["192.168.1.0/24"], | ||
| }; | ||
|
|
||
| const customOnError: ipFilterOptions = { | ||
| onError: () => { | ||
| return new Response("custom onError", { status: 401 }); | ||
| }, | ||
| }; | ||
|
|
||
| expect(await createTest("192.168.1.10", ipFilterRules, customOnError)) | ||
| .toBe(401); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This interface and its properties need documentation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have addressed this matter.