From 36331116ad150dd85ea7a620da96c03d74bca082 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 11:04:05 +0900 Subject: [PATCH 01/86] test(markdown): define parser preflight size RED --- .../serializer.resourceBounds.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/markdown/serializer.resourceBounds.test.ts diff --git a/src/markdown/serializer.resourceBounds.test.ts b/src/markdown/serializer.resourceBounds.test.ts new file mode 100644 index 00000000..a449453c --- /dev/null +++ b/src/markdown/serializer.resourceBounds.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { htmlToMarkdown, type HtmlToMarkdownOptions } from './serializer.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('HTML-to-Markdown resource bounds', () => { + it('rejects configured oversized HTML before browser parser materialization', () => { + const createElement = vi.spyOn(document, 'createElement'); + let failure: unknown; + + try { + htmlToMarkdown('

12345

', { + maxHtmlBytes: 4, + } as HtmlToMarkdownOptions & { maxHtmlBytes: number }); + } catch (error) { + failure = error; + } + + expect(createElement).not.toHaveBeenCalledWith('template'); + expect(failure).toMatchObject({ + name: 'HtmlToMarkdownResourceError', + code: 'input_too_large', + message: 'HTML-to-Markdown input exceeds the configured byte limit.', + }); + }); +}); From c5f4d33664b6c3797bb79f3bef0a2ef9d2c0b33e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 11:08:38 +0900 Subject: [PATCH 02/86] fix(markdown): define bounded HTML conversion policy --- src/markdown/htmlToMarkdownResourcePolicy.ts | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/markdown/htmlToMarkdownResourcePolicy.ts diff --git a/src/markdown/htmlToMarkdownResourcePolicy.ts b/src/markdown/htmlToMarkdownResourcePolicy.ts new file mode 100644 index 00000000..434f41fa --- /dev/null +++ b/src/markdown/htmlToMarkdownResourcePolicy.ts @@ -0,0 +1,61 @@ +/** Default UTF-8 byte ceiling for one standalone HTML-to-Markdown conversion. */ +export const DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES = 16_777_216; + +/** Hard public ceiling for an explicitly raised HTML-to-Markdown input limit. */ +export const MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES = 67_108_864; + +/** Stable redacted resource-bound failures from standalone HTML conversion. */ +export type HtmlToMarkdownResourceErrorCode = + | 'input_too_large' + | 'invalid_configuration'; + +const ERROR_MESSAGES: Readonly> = + Object.freeze({ + input_too_large: + 'HTML-to-Markdown input exceeds the configured byte limit.', + invalid_configuration: + 'HTML-to-Markdown resource configuration is invalid.', + }); + +/** Error whose stable code/message never disclose caller-controlled HTML. */ +export class HtmlToMarkdownResourceError extends Error { + /** Machine-readable rejection category safe for host telemetry. */ + readonly code: HtmlToMarkdownResourceErrorCode; + + /** Create one stable resource-bound conversion error. */ + constructor(code: HtmlToMarkdownResourceErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = 'HtmlToMarkdownResourceError'; + this.code = code; + } +} + +/** Resolve one optional per-call byte ceiling within the public hard maximum. */ +export function resolveHtmlToMarkdownMaxBytes(candidate: unknown): number { + if (candidate === undefined) return DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES; + if ( + typeof candidate !== 'number' || + !Number.isSafeInteger(candidate) || + candidate < 1 || + candidate > MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES + ) { + throw new HtmlToMarkdownResourceError('invalid_configuration'); + } + return candidate; +} + +/** Reject oversized HTML before any parser/DOM materialization. */ +export function assertHtmlToMarkdownInputSize( + html: string, + maxHtmlBytes: number, +): void { + // Every UTF-16 code unit contributes at least one UTF-8 byte. This lower + // bound avoids allocating a complete TextEncoder result when oversize is + // already certain. + if (html.length > maxHtmlBytes) { + throw new HtmlToMarkdownResourceError('input_too_large'); + } + if (new TextEncoder().encode(html).byteLength > maxHtmlBytes) { + throw new HtmlToMarkdownResourceError('input_too_large'); + } +} From 3fd1ac517082430537ac3b7d41f97c2f1668eabc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 11:09:32 +0900 Subject: [PATCH 03/86] fix(markdown): preflight HTML input before parsing --- src/markdown/serializer.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/markdown/serializer.ts b/src/markdown/serializer.ts index 4141d32a..fd79516b 100644 --- a/src/markdown/serializer.ts +++ b/src/markdown/serializer.ts @@ -14,6 +14,10 @@ import TurndownService from 'turndown'; import { gfm } from 'turndown-plugin-gfm'; import { validateInlineImageSource } from '../policy/inlineImagePolicy.js'; import { isSafeLinkHref } from '../policy/safeLinkPolicy.js'; +import { + assertHtmlToMarkdownInputSize, + resolveHtmlToMarkdownMaxBytes, +} from './htmlToMarkdownResourcePolicy.js'; const SERIALIZED_IMAGE_MAX_BYTES = 10 * 1024 * 1024; @@ -246,21 +250,27 @@ const turndownWithoutImageAlt = createTurndown(false); export interface HtmlToMarkdownOptions { /** Include image alternative text in converted Markdown. Defaults to true. */ includeImageAlt?: boolean; + /** Maximum UTF-8 bytes accepted before parsing. Defaults to 16 MiB. */ + maxHtmlBytes?: number; } /** * Convert an HTML string to Markdown through an inert, fail-closed boundary. * - * In browsers, the raw string is parsed only inside a detached `