diff --git a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index 02e3798fd..2c5955384 100644 --- a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -11,7 +11,7 @@ import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumbe import { StatusError } from "@hexclave/shared/dist/utils/errors"; import { randomUUID } from "node:crypto"; import { promisify } from "node:util"; -import { gzip as gzipCb } from "node:zlib"; +import { gzip as gzipCb, gunzipSync } from "node:zlib"; const gzip = promisify(gzipCb); @@ -19,6 +19,35 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0 const MAX_BODY_BYTES = 1_000_000; const MAX_EVENTS = 5_000; +// Zip-bomb guard; keep in sync with the client's MAX_SINGLE_EVENT_BYTES. +const MAX_DECOMPRESSED_BYTES = 8 * 1024 * 1024; + +// Gunzips application/octet-stream bodies (the client gzips large batches); +// plain application/json bodies pass through untouched. +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_BODY_BYTES) { + throw new StatusError(StatusError.PayloadTooLarge, `Encoded session replay body too large (max ${MAX_BODY_BYTES} bytes)`); + } + let decompressed: Buffer; + try { + decompressed = gunzipSync(bytes, { maxOutputLength: MAX_DECOMPRESSED_BYTES }); + } catch { + throw new StatusError(StatusError.BadRequest, "Invalid encoded session replay body"); + } + try { + return JSON.parse(decompressed.toString("utf-8")); + } catch { + throw new StatusError(StatusError.BadRequest, "Invalid encoded session replay body"); + } +} function extractEventTimesMs(events: unknown[], fallbackMs: number) { let minTs = Infinity; @@ -60,7 +89,7 @@ export const POST = createSmartRouteHandler({ started_at_ms: yupNumber().defined().integer().min(0), sent_at_ms: yupNumber().defined().integer().min(0), events: yupArray(yupMixed().defined()).defined(), - }).defined(), + }).defined().transform((_value, originalValue) => maybeDecodeBinaryBody(originalValue)), }), response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts index 351d2f6b4..abaccc8ad 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts @@ -1,4 +1,5 @@ -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; +import { gzipSync } from "node:zlib"; import { PLAN_LIMITS } from "@hexclave/shared/dist/plans"; import { wait } from "@hexclave/shared/dist/utils/promises"; import { it } from "../../../../helpers"; @@ -162,6 +163,112 @@ it("stores session replay batch metadata and dedupes by (session_replay_id, batc expect(second.body?.session_replay_id).toBe(recordingId); }); +it("accepts a gzipped binary body (compressed large-payload encoding)", async ({ expect }) => { + 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 = { + browser_session_id: randomUUID(), + session_replay_segment_id: randomUUID(), + batch_id: randomUUID(), + started_at_ms: now, + sent_at_ms: now + 500, + // Large full snapshot: exceeds the 1MB raw wire limit but gzips under it. + events: [{ timestamp: now + 100, type: 2, data: { html: "x".repeat(2_000_000) } }], + }; + const compressed = gzipSync(Buffer.from(JSON.stringify(payload), "utf-8")); + // Sanity: the raw payload exceeds the wire limit, the compressed one doesn't. + expect(compressed.byteLength).toBeLessThan(1_000_000); + + const res = await niceBackendFetch("/api/v1/session-replays/batch", { + method: "POST", + accessType: "client", + rawBody: compressed, + }); + + expect(res).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "batch_id": "", + "deduped": false, + "s3_key": "session-replays//main//.json.gz", + "session_replay_id": "", + }, + "headers": Headers {