diff --git a/src/parser/index.ts b/src/parser/index.ts index 084f40a..5a1ba36 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -369,7 +369,7 @@ export class YouMdParserImpl implements YouMdParser { } } - const content = await response.text(); + const content = await this.readResponseTextWithLimit(response, maxSize); const result = this.parse(content, parseOptions); // Add source URL to profile @@ -397,6 +397,23 @@ export class YouMdParserImpl implements YouMdParser { }; } + if ( + err instanceof Error && + err.message.includes("exceeds maximum") + ) { + return { + profile: createEmptyProfile(), + success: false, + errors: [ + { + code: "FILE_TOO_LARGE", + message: err.message, + }, + ], + warnings: [], + }; + } + return { profile: createEmptyProfile(), success: false, @@ -413,6 +430,53 @@ export class YouMdParserImpl implements YouMdParser { } } + private async readResponseTextWithLimit( + response: Response, + maxSize: number + ): Promise { + if (!response.body) { + const content = await response.text(); + const size = new TextEncoder().encode(content).length; + if (size > maxSize) { + throw new Error( + `Response size ${size} bytes exceeds maximum ${maxSize} bytes` + ); + } + return content; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (value) { + totalBytes += value.byteLength; + if (totalBytes > maxSize) { + await reader.cancel(); + throw new Error( + `Response size ${totalBytes} bytes exceeds maximum ${maxSize} bytes` + ); + } + chunks.push(value); + } + } + + const contentBytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + contentBytes.set(chunk, offset); + offset += chunk.byteLength; + } + + return new TextDecoder().decode(contentBytes); + } + async discover(options?: DiscoveryOptions): Promise { const path = await discoverProfilePath(options); diff --git a/test/parser/loadFromUrl.test.ts b/test/parser/loadFromUrl.test.ts index 5fb3dde..4a591f9 100644 --- a/test/parser/loadFromUrl.test.ts +++ b/test/parser/loadFromUrl.test.ts @@ -47,4 +47,33 @@ describe("loadFromUrl hardening", () => { expect(result.success).toBe(false); expect(result.errors.some((e) => e.code === "TIMEOUT")).toBe(true); }); + + it("rejects oversized streamed responses when content-length is missing", async () => { + const parser = createParser(); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("a".repeat(600))); + controller.enqueue(encoder.encode("b".repeat(600))); + controller.close(); + }, + }); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => null }, + body: stream, + text: vi.fn(async () => ""), + } as unknown as Response); + + const result = await parser.loadFromUrl("https://example.com/profile.md", undefined, { + maxFileSize: 1024, + }); + + expect(result.success).toBe(false); + expect(result.errors.some((e) => e.code === "FILE_TOO_LARGE")).toBe(true); + }); });