Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow characteristics to be specified on the SDK for fingerprint generation & propagate to rate limit rule #1016

Merged
merged 7 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 8 additions & 52 deletions analyze/edge-light.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ArcjetLogger } from "@arcjet/protocol";
import type { ArcjetLogger, ArcjetRequestDetails } from "@arcjet/protocol";

import * as core from "./wasm/arcjet_analyze_js_req.component.js";
import type {
Expand All @@ -14,6 +14,7 @@ import componentCore3Wasm from "./wasm/arcjet_analyze_js_req.component.core3.was

interface AnalyzeContext {
log: ArcjetLogger;
characteristics: string[];
}
e-moran marked this conversation as resolved.
Show resolved Hide resolved

async function moduleFromPath(path: string): Promise<WebAssembly.Module> {
Expand Down Expand Up @@ -72,45 +73,21 @@ export {
/**
* Generate a fingerprint for the client. This is used to identify the client
* across multiple requests.
* @param ip - The IP address of the client.
* @param context - The Arcjet Analyze context.
* @param request - The request to fingerprint.
* @returns A SHA-256 string fingerprint.
*/
export async function generateFingerprint(
context: AnalyzeContext,
ip: string,
request: Partial<ArcjetRequestDetails>,
): Promise<string> {
if (ip == "") {
return "";
}

const analyze = await init(context);

if (typeof analyze !== "undefined") {
return analyze.generateFingerprint(ip);
}

if (hasSubtleCryptoDigest()) {
// Fingerprint v1 is just the IP address
const fingerprintRaw = `fp_1_${ip}`;

// Based on MDN example at
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string

// Encode the raw fingerprint into a utf-8 Uint8Array
const fingerprintUint8 = new TextEncoder().encode(fingerprintRaw);
// Hash the message with SHA-256
const fingerprintArrayBuffer = await crypto.subtle.digest(
"SHA-256",
fingerprintUint8,
return analyze.generateFingerprint(
JSON.stringify(request),
context.characteristics,
);
// Convert the ArrayBuffer to a byte array
const fingerprintArray = Array.from(new Uint8Array(fingerprintArrayBuffer));
// Convert the bytes to a hex string
const fingerprint = fingerprintArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");

return fingerprint;
}

return "";
Expand Down Expand Up @@ -149,24 +126,3 @@ export async function detectBot(
};
}
}

function hasSubtleCryptoDigest() {
if (typeof crypto === "undefined") {
return false;
}

if (!("subtle" in crypto)) {
return false;
}
if (typeof crypto.subtle === "undefined") {
return false;
}
if (!("digest" in crypto.subtle)) {
return false;
}
if (typeof crypto.subtle.digest !== "function") {
return false;
}

return true;
}
60 changes: 8 additions & 52 deletions analyze/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ArcjetLogger } from "@arcjet/protocol";
import type { ArcjetLogger, ArcjetRequestDetails } from "@arcjet/protocol";

import * as core from "./wasm/arcjet_analyze_js_req.component.js";
import type {
Expand All @@ -14,6 +14,7 @@ import { wasm as componentCore3Wasm } from "./wasm/arcjet_analyze_js_req.compone

interface AnalyzeContext {
log: ArcjetLogger;
characteristics: string[];
}

// TODO: Do we actually need this wasmCache or does `import` cache correctly?
Expand Down Expand Up @@ -86,45 +87,21 @@ export {
/**
* Generate a fingerprint for the client. This is used to identify the client
* across multiple requests.
* @param ip - The IP address of the client.
* @param context - The Arcjet Analyze context.
* @param request - The request to fingerprint.
* @returns A SHA-256 string fingerprint.
*/
export async function generateFingerprint(
context: AnalyzeContext,
ip: string,
request: Partial<ArcjetRequestDetails>,
): Promise<string> {
if (ip == "") {
return "";
}

const analyze = await init(context);

if (typeof analyze !== "undefined") {
return analyze.generateFingerprint(ip);
}

if (hasSubtleCryptoDigest()) {
// Fingerprint v1 is just the IP address
const fingerprintRaw = `fp_1_${ip}`;

// Based on MDN example at
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string

// Encode the raw fingerprint into a utf-8 Uint8Array
const fingerprintUint8 = new TextEncoder().encode(fingerprintRaw);
// Hash the message with SHA-256
const fingerprintArrayBuffer = await crypto.subtle.digest(
"SHA-256",
fingerprintUint8,
return analyze.generateFingerprint(
JSON.stringify(request),
context.characteristics,
);
// Convert the ArrayBuffer to a byte array
const fingerprintArray = Array.from(new Uint8Array(fingerprintArrayBuffer));
// Convert the bytes to a hex string
const fingerprint = fingerprintArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");

return fingerprint;
}

return "";
Expand Down Expand Up @@ -163,24 +140,3 @@ export async function detectBot(
};
}
}

function hasSubtleCryptoDigest() {
if (typeof crypto === "undefined") {
return false;
}

if (!("subtle" in crypto)) {
return false;
}
if (typeof crypto.subtle === "undefined") {
return false;
}
if (!("digest" in crypto.subtle)) {
return false;
}
if (typeof crypto.subtle.digest !== "function") {
return false;
}

return true;
}
Binary file modified analyze/wasm/arcjet_analyze_js_req.component.core.wasm
Binary file not shown.
Binary file modified analyze/wasm/arcjet_analyze_js_req.component.core2.wasm
Binary file not shown.
Binary file modified analyze/wasm/arcjet_analyze_js_req.component.core3.wasm
Binary file not shown.
2 changes: 1 addition & 1 deletion analyze/wasm/arcjet_analyze_js_req.component.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface ImportObject {
}
export interface Root {
detectBot(headers: string, patternsAdd: string, patternsRemove: string): BotDetectionResult,
generateFingerprint(ip: string): string,
generateFingerprint(request: string, characteristics: string[]): string,
isValidEmail(candidate: string, options: EmailValidationConfig | undefined): boolean,
}

Expand Down
22 changes: 16 additions & 6 deletions analyze/wasm/arcjet_analyze_js_req.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,25 @@ async function instantiate(getCoreModule, imports, instantiateCore = WebAssembly
return variant5.val;
}

function generateFingerprint(arg0) {
function generateFingerprint(arg0, arg1) {
var ptr0 = utf8Encode(arg0, realloc0, memory0);
var len0 = utf8EncodedLen;
const ret = exports1['generate-fingerprint'](ptr0, len0);
var ptr1 = dataView(memory0).getInt32(ret + 0, true);
var len1 = dataView(memory0).getInt32(ret + 4, true);
var result1 = utf8Decoder.decode(new Uint8Array(memory0.buffer, ptr1, len1));
var vec2 = arg1;
var len2 = vec2.length;
var result2 = realloc0(0, 0, 4, len2 * 8);
for (let i = 0; i < vec2.length; i++) {
const e = vec2[i];
const base = result2 + i * 8;var ptr1 = utf8Encode(e, realloc0, memory0);
var len1 = utf8EncodedLen;
dataView(memory0).setInt32(base + 4, len1, true);
dataView(memory0).setInt32(base + 0, ptr1, true);
}
const ret = exports1['generate-fingerprint'](ptr0, len0, result2, len2);
var ptr3 = dataView(memory0).getInt32(ret + 0, true);
var len3 = dataView(memory0).getInt32(ret + 4, true);
var result3 = utf8Decoder.decode(new Uint8Array(memory0.buffer, ptr3, len3));
postReturn1(ret);
return result1;
return result3;
}

function isValidEmail(arg0, arg1) {
Expand Down
Binary file modified analyze/wasm/arcjet_analyze_js_req.component.wasm
Binary file not shown.
60 changes: 8 additions & 52 deletions analyze/workerd.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ArcjetLogger } from "@arcjet/protocol";
import type { ArcjetLogger, ArcjetRequestDetails } from "@arcjet/protocol";

import * as core from "./wasm/arcjet_analyze_js_req.component.js";
import type {
Expand All @@ -14,6 +14,7 @@ import componentCore3Wasm from "./wasm/arcjet_analyze_js_req.component.core3.was

interface AnalyzeContext {
log: ArcjetLogger;
characteristics: string[];
}

async function moduleFromPath(path: string): Promise<WebAssembly.Module> {
Expand Down Expand Up @@ -72,45 +73,21 @@ export {
/**
* Generate a fingerprint for the client. This is used to identify the client
* across multiple requests.
* @param ip - The IP address of the client.
* @param context - The Arcjet Analyze context.
* @param request - The request to fingerprint.
* @returns A SHA-256 string fingerprint.
*/
export async function generateFingerprint(
context: AnalyzeContext,
ip: string,
request: Partial<ArcjetRequestDetails>,
): Promise<string> {
if (ip == "") {
return "";
}

const analyze = await init(context);

if (typeof analyze !== "undefined") {
return analyze.generateFingerprint(ip);
}

if (hasSubtleCryptoDigest()) {
// Fingerprint v1 is just the IP address
const fingerprintRaw = `fp_1_${ip}`;

// Based on MDN example at
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string

// Encode the raw fingerprint into a utf-8 Uint8Array
const fingerprintUint8 = new TextEncoder().encode(fingerprintRaw);
// Hash the message with SHA-256
const fingerprintArrayBuffer = await crypto.subtle.digest(
"SHA-256",
fingerprintUint8,
return analyze.generateFingerprint(
JSON.stringify(request),
context.characteristics,
);
// Convert the ArrayBuffer to a byte array
const fingerprintArray = Array.from(new Uint8Array(fingerprintArrayBuffer));
// Convert the bytes to a hex string
const fingerprint = fingerprintArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");

return fingerprint;
}

return "";
Expand Down Expand Up @@ -149,24 +126,3 @@ export async function detectBot(
};
}
}

function hasSubtleCryptoDigest() {
if (typeof crypto === "undefined") {
return false;
}

if (!("subtle" in crypto)) {
return false;
}
if (typeof crypto.subtle === "undefined") {
return false;
}
if (!("digest" in crypto.subtle)) {
return false;
}
if (typeof crypto.subtle.digest !== "function") {
return false;
}

return true;
}
36 changes: 26 additions & 10 deletions arcjet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ import {
ArcjetSlidingWindowRateLimitRule,
ArcjetShieldRule,
ArcjetLogger,
ArcjetRateLimitRule,
} from "@arcjet/protocol";
import { ArcjetBotTypeToProtocol } from "@arcjet/protocol/convert.js";
import {
ArcjetBotTypeToProtocol,
isRateLimitRule,
} from "@arcjet/protocol/convert.js";
import { Client } from "@arcjet/protocol/client.js";
import * as analyze from "@arcjet/analyze";
import * as duration from "@arcjet/duration";
Expand Down Expand Up @@ -784,6 +788,10 @@ export interface ArcjetOptions<Rules extends [...(Primitive | Product)[]]> {
* Rules to apply when protecting a request.
*/
rules: readonly [...Rules];
/**
* Characteristics to be used to uniquely identify clients.
*/
characteristics?: string[];
/**
* The client used to make requests to the Arcjet API. This must be set
* when creating the SDK, such as inside @arcjet/next or mocked in tests.
Expand Down Expand Up @@ -890,21 +898,19 @@ export default function arcjet<
log.time?.("local");

log.time?.("fingerprint");
let ip = "";
if (typeof details.ip === "string") {
ip = details.ip;
}
if (details.ip === "") {
log.warn("generateFingerprint: ip is empty");
}

const characteristics = options.characteristics
? options.characteristics
: [];

const baseContext = {
key,
log,
characteristics,
...ctx,
};

const fingerprint = await analyze.generateFingerprint(baseContext, ip);
const fingerprint = await analyze.generateFingerprint(baseContext, details);
log.debug("fingerprint (%s): %s", rt, fingerprint);
log.timeEnd?.("fingerprint");

Expand Down Expand Up @@ -945,14 +951,24 @@ export default function arcjet<
}

const results: ArcjetRuleResult[] = [];
// Default all rules to NOT_RUN/ALLOW before doing anything
for (let idx = 0; idx < rules.length; idx++) {
// Default all rules to NOT_RUN/ALLOW before doing anything
results[idx] = new ArcjetRuleResult({
ttl: 0,
state: "NOT_RUN",
conclusion: "ALLOW",
reason: new ArcjetReason(),
});

// Add top-level characteristics to all Rate Limit rules that don't already have
// their own set of characteristics.
const candidate_rule = rules[idx];
if (isRateLimitRule(candidate_rule)) {
if (typeof candidate_rule.characteristics === "undefined") {
candidate_rule.characteristics = characteristics;
rules[idx] = candidate_rule;
}
}
}

// We have our own local cache which we check first. This doesn't work in
Expand Down
Loading
Loading