Skip to content

Commit

Permalink
feat: add getRequestFingerprint util (#564)
Browse files Browse the repository at this point in the history
Co-authored-by: Pooya Parsa <pooya@pi0.io>
  • Loading branch information
nandi95 and pi0 authored Nov 20, 2023
1 parent c28efd2 commit 4ef25a6
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 0 deletions.
70 changes: 70 additions & 0 deletions src/utils/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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;
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
98 changes: 98 additions & 0 deletions test/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getRequestURL,
readFormData,
getRequestIP,
getRequestFingerprint,
} from "../src";

describe("", () => {
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit 4ef25a6

Please sign in to comment.