From 2a8690143a32c88dbf38b6e5497d8e3b9db7c003 Mon Sep 17 00:00:00 2001 From: Blaine Bublitz Date: Fri, 7 Jun 2024 10:32:54 -0400 Subject: [PATCH] feat(ip)!: Allow platform to be specified as an option when looking up IP --- arcjet-bun/index.ts | 7 ++ arcjet-next/index.ts | 11 +++- arcjet-node/index.ts | 11 +++- arcjet-sveltekit/env.d.ts | 1 + arcjet-sveltekit/index.ts | 7 ++ ip/index.ts | 19 ++++-- ip/test/ipv4.test.ts | 134 ++++++++++++++++---------------------- ip/test/ipv6.test.ts | 116 +++++++++++++-------------------- 8 files changed, 151 insertions(+), 155 deletions(-) diff --git a/arcjet-bun/index.ts b/arcjet-bun/index.ts index cb29574bd..de6681a3d 100644 --- a/arcjet-bun/index.ts +++ b/arcjet-bun/index.ts @@ -131,6 +131,12 @@ export interface ArcjetBun { ) => Response | Promise; } +function detectPlatform() { + if (typeof env["FLY_APP_NAME"] === "string" && env["FLY_APP_NAME"] !== "") { + return "fly-io" as const; + } +} + // This is provided with an `ipCache` where it attempts to lookup the IP. This // is primarily a workaround to the API design in Bun that requires access to // the `Server` to lookup an IP. @@ -150,6 +156,7 @@ function toArcjetRequest( ip: ipCache.get(request), }, headers, + { platform: detectPlatform() }, ); if (ip === "") { // If the `ip` is empty but we're in development mode, we default the IP diff --git a/arcjet-next/index.ts b/arcjet-next/index.ts index 3d650354d..8422bc55c 100644 --- a/arcjet-next/index.ts +++ b/arcjet-next/index.ts @@ -193,6 +193,15 @@ export interface ArcjetNext { ): ArcjetNext>>; } +function detectPlatform() { + if ( + typeof process.env["FLY_APP_NAME"] === "string" && + process.env["FLY_APP_NAME"] !== "" + ) { + return "fly-io" as const; + } +} + function toArcjetRequest( request: ArcjetNextRequest, props: Props, @@ -200,7 +209,7 @@ function toArcjetRequest( // We construct an ArcjetHeaders to normalize over Headers const headers = new ArcjetHeaders(request.headers); - let ip = findIP(request, headers); + let ip = findIP(request, headers, { platform: detectPlatform() }); if (ip === "") { // If the `ip` is empty but we're in development mode, we default the IP // so the request doesn't fail. diff --git a/arcjet-node/index.ts b/arcjet-node/index.ts index 42dab7ce2..69b9b45c8 100644 --- a/arcjet-node/index.ts +++ b/arcjet-node/index.ts @@ -133,6 +133,15 @@ export interface ArcjetNode { ): ArcjetNode>>; } +function detectPlatform() { + if ( + typeof process.env["FLY_APP_NAME"] === "string" && + process.env["FLY_APP_NAME"] !== "" + ) { + return "fly-io" as const; + } +} + function toArcjetRequest( request: ArcjetNodeRequest, props: Props, @@ -143,7 +152,7 @@ function toArcjetRequest( // We construct an ArcjetHeaders to normalize over Headers const headers = new ArcjetHeaders(request.headers); - let ip = findIP(request, headers); + let ip = findIP(request, headers, { platform: detectPlatform() }); if (ip === "") { // If the `ip` is empty but we're in development mode, we default the IP // so the request doesn't fail. diff --git a/arcjet-sveltekit/env.d.ts b/arcjet-sveltekit/env.d.ts index c44e84e1d..3dc94b166 100644 --- a/arcjet-sveltekit/env.d.ts +++ b/arcjet-sveltekit/env.d.ts @@ -2,5 +2,6 @@ declare module "$env/dynamic/private" { export const env: { NODE_ENV?: string; ARCJET_ENV?: string; + FLY_APP_NAME?: string; }; } diff --git a/arcjet-sveltekit/index.ts b/arcjet-sveltekit/index.ts index 1b6456c07..bf3a77a48 100644 --- a/arcjet-sveltekit/index.ts +++ b/arcjet-sveltekit/index.ts @@ -157,6 +157,12 @@ export interface ArcjetSvelteKit { ): ArcjetSvelteKit>>; } +function detectPlatform() { + if (typeof env["FLY_APP_NAME"] === "string" && env["FLY_APP_NAME"] !== "") { + return "fly-io" as const; + } +} + function toArcjetRequest( event: ArcjetSvelteKitRequestEvent, props: Props, @@ -171,6 +177,7 @@ function toArcjetRequest( ip: event.getClientAddress(), }, headers, + { platform: detectPlatform() }, ); if (ip === "") { // If the `ip` is empty but we're in development mode, we default the IP diff --git a/ip/index.ts b/ip/index.ts index f5e554880..a7b03910e 100644 --- a/ip/index.ts +++ b/ip/index.ts @@ -553,6 +553,12 @@ export interface RequestLike { requestContext?: PartialRequestContext; } +export type Platform = "cloudflare" | "fly-io"; + +export interface Options { + platform?: Platform; +} + // Heavily based on https://github.com/pbojinov/request-ip // // Licensed: The MIT License (MIT) Copyright (c) 2022 Petar Bojinov - @@ -574,7 +580,11 @@ export interface RequestLike { // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -function findIP(request: RequestLike, headers: Headers): string { +function findIP( + request: RequestLike, + headers: Headers, + options: Options = {}, +): string { // Prefer anything available via the platform over headers since headers can // be set by users. Only if we don't have an IP available in `request` do we // search the `headers`. @@ -604,8 +614,9 @@ function findIP(request: RequestLike, headers: Headers): string { // header should only be accepted when running on Cloudflare; otherwise, it // can be spoofed. - // Cloudflare: https://developers.cloudflare.com/workers/configuration/compatibility-dates/#global-navigator - if (globalThis.navigator?.userAgent === "Cloudflare-Workers") { + const { platform } = options; + + if (platform === "cloudflare") { // CF-Connecting-IPv6: https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#cf-connecting-ipv6 const cfConnectingIPv6 = headers.get("cf-connecting-ipv6"); if (isGlobalIPv6(cfConnectingIPv6)) { @@ -620,7 +631,7 @@ function findIP(request: RequestLike, headers: Headers): string { } // Fly.io: https://fly.io/docs/machines/runtime-environment/#fly_app_name - if (process.env["FLY_APP_NAME"] !== "") { + if (platform === "fly-io") { // Fly-Client-IP: https://fly.io/docs/networking/request-headers/#fly-client-ip const flyClientIP = headers.get("fly-client-ip"); if (isGlobalIP(flyClientIP)) { diff --git a/ip/test/ipv4.test.ts b/ip/test/ipv4.test.ts index 1c042c7a6..cf901db46 100644 --- a/ip/test/ipv4.test.ts +++ b/ip/test/ipv4.test.ts @@ -1,150 +1,125 @@ /** * @jest-environment node */ -import { - describe, - expect, - test, - beforeEach, - afterEach, - jest, -} from "@jest/globals"; -import ip, { RequestLike } from "../index"; - -type MakeTest = (ip: unknown) => [RequestLike, Headers]; - -beforeEach(() => { - jest.replaceProperty(process, "env", { - ...process.env, - FLY_APP_NAME: "testing", - }); - // We inject an empty `navigator` object via jest.config.js to act like - // Cloudflare Workers - jest.replaceProperty(globalThis, "navigator", { - ...globalThis.navigator, - userAgent: "Cloudflare-Workers", - }); -}); +import { describe, expect, test } from "@jest/globals"; +import ip, { Options, RequestLike } from "../index"; -afterEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); -}); +type MakeTest = (ip: unknown) => [RequestLike, Headers, Options | undefined]; function suite(make: MakeTest) { test("returns empty string if unspecified", () => { - const [request, headers] = make("0.0.0.0"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("0.0.0.0"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if 'this network' address", () => { - const [request, headers] = make("0.1.2.3"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("0.1.2.3"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the shared address range", () => { - const [request, headers] = make("100.127.255.255"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("100.127.255.255"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the link local address range", () => { - const [request, headers] = make("169.254.255.255"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("169.254.255.255"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the future protocol range", () => { - const [request, headers] = make("192.0.0.1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("192.0.0.1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the 192.0.2.x documentation range", () => { - const [request, headers] = make("192.0.2.1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("192.0.2.1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the 198.51.100.x documentation range", () => { - const [request, headers] = make("198.51.100.1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("198.51.100.1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the 203.0.113.x documentation range", () => { - const [request, headers] = make("203.0.113.1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("203.0.113.1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the benchmarking range", () => { - const [request, headers] = make("198.19.255.255"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("198.19.255.255"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the reserved range", () => { - const [request, headers] = make("240.0.0.0"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("240.0.0.0"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the broadcast address", () => { - const [request, headers] = make("255.255.255.255"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("255.255.255.255"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if loopback", () => { - const [request, headers] = make("127.0.0.1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("127.0.0.1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if not full ip", () => { - const [request, headers] = make("12.3.4"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("12.3.4"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if more than 3 digits in an octet", () => { - const [request, headers] = make("1111.2.3.4"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("1111.2.3.4"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if more than full ip", () => { - const [request, headers] = make("1.2.3.4.5"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("1.2.3.4.5"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if any octet has leading 0", () => { - const [request, headers] = make("1.02.3.4"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("1.02.3.4"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if not a string", () => { - const [request, headers] = make(["12", "3", "4"]); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make(["12", "3", "4"]); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the 10.x.x.x private range", () => { - const [request, headers] = make("10.1.1.1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("10.1.1.1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the 172.16.x.x-172.31.x.x private range", () => { - const [request, headers] = make("172.18.1.1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("172.18.1.1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if in the 192.168.x.x private range", () => { - const [request, headers] = make("192.168.1.1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("192.168.1.1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string outside of the valid range", () => { - const [request, headers] = make("1.1.1.256"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("1.1.1.256"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns the ip if valid", () => { - const [request, headers] = make("1.1.1.1"); - expect(ip(request, headers)).toEqual("1.1.1.1"); + const [request, headers, options] = make("1.1.1.1"); + expect(ip(request, headers, options)).toEqual("1.1.1.1"); }); test("returns the full ip if valid, after ignoring port", () => { - const [request, headers] = make("1.1.1.1:443"); - expect(ip(request, headers)).toEqual("1.1.1.1:443"); + const [request, headers, options] = make("1.1.1.1:443"); + expect(ip(request, headers, options)).toEqual("1.1.1.1:443"); }); } @@ -161,16 +136,16 @@ function requestSuite(...keys: string[]) { } const req = nested(keys); - return [req, new Headers()]; + return [req, new Headers(), undefined]; }); }); } -function headerSuite(key: string) { +function headerSuite(key: string, options?: Options) { describe(`header: ${key}`, () => { suite((ip: unknown) => { if (typeof ip === "string") { - return [{}, new Headers([[key, ip]])]; + return [{}, new Headers([[key, ip]]), options]; } else { return [ {}, @@ -181,6 +156,7 @@ function headerSuite(key: string) { ip, ], ]), + options, ]; } }); @@ -195,10 +171,10 @@ describe("find public IPv4", () => { headerSuite("X-Client-IP"); headerSuite("X-Forwarded-For"); - headerSuite("CF-Connecting-IP"); + headerSuite("CF-Connecting-IP", { platform: "cloudflare" }); headerSuite("DO-Connecting-IP"); headerSuite("Fastly-Client-IP"); - headerSuite("Fly-Client-IP"); + headerSuite("Fly-Client-IP", { platform: "fly-io" }); headerSuite("True-Client-IP"); headerSuite("X-Real-IP"); headerSuite("X-Cluster-Client-IP"); diff --git a/ip/test/ipv6.test.ts b/ip/test/ipv6.test.ts index 958f6e74d..1dab08947 100644 --- a/ip/test/ipv6.test.ts +++ b/ip/test/ipv6.test.ts @@ -1,127 +1,102 @@ /** * @jest-environment node */ -import { - describe, - expect, - test, - beforeEach, - afterEach, - jest, -} from "@jest/globals"; -import ip, { RequestLike } from "../index"; - -type MakeTest = (ip: unknown) => [RequestLike, Headers]; - -beforeEach(() => { - jest.replaceProperty(process, "env", { - ...process.env, - FLY_APP_NAME: "testing", - }); - // We inject an empty `navigator` object via jest.config.js to act like - // Cloudflare Workers - jest.replaceProperty(globalThis, "navigator", { - ...globalThis.navigator, - userAgent: "Cloudflare-Workers", - }); -}); +import { describe, expect, test } from "@jest/globals"; +import ip, { Options, RequestLike } from "../index"; -afterEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); -}); +type MakeTest = (ip: unknown) => [RequestLike, Headers, Options | undefined]; function suite(make: MakeTest) { test("returns empty string if unspecified", () => { - const [request, headers] = make("::"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("::"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if loopback address", () => { - const [request, headers] = make("::1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("::1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if ipv4 mapped address", () => { - const [request, headers] = make("::ffff:127.0.0.1"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("::ffff:127.0.0.1"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if ipv4-ipv6 translat range", () => { - const [request, headers] = make("64:ff9b:1::"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("64:ff9b:1::"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if discard range", () => { - const [request, headers] = make("100::"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("100::"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if documentation range", () => { - const [request, headers] = make("2001:db8::"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("2001:db8::"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if benchmarking range", () => { - const [request, headers] = make("2001:2::"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("2001:2::"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if unique local range", () => { - const [request, headers] = make("fc02::"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("fc02::"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if unicast link local range", () => { - const [request, headers] = make("fe80::"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("fe80::"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if the ip address is too short", () => { - const [request, headers] = make("ffff:ffff:"); - expect(ip(request, headers)).toEqual(""); + const [request, headers, options] = make("ffff:ffff:"); + expect(ip(request, headers, options)).toEqual(""); }); test("returns empty string if the ip address is too long", () => { - const [request, headers] = make( + const [request, headers, options] = make( "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", ); - expect(ip(request, headers)).toEqual(""); + expect(ip(request, headers, options)).toEqual(""); }); test("returns the ip if it is 'Port Control Protocol Anycast' address", () => { - const [request, headers] = make("2001:1::1"); - expect(ip(request, headers)).toEqual("2001:1::1"); + const [request, headers, options] = make("2001:1::1"); + expect(ip(request, headers, options)).toEqual("2001:1::1"); }); test("returns the ip if it is 'Traversal Using Relays around NAT Anycast' address", () => { - const [request, headers] = make("2001:1::2"); - expect(ip(request, headers)).toEqual("2001:1::2"); + const [request, headers, options] = make("2001:1::2"); + expect(ip(request, headers, options)).toEqual("2001:1::2"); }); test("returns the ip if it is 'AMT' address", () => { - const [request, headers] = make("2001:3::"); - expect(ip(request, headers)).toEqual("2001:3::"); + const [request, headers, options] = make("2001:3::"); + expect(ip(request, headers, options)).toEqual("2001:3::"); }); test("returns the ip if it is 'AS112-v6' address", () => { - const [request, headers] = make("2001:4:112::"); - expect(ip(request, headers)).toEqual("2001:4:112::"); + const [request, headers, options] = make("2001:4:112::"); + expect(ip(request, headers, options)).toEqual("2001:4:112::"); }); test("returns the ip if it is 'ORCHIDv2' address", () => { - const [request, headers] = make("2001:20::"); - expect(ip(request, headers)).toEqual("2001:20::"); + const [request, headers, options] = make("2001:20::"); + expect(ip(request, headers, options)).toEqual("2001:20::"); }); test("returns the ip if valid", () => { - const [request, headers] = make("::abcd:c00a:2ff"); - expect(ip(request, headers)).toEqual("::abcd:c00a:2ff"); + const [request, headers, options] = make("::abcd:c00a:2ff"); + expect(ip(request, headers, options)).toEqual("::abcd:c00a:2ff"); }); test("returns the ip if valid, after ignoring scope", () => { - const [request, headers] = make("::abcd:c00a:2ff%1"); - expect(ip(request, headers)).toEqual("::abcd:c00a:2ff%1"); + const [request, headers, options] = make("::abcd:c00a:2ff%1"); + expect(ip(request, headers, options)).toEqual("::abcd:c00a:2ff%1"); }); } @@ -138,16 +113,16 @@ function requestSuite(...keys: string[]) { } const req = nested(keys); - return [req, new Headers()]; + return [req, new Headers(), undefined]; }); }); } -function headerSuite(key: string) { +function headerSuite(key: string, options?: Options) { describe(`header: ${key}`, () => { suite((ip: unknown) => { if (typeof ip === "string") { - return [{}, new Headers([[key, ip]])]; + return [{}, new Headers([[key, ip]]), options]; } else { return [ {}, @@ -158,6 +133,7 @@ function headerSuite(key: string) { ip, ], ]), + options, ]; } }); @@ -172,11 +148,11 @@ describe("find public IPv6", () => { headerSuite("X-Client-IP"); headerSuite("X-Forwarded-For"); - headerSuite("CF-Connecting-IPv6"); - headerSuite("CF-Connecting-IP"); + headerSuite("CF-Connecting-IPv6", { platform: "cloudflare" }); + headerSuite("CF-Connecting-IP", { platform: "cloudflare" }); headerSuite("DO-Connecting-IP"); headerSuite("Fastly-Client-IP"); - headerSuite("Fly-Client-IP"); + headerSuite("Fly-Client-IP", { platform: "fly-io" }); headerSuite("True-Client-IP"); headerSuite("X-Real-IP"); headerSuite("X-Cluster-Client-IP");