From c9f54bbe0561b13dbb2dbc6f58087a1b25218504 Mon Sep 17 00:00:00 2001 From: blaine-arcjet <146491715+blaine-arcjet@users.noreply.github.com> Date: Mon, 10 Jun 2024 06:27:35 -0700 Subject: [PATCH] feat(ip)!: Allow platform to be specified when looking up IP (#896) Depends upon #895 Towards #51 Towards #885 This removes the platform detection inside the `@arcjet/ip` package and instead allows the platform to be selected by the caller and passed in via an options argument. This allows us to discover the platform from different types of environments based on the adapter --- 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");