From 1968cc55a4b97b31ed37493e2d4c01490f90eab1 Mon Sep 17 00:00:00 2001 From: Mouse Date: Fri, 17 Apr 2026 15:08:28 -0700 Subject: [PATCH] feat: harden remote profile loading by validating content-type --- src/parser/index.ts | 33 ++++++++++++++++++++++++ test/parser/loadFromUrl.test.ts | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/parser/index.ts b/src/parser/index.ts index 5a1ba36..516c764 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -32,6 +32,12 @@ import { */ export class YouMdParserImpl implements YouMdParser { private readonly maxFileSize: number; + private static readonly ALLOWED_REMOTE_CONTENT_TYPES = [ + "text/markdown", + "text/plain", + "text/x-markdown", + "application/markdown", + ]; constructor(options?: { maxFileSize?: number }) { this.maxFileSize = options?.maxFileSize ?? MAX_FILE_SIZE; @@ -350,6 +356,33 @@ export class YouMdParserImpl implements YouMdParser { }; } + const contentTypeHeader = response.headers.get("content-type"); + if (contentTypeHeader) { + const normalizedContentType = contentTypeHeader + .split(";")[0] + ?.trim() + .toLowerCase(); + + if ( + normalizedContentType && + !YouMdParserImpl.ALLOWED_REMOTE_CONTENT_TYPES.includes( + normalizedContentType + ) + ) { + return { + profile: createEmptyProfile(), + success: false, + errors: [ + { + code: "NETWORK_ERROR", + message: `Unsupported content type: ${normalizedContentType}`, + }, + ], + warnings: [], + }; + } + } + const maxSize = parseOptions?.maxFileSize ?? this.maxFileSize; const contentLengthHeader = response.headers.get("content-length"); if (contentLengthHeader) { diff --git a/test/parser/loadFromUrl.test.ts b/test/parser/loadFromUrl.test.ts index 4a591f9..909c261 100644 --- a/test/parser/loadFromUrl.test.ts +++ b/test/parser/loadFromUrl.test.ts @@ -76,4 +76,49 @@ describe("loadFromUrl hardening", () => { expect(result.success).toBe(false); expect(result.errors.some((e) => e.code === "FILE_TOO_LARGE")).toBe(true); }); + + it("rejects unsupported content types before reading body", async () => { + const parser = createParser(); + const textSpy = vi.fn(async () => "---\nschema_version: \"1.1\"\n---\n# Me"); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" ? "application/json" : null, + }, + text: textSpy, + } as unknown as Response); + + const result = await parser.loadFromUrl("https://example.com/profile.md"); + + expect(result.success).toBe(false); + expect(result.errors.some((e) => e.code === "NETWORK_ERROR")).toBe(true); + expect(result.errors[0]?.message).toContain("Unsupported content type"); + expect(textSpy).not.toHaveBeenCalled(); + }); + + it("accepts allowed content types with charset parameters", async () => { + const parser = createParser(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" + ? "text/markdown; charset=utf-8" + : null, + }, + body: null, + text: vi.fn(async () => "---\nschema_version: \"1.1\"\n---\n# Me"), + } as unknown as Response); + + const result = await parser.loadFromUrl("https://example.com/profile.md"); + + expect(result.success).toBe(true); + }); });