diff --git a/deno.json b/deno.json index dbd5e8a962e..7202cd7b0a3 100644 --- a/deno.json +++ b/deno.json @@ -47,6 +47,7 @@ "@std/collections": "jsr:@std/collections@^1.1.2", "@std/dotenv": "jsr:@std/dotenv@^0.225.5", "@std/http": "jsr:@std/http@^1.0.15", + "@std/net": "jsr:@std/net@^1.0.6", "@std/uuid": "jsr:@std/uuid@^1.0.7", "@supabase/postgrest-js": "npm:@supabase/postgrest-js@^1.21.4", "@types/mime-db": "npm:@types/mime-db@^1.43.6", diff --git a/deno.lock b/deno.lock index f4cbb0f5428..a988802a49b 100644 --- a/deno.lock +++ b/deno.lock @@ -7,6 +7,8 @@ "jsr:@deno/cache-dir@0.22.2": "0.22.2", "jsr:@deno/doc@0.172": "0.172.0", "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", + "jsr:@deno/graph@0.86": "0.86.9", + "jsr:@deno/graph@~0.82.3": "0.82.3", "jsr:@deno/loader@~0.3.10": "0.3.10", "jsr:@fresh/build-id@1": "1.0.1", "jsr:@fresh/core@2": "2.2.0", @@ -16,6 +18,7 @@ "jsr:@std/assert@^1.0.15": "1.0.16", "jsr:@std/async@1": "1.0.16", "jsr:@std/async@^1.0.13": "1.0.16", + "jsr:@std/async@^1.0.15": "1.0.16", "jsr:@std/bytes@^1.0.5": "1.0.6", "jsr:@std/bytes@^1.0.6": "1.0.6", "jsr:@std/cli@^1.0.19": "1.0.25", @@ -45,6 +48,7 @@ "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/io@0.225": "0.225.2", "jsr:@std/io@0.225.0": "0.225.0", + "jsr:@std/json@^1.0.2": "1.0.2", "jsr:@std/jsonc@1": "1.0.2", "jsr:@std/jsonc@^1.0.2": "1.0.2", "jsr:@std/media-types@1": "1.1.0", @@ -90,6 +94,7 @@ "npm:esbuild-wasm@0.25.7": "0.25.7", "npm:esbuild-wasm@~0.25.11": "0.25.12", "npm:esbuild@0.25.7": "0.25.7", + "npm:esbuild@~0.25.5": "0.25.7", "npm:feed@^5.1.0": "5.1.0", "npm:github-slugger@2": "2.0.0", "npm:ioredis@^5.7.0": "5.8.2", @@ -143,6 +148,7 @@ "@deno/cache-dir@0.14.0": { "integrity": "729f0b68e7fc96443c09c2c544b830ca70897bdd5168598446d752f7a4c731ad", "dependencies": [ + "jsr:@deno/graph@0.86", "jsr:@std/fmt@^1.0.3", "jsr:@std/fs@^1.0.6", "jsr:@std/io@0.225", @@ -152,6 +158,7 @@ "@deno/cache-dir@0.22.2": { "integrity": "0c84b8db6175618cc2e25ed7d7648d83b38e298c14c1aae1e4b4e1b2219b840c", "dependencies": [ + "jsr:@deno/graph@0.86", "jsr:@std/fmt@^1.0.3", "jsr:@std/fs@^1.0.6", "jsr:@std/io@0.225", @@ -161,16 +168,24 @@ "@deno/doc@0.172.0": { "integrity": "72a68ed533576a06feb930a84784ad9ba6d83ca9d581fc734d498c58e32b7cf5", "dependencies": [ - "jsr:@deno/cache-dir@0.14" + "jsr:@deno/cache-dir@0.14", + "jsr:@deno/graph@~0.82.3" ] }, "@deno/esbuild-plugin@1.2.1": { "integrity": "df629467913adc1f960149fdfa3a3430ba8c20381c310fba096db244e6c3c9f6", "dependencies": [ "jsr:@deno/loader", - "jsr:@std/path@^1.1.1" + "jsr:@std/path@^1.1.1", + "npm:esbuild@~0.25.5" ] }, + "@deno/graph@0.82.3": { + "integrity": "5c1fe944368172a9c87588ac81b82eb027ca78002a57521567e6264be322637e" + }, + "@deno/graph@0.86.9": { + "integrity": "c4f353a695bcc5246c099602977dabc6534eacea9999a35a8cb24e807192e6a1" + }, "@deno/loader@0.3.10": { "integrity": "a9c0aa44a0499e7fecef52c29fbc206c1c8f8946388f25d9d0789a23313bfd43" }, @@ -196,8 +211,8 @@ "jsr:@std/uuid@^1.0.9", "npm:@opentelemetry/api", "npm:@preact/signals@^2.2.1", - "npm:esbuild", "npm:esbuild-wasm@~0.25.11", + "npm:esbuild@0.25.7", "npm:preact-render-to-string@^6.6.3", "npm:preact@^10.27.0", "npm:preact@^10.27.2" @@ -305,8 +320,14 @@ "jsr:@std/bytes@^1.0.5" ] }, + "@std/json@1.0.2": { + "integrity": "d9e5497801c15fb679f55a2c01c7794ad7a5dfda4dd1bebab5e409cb5e0d34d4" + }, "@std/jsonc@1.0.2": { - "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7" + "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7", + "dependencies": [ + "jsr:@std/json" + ] }, "@std/media-types@1.1.0": { "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" @@ -336,6 +357,7 @@ "integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092", "dependencies": [ "jsr:@std/assert@^1.0.15", + "jsr:@std/async@^1.0.15", "jsr:@std/data-structures", "jsr:@std/fs@^1.0.19", "jsr:@std/internal@^1.0.12", @@ -4016,6 +4038,7 @@ "jsr:@std/http@^1.0.15", "jsr:@std/jsonc@1", "jsr:@std/media-types@1", + "jsr:@std/net@^1.0.6", "jsr:@std/path@1", "jsr:@std/semver@1", "jsr:@std/streams@1", @@ -4069,6 +4092,7 @@ "jsr:@std/http@^1.0.21", "jsr:@std/jsonc@^1.0.2", "jsr:@std/media-types@^1.1.0", + "jsr:@std/net@^1.0.6", "jsr:@std/path@^1.1.2", "jsr:@std/semver@^1.0.6", "jsr:@std/uuid@^1.0.9", diff --git a/packages/fresh/deno.json b/packages/fresh/deno.json index d73c39c43a6..d019825ffd0 100644 --- a/packages/fresh/deno.json +++ b/packages/fresh/deno.json @@ -21,6 +21,7 @@ "@std/http": "jsr:@std/http@^1.0.21", "@std/jsonc": "jsr:@std/jsonc@^1.0.2", "@std/media-types": "jsr:@std/media-types@^1.1.0", + "@std/net": "jsr:@std/net@^1.0.6", "@std/path": "jsr:@std/path@^1.1.2", "@std/semver": "jsr:@std/semver@^1.0.6", "@std/uuid": "jsr:@std/uuid@^1.0.9", diff --git a/packages/fresh/src/middlewares/ip_filter.ts b/packages/fresh/src/middlewares/ip_filter.ts new file mode 100644 index 00000000000..501c2725359 --- /dev/null +++ b/packages/fresh/src/middlewares/ip_filter.ts @@ -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?: ( + remote: { + addr: string; + type: Deno.NetworkInterfaceInfo["family"] | undefined; + }, + ctx: Context, + ) => Response | Promise; +} + +/** + * IP restriction Middleware for Fresh. + * + * @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() + * + * 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( + rules: IpFilterRules, + options?: ipFilterOptions, +): Middleware { + const onBlock = options?.onError ?? + (() => new Response("Forbidden", { status: 403 })); + return function ipFilter(ctx: Context) { + if ( + ctx.info.remoteAddr.transport !== "udp" && + ctx.info.remoteAddr.transport !== "tcp" + ) { + return ctx.next(); + } + + 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); + }; +} diff --git a/packages/fresh/src/middlewares/ip_filter_test.ts b/packages/fresh/src/middlewares/ip_filter_test.ts new file mode 100644 index 00000000000..290a6a6ef0a --- /dev/null +++ b/packages/fresh/src/middlewares/ip_filter_test.ts @@ -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( + remortAddr: string, + ipFilterRules: IpFilterRules, + options?: ipFilterOptions, +): (request: Request) => Promise { + function remoteHostOverRide(ctx: Context) { + (ctx.info.remoteAddr as { hostname: string }).hostname = remortAddr; + return ctx.next(); + } + + if (!options) { + return new App() + .use(remoteHostOverRide) + .use(ipFilter(ipFilterRules)) + .all("/", () => new Response("hello")) + .handler(); + } + + return new App() + .use(remoteHostOverRide) + .use(ipFilter(ipFilterRules, options)) + .all("/", () => new Response("hello")) + .handler(); +} + +async function createTest( + addr: string, + ipFilterRules: IpFilterRules, + options?: ipFilterOptions, +): Promise { + 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); +}); diff --git a/packages/fresh/src/mod.ts b/packages/fresh/src/mod.ts index 283ed19ff92..e2e799f7315 100644 --- a/packages/fresh/src/mod.ts +++ b/packages/fresh/src/mod.ts @@ -13,6 +13,7 @@ export type { Middleware, MiddlewareFn } from "./middlewares/mod.ts"; export { staticFiles } from "./middlewares/static_files.ts"; export { csrf, type CsrfOptions } from "./middlewares/csrf.ts"; export { cors, type CORSOptions } from "./middlewares/cors.ts"; +export { ipFilter, type IpFilterRules } from "./middlewares/ip_filter.ts"; export { csp, type CSPOptions } from "./middlewares/csp.ts"; export type { FreshConfig, ResolvedFreshConfig } from "./config.ts"; export type { Context, FreshContext, Island } from "./context.ts";