Skip to content

Commit 4ef25a6

Browse files
nandi95pi0
andauthored
feat: add getRequestFingerprint util (#564)
Co-authored-by: Pooya Parsa <[email protected]>
1 parent c28efd2 commit 4ef25a6

File tree

3 files changed

+169
-0
lines changed

3 files changed

+169
-0
lines changed

src/utils/fingerprint.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import crypto from "uncrypto";
2+
import type { H3Event } from "../event";
3+
import { getRequestIP, getRequestHeader } from "./request";
4+
5+
export interface RequestFingerprintOptions {
6+
/** @default SHA-1 */
7+
hash?: false | "SHA-1";
8+
9+
/** @default `true` */
10+
ip?: boolean;
11+
12+
/** @default `false` */
13+
xForwardedFor?: boolean;
14+
15+
/** @default `false` */
16+
method?: boolean;
17+
18+
/** @default `false` */
19+
path?: boolean;
20+
21+
/** @default `false` */
22+
userAgent?: boolean;
23+
}
24+
25+
/** @experimental Behavior of this utility might change in the future versions */
26+
export async function getRequestFingerprint(
27+
event: H3Event,
28+
opts: RequestFingerprintOptions = {},
29+
): Promise<string | null> {
30+
const fingerprint: unknown[] = [];
31+
32+
if (opts.ip !== false) {
33+
fingerprint.push(
34+
getRequestIP(event, { xForwardedFor: opts.xForwardedFor }),
35+
);
36+
}
37+
38+
if (opts.method === true) {
39+
fingerprint.push(event.method);
40+
}
41+
42+
if (opts.path === true) {
43+
fingerprint.push(event.path);
44+
}
45+
46+
if (opts.userAgent === true) {
47+
fingerprint.push(getRequestHeader(event, "user-agent"));
48+
}
49+
50+
const fingerprintString = fingerprint.filter(Boolean).join("|");
51+
52+
if (!fingerprintString) {
53+
return null;
54+
}
55+
56+
if (opts.hash === false) {
57+
return fingerprintString;
58+
}
59+
60+
const buffer = await crypto.subtle.digest(
61+
opts.hash || "SHA-1",
62+
new TextEncoder().encode(fingerprintString),
63+
);
64+
65+
const hash = [...new Uint8Array(buffer)]
66+
.map((b) => b.toString(16).padStart(2, "0"))
67+
.join("");
68+
69+
return hash;
70+
}

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from "./cache";
44
export * from "./consts";
55
export * from "./cors";
66
export * from "./cookie";
7+
export * from "./fingerprint";
78
export * from "./proxy";
89
export * from "./request";
910
export * from "./response";

test/utils.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getRequestURL,
1313
readFormData,
1414
getRequestIP,
15+
getRequestFingerprint,
1516
} from "../src";
1617

1718
describe("", () => {
@@ -187,6 +188,103 @@ describe("", () => {
187188
});
188189
});
189190

191+
describe("getRequestFingerprint", () => {
192+
it("returns an hash", async () => {
193+
app.use(eventHandler((event) => getRequestFingerprint(event)));
194+
195+
const req = request.get("/");
196+
197+
// sha1 is 40 chars long
198+
expect((await req).text).toHaveLength(40);
199+
200+
// and only uses hex chars
201+
expect((await req).text).toMatch(/^[\dA-Fa-f]+$/);
202+
});
203+
204+
it("returns the same hash every time for same request", async () => {
205+
app.use(
206+
eventHandler((event) => getRequestFingerprint(event, { hash: false })),
207+
);
208+
209+
const req = request.get("/");
210+
expect((await req).text).toMatchInlineSnapshot('"::ffff:127.0.0.1"');
211+
expect((await req).text).toMatchInlineSnapshot('"::ffff:127.0.0.1"');
212+
});
213+
214+
it("returns null when all detections impossible", async () => {
215+
app.use(
216+
eventHandler((event) =>
217+
getRequestFingerprint(event, { hash: false, ip: false }),
218+
),
219+
);
220+
const f1 = (await request.get("/")).text;
221+
expect(f1).toBe("");
222+
});
223+
224+
it("can use path/method", async () => {
225+
app.use(
226+
eventHandler((event) =>
227+
getRequestFingerprint(event, {
228+
hash: false,
229+
ip: false,
230+
path: true,
231+
method: true,
232+
}),
233+
),
234+
);
235+
236+
const req = request.post("/foo");
237+
238+
expect((await req).text).toMatchInlineSnapshot('"POST|/foo"');
239+
});
240+
241+
it("uses user agent when available", async () => {
242+
app.use(
243+
eventHandler((event) =>
244+
getRequestFingerprint(event, { hash: false, userAgent: true }),
245+
),
246+
);
247+
248+
const req = request.get("/");
249+
req.set("user-agent", "test-user-agent");
250+
251+
expect((await req).text).toMatchInlineSnapshot(
252+
'"::ffff:127.0.0.1|test-user-agent"',
253+
);
254+
});
255+
256+
it("uses x-forwarded-for ip when header set", async () => {
257+
app.use(
258+
eventHandler((event) =>
259+
getRequestFingerprint(event, { hash: false, xForwardedFor: true }),
260+
),
261+
);
262+
263+
const req = request.get("/");
264+
req.set("x-forwarded-for", "x-forwarded-for");
265+
266+
expect((await req).text).toMatchInlineSnapshot('"x-forwarded-for"');
267+
});
268+
269+
it("uses the request ip when no x-forwarded-for header set", async () => {
270+
app.use(
271+
eventHandler((event) => getRequestFingerprint(event, { hash: false })),
272+
);
273+
274+
app.options.onRequest = (e) => {
275+
Object.defineProperty(e.node.req.socket, "remoteAddress", {
276+
get(): any {
277+
return "0.0.0.0";
278+
},
279+
});
280+
};
281+
282+
const req = request.get("/");
283+
284+
expect((await req).text).toMatchInlineSnapshot('"0.0.0.0"');
285+
});
286+
});
287+
190288
describe("assertMethod", () => {
191289
it("only allow head and post", async () => {
192290
app.use(

0 commit comments

Comments
 (0)