Skip to content
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
66 changes: 65 additions & 1 deletion src/parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -413,6 +430,53 @@ export class YouMdParserImpl implements YouMdParser {
}
}

private async readResponseTextWithLimit(
response: Response,
maxSize: number
): Promise<string> {
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<ParseResult | null> {
const path = await discoverProfilePath(options);

Expand Down
29 changes: 29 additions & 0 deletions test/parser/loadFromUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>({
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);
});
});
Loading