diff --git a/src/parser/index.ts b/src/parser/index.ts index 084f40a..2daad37 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,6 +1,7 @@ import { readFile, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; +import { isIP } from "node:net"; import type { YouMdParser, @@ -27,6 +28,54 @@ import { CURRENT_SCHEMA_VERSION, } from "../utils/constants"; + +function isDisallowedHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + + if ( + normalized === "localhost" || + normalized === "localhost.localdomain" || + normalized.endsWith(".localhost") + ) { + return true; + } + + // Block link-local and local-domain names often used for internal routing. + if (normalized.endsWith(".local")) { + return true; + } + + const ipType = isIP(normalized); + if (ipType === 4) { + const parts = normalized.split(".").map(Number); + const [a, b] = parts; + + if ( + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + a === 127 || + a === 0 || + (a === 169 && b === 254) + ) { + return true; + } + } + + if (ipType === 6) { + if ( + normalized === "::1" || + normalized.startsWith("fc") || + normalized.startsWith("fd") || + normalized.startsWith("fe80:") + ) { + return true; + } + } + + return false; +} + /** * Default parser implementation */ @@ -302,7 +351,7 @@ export class YouMdParserImpl implements YouMdParser { }; } - // Enforce HTTPS + // Enforce HTTPS and block local/private network targets. if (parsedUrl.protocol !== "https:") { return { profile: createEmptyProfile(), @@ -317,6 +366,20 @@ export class YouMdParserImpl implements YouMdParser { }; } + if (isDisallowedHostname(parsedUrl.hostname)) { + return { + profile: createEmptyProfile(), + success: false, + errors: [ + { + code: "NETWORK_ERROR", + message: "Refusing to fetch from local or private network addresses", + }, + ], + warnings: [], + }; + } + const timeout = fetchOptions?.timeout ?? DEFAULT_FETCH_TIMEOUT; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); @@ -336,6 +399,36 @@ export class YouMdParserImpl implements YouMdParser { signal: controller.signal, }); + // Guard against redirects to insecure or internal destinations. + const finalUrl = response.url ? new URL(response.url) : parsedUrl; + if (finalUrl.protocol !== "https:") { + return { + profile: createEmptyProfile(), + success: false, + errors: [ + { + code: "NETWORK_ERROR", + message: "Redirected to non-HTTPS URL", + }, + ], + warnings: [], + }; + } + + if (isDisallowedHostname(finalUrl.hostname)) { + return { + profile: createEmptyProfile(), + success: false, + errors: [ + { + code: "NETWORK_ERROR", + message: "Redirected to local or private network address", + }, + ], + warnings: [], + }; + } + if (!response.ok) { return { profile: createEmptyProfile(), diff --git a/test/parser/loadFromUrl.test.ts b/test/parser/loadFromUrl.test.ts index 5fb3dde..b7664d1 100644 --- a/test/parser/loadFromUrl.test.ts +++ b/test/parser/loadFromUrl.test.ts @@ -6,6 +6,35 @@ describe("loadFromUrl hardening", () => { vi.restoreAllMocks(); }); + it("rejects local/private targets before network fetch", async () => { + const parser = createParser(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await parser.loadFromUrl("https://127.0.0.1/profile.md"); + + expect(result.success).toBe(false); + expect(result.errors.some((e) => e.message.includes("local or private network"))).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("rejects redirects to local/private targets", async () => { + const parser = createParser(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + url: "https://localhost/internal.md", + headers: { get: () => 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(false); + expect(result.errors.some((e) => e.message.includes("Redirected to local or private network"))).toBe(true); + }); + it("rejects oversized responses using content-length before body read", async () => { const parser = createParser(); const textSpy = vi.fn(async () => "---\nschema_version: \"1.1\"\n---\n# Me");