From 9e6e558c7f6b48edd2c3b076714197375e7bde61 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:44:35 -0700 Subject: [PATCH] fix(core): Parse envelopes that contain an empty attachment `parseEnvelope` treated an item header with `length: 0` as having no length and tried to read a JSON payload instead. That payload is empty, so `JSON.parse('')` threw and the whole envelope failed to parse. This made `handleTunnelRequest` answer 400 for an event that carried an empty attachment, and made the browser offline transport drop such an envelope when reading it back from IndexedDB. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/utils/envelope.ts | 2 +- packages/core/test/lib/utils/envelope.test.ts | 17 ++++++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 20 ++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/envelope.ts b/packages/core/src/utils/envelope.ts index 4eacc1919410..b0c5b511f698 100644 --- a/packages/core/src/utils/envelope.ts +++ b/packages/core/src/utils/envelope.ts @@ -168,7 +168,7 @@ export function parseEnvelope(env: string | Uint8Array): Envelope { const itemHeader = readJson(); const binaryLength = typeof itemHeader.length === 'number' ? itemHeader.length : undefined; - items.push([itemHeader, binaryLength ? readBinary(binaryLength) : readJson()]); + items.push([itemHeader, binaryLength !== undefined ? readBinary(binaryLength) : readJson()]); } return [envelopeHeader, items]; diff --git a/packages/core/test/lib/utils/envelope.test.ts b/packages/core/test/lib/utils/envelope.test.ts index d6002ce1dfa3..ae068e20c7fe 100644 --- a/packages/core/test/lib/utils/envelope.test.ts +++ b/packages/core/test/lib/utils/envelope.test.ts @@ -4,6 +4,7 @@ import type { EventEnvelope } from '../../../src/types/envelope'; import type { Event } from '../../../src/types/event'; import { addItemToEnvelope, + createAttachmentEnvelopeItem, createEnvelope, forEachEnvelopeItem, parseEnvelope, @@ -90,6 +91,22 @@ describe('envelope', () => { after?.(); }); + it('parses an envelope with an empty attachment', () => { + const env = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + createAttachmentEnvelopeItem({ filename: 'empty.txt', data: '' }), + createAttachmentEnvelopeItem({ filename: 'foo.txt', data: 'foo' }), + ]); + + const [, parsedItems] = parseEnvelope(serializeEnvelope(env)); + + expect(parsedItems).toEqual([ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + [{ type: 'attachment', filename: 'empty.txt', length: 0 }, new Uint8Array(0)], + [{ type: 'attachment', filename: 'foo.txt', length: 3 }, new TextEncoder().encode('foo')], + ]); + }); + it("doesn't throw when being passed a an envelope that contains a circular item payload", () => { const chicken: { egg?: any } = {}; const egg = { chicken } as unknown as Event; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 2b499ce754ae..786465cb6e80 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; import { makeDsn } from '../../../src/utils/dsn'; -import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; +import { createAttachmentEnvelopeItem, createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { handleTunnelRequest } from '../../../src/utils/tunnel'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; @@ -43,6 +43,24 @@ describe('handleTunnelRequest', () => { expect(result).toBe(upstreamResponse); }); + it('forwards an envelope that contains an empty attachment', async () => { + const upstreamResponse = new Response('ok', { status: 200 }); + fetchMock.mockResolvedValueOnce(upstreamResponse); + + const envelope = createEnvelope({ dsn: TEST_DSN }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + createAttachmentEnvelopeItem({ filename: 'empty.txt', data: '' }), + ]); + + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { method: 'POST', body: serializeEnvelope(envelope) }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(result).toBe(upstreamResponse); + }); + it('returns 500 when allowedDsns is empty', async () => { const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }),