Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
34 changes: 33 additions & 1 deletion apps/backend/src/app/api/latest/analytics/events/batch/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import * as zlib from "node:zlib";

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

const MAX_EVENTS = 500;
const MAX_COMPRESSED_BYTES = 1 * 1024 * 1024;
const MAX_DECOMPRESSED_BYTES = 8 * 1024 * 1024;

// Lone surrogates (\uD800-\uDFFF not part of a valid pair) are technically
// representable in JS strings but rejected by ClickHouse's JSON parser.
Expand All @@ -32,6 +35,35 @@ function stripLoneSurrogates(value: unknown): unknown {
return value;
}

// Bodies sent as application/octet-stream are gzipped JSON. The encoding is
// purely to evade keyword-matching adblockers (e.g. filters on "$click").
// We gunzip + JSON.parse here so the rest of the schema can validate the
// decoded object normally.
function maybeDecodeBinaryBody(value: unknown): unknown {
let bytes: Uint8Array | undefined;
if (value instanceof ArrayBuffer) {
bytes = new Uint8Array(value);
} else if (value instanceof Uint8Array) {
bytes = value;
}
if (!bytes) return value;

if (bytes.byteLength > MAX_COMPRESSED_BYTES) {
throw new StatusError(StatusError.BadRequest, "Encoded analytics body too large");
}
let decompressed: Buffer;
try {
decompressed = zlib.gunzipSync(bytes, { maxOutputLength: MAX_DECOMPRESSED_BYTES });
} catch {
throw new StatusError(StatusError.BadRequest, "Invalid encoded analytics body");
}
try {
return JSON.parse(decompressed.toString("utf-8"));
} catch {
throw new StatusError(StatusError.BadRequest, "Invalid encoded analytics body");
}
}

export const POST = createSmartRouteHandler({
metadata: {
summary: "Upload analytics event batch",
Expand All @@ -57,7 +89,7 @@ export const POST = createSmartRouteHandler({
data: yupMixed().defined(),
}).defined(),
).defined().min(1).max(MAX_EVENTS),
}).defined(),
}).defined().transform((_value, originalValue) => maybeDecodeBinaryBody(originalValue)),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
Expand Down
12 changes: 10 additions & 2 deletions apps/e2e/tests/backend/backend-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,19 @@ function expectSnakeCase(obj: unknown, path: string): void {
export async function niceBackendFetch(url: string | URL, options?: Omit<NiceRequestInit, "body" | "headers"> & {
accessType?: null | "client" | "server" | "admin",
body?: unknown,
rawBody?: Uint8Array,
rawContentType?: string,
Comment thread
BilalG1 marked this conversation as resolved.
headers?: Record<string, string | undefined>,
omitPublishableClientKey?: boolean,
userAuth?: {
accessToken?: string,
refreshToken?: string,
},
}): Promise<NiceResponse> {
const { body, headers, accessType, omitPublishableClientKey, userAuth: userAuthOverride, ...otherOptions } = options ?? {};
const { body, rawBody, rawContentType, headers, accessType, omitPublishableClientKey, userAuth: userAuthOverride, ...otherOptions } = options ?? {};
if (body !== undefined && rawBody !== undefined) {
throw new StackAssertionError("niceBackendFetch: pass either body or rawBody, not both");
}
if (typeof body === "object") {
expectSnakeCase(body, "req.body");
}
Expand All @@ -147,8 +152,11 @@ export async function niceBackendFetch(url: string | URL, options?: Omit<NiceReq
const res = await niceFetch(fullUrl, {
...otherOptions,
...body !== undefined ? { body: JSON.stringify(body) } : {},
...rawBody !== undefined ? { body: rawBody as BodyInit } : {},
Comment thread
BilalG1 marked this conversation as resolved.
headers: filterUndefined({
"content-type": body !== undefined ? "application/json" : undefined,
"content-type": rawBody !== undefined
? (rawContentType ?? "application/octet-stream")
: body !== undefined ? "application/json" : undefined,
"x-stack-access-type": accessType ?? undefined,
...projectKeys !== "no-project" && accessType ? {
"x-stack-project-id": projectKeys.projectId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { gzipSync } from "node:zlib";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { it } from "../../../../helpers";
import { Auth, Project, backendContext, niceBackendFetch } from "../../../backend-helpers";
Expand Down Expand Up @@ -160,6 +161,91 @@ it("accepts valid $click events", async ({ expect }) => {
`);
});

it("accepts a gzipped binary body (adblocker-evasion encoding)", async ({ expect }) => {
Comment thread
BilalG1 marked this conversation as resolved.
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();

const now = Date.now();
const payload = {
session_replay_segment_id: randomUUID(),
batch_id: randomUUID(),
sent_at_ms: now,
events: [
{
event_type: "$click",
event_at_ms: now - 50,
data: {
tag_name: "button",
text: "Encoded",
href: null,
selector: "button.encoded",
x: 1, y: 2, page_x: 1, page_y: 2,
viewport_width: 100, viewport_height: 100,
},
},
],
};
const compressed = gzipSync(Buffer.from(JSON.stringify(payload), "utf-8"));

const res = await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: "client",
rawBody: compressed,
});

expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "inserted": 1 },
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("rejects a binary body that isn't valid gzip", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();

const res = await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: "client",
rawBody: new Uint8Array([0, 1, 2, 3, 4, 5]),
});

expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Invalid encoded analytics body",
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("rejects a gzipped body that decompresses past the server size cap", async ({ expect }) => {
Comment thread
BilalG1 marked this conversation as resolved.
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();

// 9 MB of zeros gzips to ~9 KB but decompresses past the 8 MB server cap.
const bomb = gzipSync(Buffer.alloc(9 * 1024 * 1024));

const res = await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: "client",
rawBody: bomb,
});

expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Invalid encoded analytics body",
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("handles click event data containing a truncated surrogate pair (lone high surrogate)", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
Comment thread
BilalG1 marked this conversation as resolved.
Expand Down
16 changes: 14 additions & 2 deletions packages/stack-shared/src/interface/client-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ function getBotChallengeRequestFields(botChallenge: BotChallengeInput | undefine
};
}

async function encodeAnalyticsBody(jsonBody: string): Promise<{ body: BodyInit, contentType: string }> {
const CompressionStreamCtor: typeof CompressionStream | undefined =
Comment thread
BilalG1 marked this conversation as resolved.
(globalVar as { CompressionStream?: typeof CompressionStream }).CompressionStream;
if (typeof CompressionStreamCtor !== "function" || typeof Blob === "undefined" || typeof Response === "undefined") {
return { body: jsonBody, contentType: "application/json" };
}
const stream = new Blob([jsonBody]).stream().pipeThrough(new CompressionStreamCtor("gzip"));
Comment thread
BilalG1 marked this conversation as resolved.
Outdated
const buffer = await new Response(stream).arrayBuffer();
return { body: new Uint8Array(buffer), contentType: "application/octet-stream" };
}
Comment thread
BilalG1 marked this conversation as resolved.

export class StackClientInterface {
private pendingNetworkDiagnostics?: ReturnType<StackClientInterface["_runNetworkDiagnosticsInner"]>;
private _requestListeners = new Set<RequestListener>();
Expand Down Expand Up @@ -529,12 +540,13 @@ export class StackClientInterface {
options: { keepalive: boolean },
): Promise<Result<Response, Error>> {
try {
const encoded = await encodeAnalyticsBody(body);
const response = await this.sendClientRequest(
"/analytics/events/batch",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
headers: { "Content-Type": encoded.contentType },
body: encoded.body,
keepalive: options.keepalive,
},
session,
Expand Down
Loading