From 4ef25a65a78a9ef8ec34fd4213d1ecea38e90f4a Mon Sep 17 00:00:00 2001 From: nandi95 <41805560+nandi95@users.noreply.github.com> Date: Mon, 20 Nov 2023 02:51:56 +0000 Subject: [PATCH] feat: add `getRequestFingerprint` util (#564) Co-authored-by: Pooya Parsa --- src/utils/fingerprint.ts | 70 ++++++++++++++++++++++++++++ src/utils/index.ts | 1 + test/utils.test.ts | 98 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/utils/fingerprint.ts diff --git a/src/utils/fingerprint.ts b/src/utils/fingerprint.ts new file mode 100644 index 00000000..7cbd11df --- /dev/null +++ b/src/utils/fingerprint.ts @@ -0,0 +1,70 @@ +import crypto from "uncrypto"; +import type { H3Event } from "../event"; +import { getRequestIP, getRequestHeader } from "./request"; + +export interface RequestFingerprintOptions { + /** @default SHA-1 */ + hash?: false | "SHA-1"; + + /** @default `true` */ + ip?: boolean; + + /** @default `false` */ + xForwardedFor?: boolean; + + /** @default `false` */ + method?: boolean; + + /** @default `false` */ + path?: boolean; + + /** @default `false` */ + userAgent?: boolean; +} + +/** @experimental Behavior of this utility might change in the future versions */ +export async function getRequestFingerprint( + event: H3Event, + opts: RequestFingerprintOptions = {}, +): Promise { + const fingerprint: unknown[] = []; + + if (opts.ip !== false) { + fingerprint.push( + getRequestIP(event, { xForwardedFor: opts.xForwardedFor }), + ); + } + + if (opts.method === true) { + fingerprint.push(event.method); + } + + if (opts.path === true) { + fingerprint.push(event.path); + } + + if (opts.userAgent === true) { + fingerprint.push(getRequestHeader(event, "user-agent")); + } + + const fingerprintString = fingerprint.filter(Boolean).join("|"); + + if (!fingerprintString) { + return null; + } + + if (opts.hash === false) { + return fingerprintString; + } + + const buffer = await crypto.subtle.digest( + opts.hash || "SHA-1", + new TextEncoder().encode(fingerprintString), + ); + + const hash = [...new Uint8Array(buffer)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return hash; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 4404d426..f24486b1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,6 +4,7 @@ export * from "./cache"; export * from "./consts"; export * from "./cors"; export * from "./cookie"; +export * from "./fingerprint"; export * from "./proxy"; export * from "./request"; export * from "./response"; diff --git a/test/utils.test.ts b/test/utils.test.ts index bee47495..15289069 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -12,6 +12,7 @@ import { getRequestURL, readFormData, getRequestIP, + getRequestFingerprint, } from "../src"; describe("", () => { @@ -187,6 +188,103 @@ describe("", () => { }); }); + describe("getRequestFingerprint", () => { + it("returns an hash", async () => { + app.use(eventHandler((event) => getRequestFingerprint(event))); + + const req = request.get("/"); + + // sha1 is 40 chars long + expect((await req).text).toHaveLength(40); + + // and only uses hex chars + expect((await req).text).toMatch(/^[\dA-Fa-f]+$/); + }); + + it("returns the same hash every time for same request", async () => { + app.use( + eventHandler((event) => getRequestFingerprint(event, { hash: false })), + ); + + const req = request.get("/"); + expect((await req).text).toMatchInlineSnapshot('"::ffff:127.0.0.1"'); + expect((await req).text).toMatchInlineSnapshot('"::ffff:127.0.0.1"'); + }); + + it("returns null when all detections impossible", async () => { + app.use( + eventHandler((event) => + getRequestFingerprint(event, { hash: false, ip: false }), + ), + ); + const f1 = (await request.get("/")).text; + expect(f1).toBe(""); + }); + + it("can use path/method", async () => { + app.use( + eventHandler((event) => + getRequestFingerprint(event, { + hash: false, + ip: false, + path: true, + method: true, + }), + ), + ); + + const req = request.post("/foo"); + + expect((await req).text).toMatchInlineSnapshot('"POST|/foo"'); + }); + + it("uses user agent when available", async () => { + app.use( + eventHandler((event) => + getRequestFingerprint(event, { hash: false, userAgent: true }), + ), + ); + + const req = request.get("/"); + req.set("user-agent", "test-user-agent"); + + expect((await req).text).toMatchInlineSnapshot( + '"::ffff:127.0.0.1|test-user-agent"', + ); + }); + + it("uses x-forwarded-for ip when header set", async () => { + app.use( + eventHandler((event) => + getRequestFingerprint(event, { hash: false, xForwardedFor: true }), + ), + ); + + const req = request.get("/"); + req.set("x-forwarded-for", "x-forwarded-for"); + + expect((await req).text).toMatchInlineSnapshot('"x-forwarded-for"'); + }); + + it("uses the request ip when no x-forwarded-for header set", async () => { + app.use( + eventHandler((event) => getRequestFingerprint(event, { hash: false })), + ); + + app.options.onRequest = (e) => { + Object.defineProperty(e.node.req.socket, "remoteAddress", { + get(): any { + return "0.0.0.0"; + }, + }); + }; + + const req = request.get("/"); + + expect((await req).text).toMatchInlineSnapshot('"0.0.0.0"'); + }); + }); + describe("assertMethod", () => { it("only allow head and post", async () => { app.use(