diff --git a/docs/doctoring/html-to-markdown-resource-bounds.md b/docs/doctoring/html-to-markdown-resource-bounds.md new file mode 100644 index 00000000..cf630c15 --- /dev/null +++ b/docs/doctoring/html-to-markdown-resource-bounds.md @@ -0,0 +1,82 @@ +# HTML-to-Markdown resource bounds + +Status: Implemented on active PR + +## Purpose + +Inkspan's standalone `htmlToMarkdown()` boundary accepts caller-provided HTML and +parses it through either a detached browser template or Turndown's browserless +parser. Protected shipped truth is currently +`main@3b38ead2d00f44eb578d0689087b9293b3dabe1e`; this active PR carries the +next-release bounded reliability contract and remains unintegrated. The contract +does not change transport, persistence, credentials, models, tenancy, +authorization, collaboration-provider, or network authority. + +## Active-PR contract + +`HtmlToMarkdownOptions.maxHtmlBytes` is an optional positive safe integer. The +active implementation defaults to 16 MiB and rejects configured values above a +64 MiB hard maximum. The public runtime option bag is snapshotted before source +sizing or parser work: only ordinary/null-prototype enumerable data properties +for `includeImageAlt` and `maxHtmlBytes` are accepted. Accessor-backed options +are rejected without invoking their getters. Exotic prototypes, symbol or +unknown keys, non-enumerable properties, malformed `includeImageAlt`, and +hostile reflection failures also fail closed through the stable resource error. + +JavaScript Proxy meta-object traps are a distinct boundary: inspecting an +untrusted option object's prototype/descriptors necessarily performs language +reflection and can execute a Proxy `getPrototypeOf`, `ownKeys`, or +`getOwnPropertyDescriptor` trap. Inkspan does not claim otherwise. A thrown or +malformed reflection result is normalized to the payload-redacted +`HtmlToMarkdownResourceError` contract and is never reflected to diagnostics; +callers that require a no-caller-code boundary must pass ordinary or +null-prototype data objects rather than Proxies. + +The HTML source itself must be a primitive string. Non-string runtime input is +rejected before reading caller properties, `TextEncoder` coercion, browser DOM +materialization, or browserless parsing. For accepted strings, Inkspan first +compares JavaScript UTF-16 code-unit length to the selected byte ceiling. Because +each code unit contributes at least one UTF-8 byte, that check can reject inputs +that are certainly oversized without allocating a complete encoded buffer. +Inputs not rejected by that lower bound receive an exact UTF-8 byte-length check +before any browser DOM or browserless Turndown parser is reached. + +Oversized input raises a stable redacted error with name +`HtmlToMarkdownResourceError`, code `input_too_large`, and no caller-controlled +HTML in its message. Non-string input uses code `invalid_input`; malformed +runtime options/resource-limit configuration use code `invalid_configuration`. +Accepted safe-link, strict inline-raster, image-alt, normalization, +browserless-package, and deterministic conversion semantics remain unchanged. + +## Verification + +The active PR carries machine tests that prove: + +- obvious oversize does not invoke `TextEncoder.encode()` or browser template + creation; +- hostile non-string input is rejected before caller property access, encoding, + or parser work and without leaking caller-thrown values; +- non-ASCII input still uses exact UTF-8 byte accounting; +- exact-boundary input remains accepted; +- accessor-backed option properties are rejected without executing their + getters, while a hostile Proxy prototype-reflection trap is explicitly proven + to execute once and its private thrown value is normalized/redacted; +- malformed option bags and wrong-type, fractional, zero, and above-maximum + limits fail closed without reflecting input content; and +- the packed ESM runtime and strict TypeScript consumer exercise the public + resource-bound surface while retaining the no-network/no-credential package + authority check. + +This document is active-PR truth only. It must not be represented as protected +behavior until the implementation is integrated into protected `main`. Issue +#118 continues to own the exact `0.6.0` protected release-candidate operational +boundary, so this next-release lane remains Draft and unmerged while that +identity is active. + +## Rollback + +Before protected integration, rollback removes this active-PR option, resource +policy module, regression tests, packed-consumer assertions, and this doctoring +record together. After integration, reducing the documented hard ceiling or +changing error codes/messages is a public compatibility decision and requires +versioned release treatment. diff --git a/docs/plain-text-projection.md b/docs/plain-text-projection.md index ccb611dc..79272550 100644 --- a/docs/plain-text-projection.md +++ b/docs/plain-text-projection.md @@ -54,6 +54,30 @@ default policy. Informative image alternatives remain in reading order, which allows indexing and AI workflows to retain author-supplied non-visual meaning without receiving image bytes. +## Next-release parser resource bounds + +Status: `implemented_on_active_pr` in #174. Protected `main` does not yet expose +these plain-text-specific options, so this section is not a shipped-release +claim. + +The active contract accepts `maxMarkdownBytes` on `markdownToPlainText()` and +checks the exact UTF-8 byte size before the Marked lexer materializes tokens. +The inherited Markdown parser policy defaults to 16 MiB and rejects configured +limits above the 64 MiB hard maximum. Invalid configuration and oversized input +fail closed through the same stable, payload-redacted Markdown resource errors +used by the shared Markdown package boundary. + +`htmlToPlainText()` additionally accepts `maxHtmlBytes`. That ceiling is checked +before HTML normalization, and `maxMarkdownBytes` is checked again on the +normalized Markdown before it enters the plain-text lexer. The two bounds are +intentionally independent because HTML normalization can change representation +size. Accepted-input reading-order, list/table/code, image-alt, link-label and +raw-HTML omission semantics remain unchanged. + +These local parser bounds are defense in depth, not transport or persistence +authority. Hosts still own request/ingress limits, authorization, tenancy, +durable storage, retention and operational admission control. + ## Runtime and interoperability boundary `markdownToPlainText` uses Marked's lexer and does not execute raw HTML, open a diff --git a/scripts/verify-markdown-subpath-package.mjs b/scripts/verify-markdown-subpath-package.mjs index d4e988f3..cfa9bf2d 100644 --- a/scripts/verify-markdown-subpath-package.mjs +++ b/scripts/verify-markdown-subpath-package.mjs @@ -108,26 +108,75 @@ Object.defineProperty(globalThis, 'document', { configurable: true, get() { throw new Error('ambient document access is forbidden'); }, }); +Object.defineProperty(globalThis, 'window', { + configurable: true, + get() { throw new Error('ambient window access is forbidden'); }, +}); const markdown = await import('${packageJson.name}/markdown'); const { + DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, + DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, + MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, + MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, + HtmlToMarkdownResourceError, + MarkdownToHtmlResourceError, htmlToMarkdown, markdownToEmailHtml, markdownToHtml, markdownToPlainText, normalizeMarkdown, } = markdown; +assert.equal(DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, 16_777_216); +assert.equal(MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, 67_108_864); +assert.equal(DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, 16_777_216); +assert.equal(MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, 67_108_864); const safeHtml = markdownToHtml('[safe](https://example.com)'); assert.equal(safeHtml.includes('href="https://example.com"'), true); assert.doesNotMatch(markdownToHtml('[unsafe](javascript:alert(1))'), /href=/u); +let markdownBoundedFailure; +try { + markdownToHtml('oversized', { maxMarkdownBytes: 4 }); +} catch (error) { + markdownBoundedFailure = error; +} +assert.equal(markdownBoundedFailure instanceof MarkdownToHtmlResourceError, true); +assert.equal(markdownBoundedFailure?.name, 'MarkdownToHtmlResourceError'); +assert.equal(markdownBoundedFailure?.code, 'input_too_large'); +assert.equal( + markdownBoundedFailure?.message, + 'Markdown-to-HTML input exceeds the configured byte limit.', +); assert.equal(htmlToMarkdown('

Alpha Beta

'), 'Alpha **Beta**'); +let htmlBoundedFailure; +try { + htmlToMarkdown('

oversized

', { maxHtmlBytes: 4 }); +} catch (error) { + htmlBoundedFailure = error; +} +assert.equal(htmlBoundedFailure instanceof HtmlToMarkdownResourceError, true); +assert.equal(htmlBoundedFailure?.name, 'HtmlToMarkdownResourceError'); +assert.equal(htmlBoundedFailure?.code, 'input_too_large'); +assert.equal( + htmlBoundedFailure?.message, + 'HTML-to-Markdown input exceeds the configured byte limit.', +); assert.equal(markdownToPlainText('**Alpha** [Beta](https://example.com)'), 'Alpha Beta'); assert.equal(normalizeMarkdown('**Alpha**').includes('**Alpha**'), true); +assert.throws( + () => normalizeMarkdown('oversized', { maxMarkdownBytes: 4 }), + (error) => error instanceof MarkdownToHtmlResourceError && error.code === 'input_too_large', +); const email = markdownToEmailHtml('Hello', { fullDocument: true, languageTag: 'ko-kr', textDirection: 'ltr', }); assert.equal(email.includes(''), true); +assert.throws( + () => markdownToEmailHtml('oversized', { maxMarkdownBytes: 4 }), + (error) => error instanceof MarkdownToHtmlResourceError && error.code === 'input_too_large', +); +delete globalThis.window; delete globalThis.document; `, 'utf8', @@ -141,11 +190,34 @@ Object.defineProperty(globalThis, 'document', { configurable: true, get() { throw new Error('ambient document access is forbidden'); }, }); +Object.defineProperty(globalThis, 'window', { + configurable: true, + get() { throw new Error('ambient window access is forbidden'); }, +}); const markdown = require('${packageJson.name}/markdown'); assert.equal(typeof markdown.markdownToHtml, 'function'); assert.equal(markdown.htmlToMarkdown('

Gamma

'), 'Gamma'); +assert.equal(markdown.DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, 16_777_216); +assert.equal(markdown.MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, 67_108_864); +assert.equal(typeof markdown.HtmlToMarkdownResourceError, 'function'); +assert.equal(markdown.DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, 16_777_216); +assert.equal(markdown.MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, 67_108_864); +assert.equal(typeof markdown.MarkdownToHtmlResourceError, 'function'); +assert.throws( + () => markdown.markdownToHtml('oversized', { maxMarkdownBytes: 4 }), + (error) => error instanceof markdown.MarkdownToHtmlResourceError && error.code === 'input_too_large', +); +assert.throws( + () => markdown.normalizeMarkdown('oversized', { maxMarkdownBytes: 4 }), + (error) => error instanceof markdown.MarkdownToHtmlResourceError && error.code === 'input_too_large', +); assert.equal(typeof markdown.markdownToEmailHtml, 'function'); +assert.throws( + () => markdown.markdownToEmailHtml('oversized', { maxMarkdownBytes: 4 }), + (error) => error instanceof markdown.MarkdownToHtmlResourceError && error.code === 'input_too_large', +); assert.equal(markdown.markdownToPlainText('# Title'), 'Title'); +delete globalThis.window; delete globalThis.document; `, 'utf8', @@ -162,6 +234,12 @@ function verifyDeclarationConsumer() { writeFileSync( sourcePath, `import { + DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, + DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, + MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, + MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, + HtmlToMarkdownResourceError, + MarkdownToHtmlResourceError, htmlToMarkdown, htmlToPlainText, markdownToEmailHtml, @@ -169,20 +247,40 @@ function verifyDeclarationConsumer() { markdownToPlainText, normalizeMarkdown, type HtmlToMarkdownOptions, + type HtmlToMarkdownResourceErrorCode, type MarkdownToEmailHtmlOptions, + type MarkdownToHtmlOptions, + type MarkdownToHtmlResourceErrorCode, + type NormalizeMarkdownOptions, type PlainTextOptions, } from '${packageJson.name}/markdown'; -const htmlOptions: HtmlToMarkdownOptions = { includeImageAlt: false }; +const htmlOptions: HtmlToMarkdownOptions = { + includeImageAlt: false, + maxHtmlBytes: 1024, +}; +const markdownOptions: MarkdownToHtmlOptions = { maxMarkdownBytes: 1024 }; +const normalizeOptions: NormalizeMarkdownOptions = { maxMarkdownBytes: 1024 }; +const htmlErrorCode: HtmlToMarkdownResourceErrorCode = 'input_too_large'; +const htmlResourceError = new HtmlToMarkdownResourceError(htmlErrorCode); +const markdownErrorCode: MarkdownToHtmlResourceErrorCode = 'input_too_large'; +const markdownResourceError = new MarkdownToHtmlResourceError(markdownErrorCode); const emailOptions: MarkdownToEmailHtmlOptions = { fullDocument: true, languageTag: 'en-US', textDirection: 'ltr', + maxMarkdownBytes: 1024, }; const plainOptions: PlainTextOptions = { includeImageAlt: true }; void [ - markdownToHtml('x'), + DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, + MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, + DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, + MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, + htmlResourceError.code, + markdownResourceError.code, + markdownToHtml('x', markdownOptions), htmlToMarkdown('

x

', htmlOptions), - normalizeMarkdown('x'), + normalizeMarkdown('x', normalizeOptions), markdownToEmailHtml('x', emailOptions), markdownToPlainText('x', plainOptions), htmlToPlainText('

x

', plainOptions), diff --git a/src/components/editorSerialization.ts b/src/components/editorSerialization.ts index f0d1ec43..a2a0a810 100644 --- a/src/components/editorSerialization.ts +++ b/src/components/editorSerialization.ts @@ -1,15 +1,23 @@ import type { EditorMode } from '../types.js'; -import { - htmlToMarkdown, - markdownToEditorHtml, -} from '../markdown/serializer.js'; +import { htmlToMarkdown } from '../markdown/serializer.js'; +import { markdownToEditorHtml } from '../markdown/resourceBoundMarkdown.js'; + +const INVALID_MODE_ERROR = 'Editor mode must be markdown or html.'; + +function assertEditorMode(mode: EditorMode): void { + if (mode !== 'markdown' && mode !== 'html') { + throw new RangeError(INVALID_MODE_ERROR); + } +} /** Convert a host value in the selected editor mode into TipTap HTML. */ export function editorValueToHtml(value: string, mode: EditorMode): string { + assertEditorMode(mode); return mode === 'markdown' ? markdownToEditorHtml(value) : value; } /** Convert TipTap HTML into the serialization selected by the host. */ export function editorHtmlToValue(html: string, mode: EditorMode): string { + assertEditorMode(mode); return mode === 'markdown' ? htmlToMarkdown(html) : html; } diff --git a/src/components/editorSerializationRuntime.test.ts b/src/components/editorSerializationRuntime.test.ts new file mode 100644 index 00000000..a3675185 --- /dev/null +++ b/src/components/editorSerializationRuntime.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { + editorHtmlToValue, + editorValueToHtml, +} from './editorSerialization.js'; + +const INVALID_MODE_ERROR = 'Editor mode must be markdown or html.'; + +describe('editor serialization runtime mode contract', () => { + it('rejects an invalid runtime mode before converting a host value', () => { + expect(() => editorValueToHtml('# Heading', 'md' as never)).toThrowError( + new RangeError(INVALID_MODE_ERROR), + ); + }); + + it('rejects an invalid runtime mode before converting editor HTML', () => { + expect(() => editorHtmlToValue('

Body

', 'rich' as never)).toThrowError( + new RangeError(INVALID_MODE_ERROR), + ); + }); +}); diff --git a/src/documentEnvelopeCanonical.test.ts b/src/documentEnvelopeCanonical.test.ts index f698107a..2e4aab93 100644 --- a/src/documentEnvelopeCanonical.test.ts +++ b/src/documentEnvelopeCanonical.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DOCUMENT_ENVELOPE_SCHEMA_ID, DOCUMENT_ENVELOPE_SCHEMA_VERSION, @@ -11,6 +11,10 @@ import { } from './documentEnvelopeCanonical.js'; import * as publicApi from './index.js'; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('canonical document envelope serialization', () => { it('sorts object properties recursively while preserving array order', () => { const envelope = createDocumentEnvelope({ @@ -105,6 +109,80 @@ describe('canonical document envelope serialization', () => { expect([...bytes.slice(0, 3)]).not.toEqual([0xef, 0xbb, 0xbf]); }); + it('rejects a configured canonical output ceiling before UTF-8 allocation', () => { + const envelope = createDocumentEnvelope({ + type: 'doc', + attrs: { label: 'bounded-output' }, + }); + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + let failure: unknown; + + try { + encodeDocumentEnvelope(envelope, { maxUtf8Bytes: 16 }); + } catch (error) { + failure = error; + } + + expect(encode).not.toHaveBeenCalled(); + expect(failure).toBeInstanceOf(DocumentEnvelopeError); + expect(failure).toMatchObject({ + message: 'Canonical document envelope exceeds the configured UTF-8 byte limit', + }); + }); + + it('exact-checks UTF-8 bytes when code-unit length alone fits', () => { + const envelope = createDocumentEnvelope({ + type: 'doc', + attrs: { label: 'é' }, + }); + const serialized = serializeDocumentEnvelope(envelope); + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + + expect(() => + encodeDocumentEnvelope(envelope, { maxUtf8Bytes: serialized.length }), + ).toThrowError( + 'Canonical document envelope exceeds the configured UTF-8 byte limit', + ); + expect(encode).toHaveBeenCalledTimes(1); + }); + + it('accepts canonical bytes exactly at the configured output ceiling', () => { + const envelope = createDocumentEnvelope({ + type: 'doc', + attrs: { label: 'é' }, + }); + const serialized = serializeDocumentEnvelope(envelope); + const exactBytes = new TextEncoder().encode(serialized); + + expect( + encodeDocumentEnvelope(envelope, { maxUtf8Bytes: exactBytes.byteLength }), + ).toEqual(exactBytes); + }); + + it.each([ + ['wrong type', '16'], + ['fractional', 1.5], + ['zero', 0], + ])('fails closed for %s canonical output limits', (_label, maxUtf8Bytes) => { + const envelope = createDocumentEnvelope({ + type: 'doc', + attrs: { label: 'private-document' }, + }); + let failure: unknown; + + try { + encodeDocumentEnvelope(envelope, { maxUtf8Bytes } as never); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(DocumentEnvelopeError); + expect(failure).toMatchObject({ + message: 'Canonical document envelope UTF-8 byte limit must be a positive safe integer', + }); + expect(String(failure)).not.toContain('private-document'); + }); + it.each([ { type: 'doc', attrs: { value: '\ud800' } }, { type: 'doc', attrs: { value: '\udc00' } }, diff --git a/src/documentEnvelopeCanonical.ts b/src/documentEnvelopeCanonical.ts index c4dffea6..e9e98655 100644 --- a/src/documentEnvelopeCanonical.ts +++ b/src/documentEnvelopeCanonical.ts @@ -1,4 +1,5 @@ import { + DEFAULT_DOCUMENT_ENVELOPE_LIMITS, DocumentEnvelopeError, parseDocumentEnvelope, type CwlEditorDocumentEnvelope, @@ -16,10 +17,22 @@ type CanonicalJsonValue = | readonly CanonicalJsonValue[] | CanonicalJsonObject; +/** Resource options for canonical document-envelope byte encoding. */ +export interface DocumentEnvelopeEncodingOptions { + /** Maximum canonical UTF-8 bytes returned by the encoder. Defaults to 64 MiB. */ + readonly maxUtf8Bytes?: number; +} + const INVALID_UNICODE_MESSAGE = 'Document envelope must contain valid Unicode scalar strings'; const NEGATIVE_ZERO_MESSAGE = 'Document envelope must not contain negative zero'; +const INVALID_ENCODING_OPTIONS_MESSAGE = + 'Canonical document envelope encoding options are invalid'; +const INVALID_OUTPUT_LIMIT_MESSAGE = + 'Canonical document envelope UTF-8 byte limit must be a positive safe integer'; +const OUTPUT_LIMIT_EXCEEDED_MESSAGE = + 'Canonical document envelope exceeds the configured UTF-8 byte limit'; /** * Serialize a valid Inkspan envelope to deterministic RFC 8785 JSON. @@ -33,11 +46,15 @@ export function serializeDocumentEnvelope(source: unknown): string { return serializeValidatedDocumentEnvelope(parseDocumentEnvelope(source)); } -/** Encode a canonical Inkspan envelope as UTF-8 bytes without a BOM. */ +/** Encode a canonical Inkspan envelope as bounded UTF-8 bytes without a BOM. */ export function encodeDocumentEnvelope( source: unknown, + options: DocumentEnvelopeEncodingOptions = {}, ): Uint8Array { - return encodeValidatedDocumentEnvelope(parseDocumentEnvelope(source)); + return encodeValidatedDocumentEnvelope( + parseDocumentEnvelope(source), + options, + ); } /** @@ -57,10 +74,75 @@ export function serializeValidatedDocumentEnvelope( /** Encode an already-validated envelope without repeating graph validation. */ export function encodeValidatedDocumentEnvelope( envelope: CwlEditorDocumentEnvelope, + options: DocumentEnvelopeEncodingOptions = {}, ): Uint8Array { - return new TextEncoder().encode( - serializeValidatedDocumentEnvelope(envelope), - ); + const maxUtf8Bytes = resolveCanonicalOutputMaxBytes(options); + const serialized = serializeValidatedDocumentEnvelope(envelope); + + // Every valid UTF-8 encoding uses at least one byte per UTF-16 code unit. + // Reject the common/obvious oversize case before allocating encoded bytes. + if (serialized.length > maxUtf8Bytes) { + throw new DocumentEnvelopeError(OUTPUT_LIMIT_EXCEEDED_MESSAGE); + } + + const encoded = new TextEncoder().encode(serialized); + if (encoded.byteLength > maxUtf8Bytes) { + throw new DocumentEnvelopeError(OUTPUT_LIMIT_EXCEEDED_MESSAGE); + } + return encoded; +} + +function resolveCanonicalOutputMaxBytes( + options: DocumentEnvelopeEncodingOptions, +): number { + const configuredMaxUtf8Bytes = readCanonicalOutputMaxBytesOption(options); + if (configuredMaxUtf8Bytes === undefined) { + return DEFAULT_DOCUMENT_ENVELOPE_LIMITS.maxUtf8Bytes; + } + if ( + typeof configuredMaxUtf8Bytes !== 'number' || + !Number.isSafeInteger(configuredMaxUtf8Bytes) || + configuredMaxUtf8Bytes <= 0 + ) { + throw new DocumentEnvelopeError(INVALID_OUTPUT_LIMIT_MESSAGE); + } + return configuredMaxUtf8Bytes; +} + +function readCanonicalOutputMaxBytesOption( + options: DocumentEnvelopeEncodingOptions, +): unknown { + try { + if ( + typeof options !== 'object' || + options === null || + Array.isArray(options) + ) { + throw new TypeError('invalid encoding options container'); + } + + const prototype = Object.getPrototypeOf(options); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('invalid encoding options prototype'); + } + + const keys = Reflect.ownKeys(options); + if (keys.some((key) => key !== 'maxUtf8Bytes')) { + throw new TypeError('unsupported encoding option'); + } + if (keys.length === 0) return undefined; + + const descriptor = Object.getOwnPropertyDescriptor( + options, + 'maxUtf8Bytes', + ) as PropertyDescriptor; + if (!descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError('invalid encoding option property'); + } + return descriptor.value as unknown; + } catch { + throw new DocumentEnvelopeError(INVALID_ENCODING_OPTIONS_MESSAGE); + } } function serializeCanonicalValue(value: CanonicalJsonValue): string { diff --git a/src/documentEnvelopeEncodingConsolidation.test.ts b/src/documentEnvelopeEncodingConsolidation.test.ts new file mode 100644 index 00000000..577ead63 --- /dev/null +++ b/src/documentEnvelopeEncodingConsolidation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DocumentEnvelopeError, + createDocumentEnvelope, +} from './documentEnvelope.js'; +import { encodeDocumentEnvelope } from './documentEnvelopeCanonical.js'; + +const ENVELOPE = createDocumentEnvelope({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Canonical output boundary' }], + }, + ], +}); + +type RuntimeEncodingOptions = { + readonly maxUtf8Bytes?: number; +}; + +const encodeWithOptions = encodeDocumentEnvelope as unknown as ( + source: unknown, + options?: RuntimeEncodingOptions, +) => Uint8Array; + +describe('canonical envelope encoding consolidation', () => { + it('rejects an impossible output ceiling before UTF-8 allocation', () => { + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + + expect(() => + encodeWithOptions(ENVELOPE, { maxUtf8Bytes: 1 }), + ).toThrowError( + new DocumentEnvelopeError( + 'Canonical document envelope exceeds the configured UTF-8 byte limit', + ), + ); + expect(encode).not.toHaveBeenCalled(); + }); + + it('rejects unknown runtime option keys instead of silently defaulting', () => { + expect(() => + encodeWithOptions( + ENVELOPE, + { maxUTF8Bytes: 1024 } as unknown as RuntimeEncodingOptions, + ), + ).toThrowError( + new DocumentEnvelopeError( + 'Canonical document envelope encoding options are invalid', + ), + ); + }); + + it('rejects accessor-backed options without evaluating the accessor', () => { + let getterCalls = 0; + const options = {}; + Object.defineProperty(options, 'maxUtf8Bytes', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('private option getter detail'); + }, + }); + + expect(() => + encodeWithOptions(ENVELOPE, options as RuntimeEncodingOptions), + ).toThrowError( + new DocumentEnvelopeError( + 'Canonical document envelope encoding options are invalid', + ), + ); + expect(getterCalls).toBe(0); + }); +}); diff --git a/src/documentEnvelopeEncodingOptionsRuntime.test.ts b/src/documentEnvelopeEncodingOptionsRuntime.test.ts new file mode 100644 index 00000000..ffd7ca76 --- /dev/null +++ b/src/documentEnvelopeEncodingOptionsRuntime.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + DocumentEnvelopeError, + createDocumentEnvelope, +} from './documentEnvelope.js'; +import { + encodeDocumentEnvelope, + type DocumentEnvelopeEncodingOptions, +} from './documentEnvelopeCanonical.js'; + +const ENVELOPE = createDocumentEnvelope({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Canonical option boundary' }], + }, + ], +}); +const INVALID_OPTIONS_MESSAGE = + 'Canonical document envelope encoding options are invalid'; + +function expectInvalidOptions(options: unknown): void { + expect(() => + encodeDocumentEnvelope( + ENVELOPE, + options as DocumentEnvelopeEncodingOptions, + ), + ).toThrowError(new DocumentEnvelopeError(INVALID_OPTIONS_MESSAGE)); +} + +describe('document envelope encoding option runtime boundary', () => { + it('rejects malformed option containers through one redacted error', () => { + expectInvalidOptions(null); + expectInvalidOptions(7); + expectInvalidOptions([]); + }); + + it('rejects exotic object prototypes instead of treating them as empty options', () => { + expectInvalidOptions(new Date(0)); + + class HostOptions { + maxUtf8Bytes = 1024; + } + expectInvalidOptions(new HostOptions()); + }); + + it('rejects unknown string and symbol keys instead of silently defaulting', () => { + expectInvalidOptions({ maxUTF8Bytes: 1024 }); + expectInvalidOptions({ [Symbol('private option')]: 1024 }); + }); + + it('rejects accessor and non-enumerable option properties without reading them', () => { + let getterCalls = 0; + const accessorOptions = {}; + Object.defineProperty(accessorOptions, 'maxUtf8Bytes', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('private option getter detail'); + }, + }); + expectInvalidOptions(accessorOptions); + expect(getterCalls).toBe(0); + + const hiddenOptions = {}; + Object.defineProperty(hiddenOptions, 'maxUtf8Bytes', { + enumerable: false, + value: 1024, + }); + expectInvalidOptions(hiddenOptions); + }); + + it('redacts option reflection failures before serialization', () => { + const hostileOptions = new Proxy( + {}, + { + ownKeys() { + throw new Error('private reflection detail'); + }, + }, + ); + + expectInvalidOptions(hostileOptions); + }); + + it('preserves omitted, empty, exact data-property, and null-prototype options', () => { + expect(encodeDocumentEnvelope(ENVELOPE).byteLength).toBeGreaterThan(0); + expect(encodeDocumentEnvelope(ENVELOPE, {}).byteLength).toBeGreaterThan(0); + expect( + encodeDocumentEnvelope(ENVELOPE, { + maxUtf8Bytes: 1024, + }).byteLength, + ).toBeGreaterThan(0); + + const nullPrototypeOptions = Object.create(null) as Record< + string, + unknown + >; + Object.defineProperty(nullPrototypeOptions, 'maxUtf8Bytes', { + enumerable: true, + value: 1024, + }); + expect( + encodeDocumentEnvelope( + ENVELOPE, + nullPrototypeOptions as DocumentEnvelopeEncodingOptions, + ).byteLength, + ).toBeGreaterThan(0); + }); +}); diff --git a/src/extensions/SafeLink.test.ts b/src/extensions/SafeLink.test.ts index 845d723a..e78673b8 100644 --- a/src/extensions/SafeLink.test.ts +++ b/src/extensions/SafeLink.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { Editor } from '@tiptap/react'; import { buildExtensions } from './kit.js'; import { @@ -23,6 +23,7 @@ function makeEditor(content = '

alpha

omega

'): Editor { } afterEach(() => { + vi.restoreAllMocks(); for (const editor of openEditors.splice(0)) { if (!editor.isDestroyed) editor.destroy(); } @@ -46,6 +47,148 @@ describe('validateSafeLinkHref', () => { expect(isSafeLinkHref(href)).toBe(true); }); + it('rejects obvious oversize before UTF-8 allocation and URL parsing', () => { + const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode'); + const href = 'https://example.com/path'; + + let error: unknown; + try { + validateSafeLinkHref(href, { maxHrefBytes: 8 }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(SafeLinkHrefError); + expect(error).toMatchObject({ + code: 'input_too_large', + hrefPreview: '', + }); + expect(String(error)).not.toContain('example.com'); + expect(encodeSpy).not.toHaveBeenCalled(); + expect(isSafeLinkHref(href, { maxHrefBytes: 8 })).toBe(false); + }); + + it('enforces the default bound before allocating an oversized UTF-8 copy', () => { + const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode'); + const href = `https://example.com/${'a'.repeat(65_536)}`; + + expect(() => validateSafeLinkHref(href)).toThrow(SafeLinkHrefError); + expect(encodeSpy).not.toHaveBeenCalled(); + }); + + it('enforces exact UTF-8 byte counts when code-unit length alone can fit', () => { + expect(() => + validateSafeLinkHref('/é', { maxHrefBytes: 2 }), + ).toThrow(SafeLinkHrefError); + expect(validateSafeLinkHref('/é', { maxHrefBytes: 3 })).toBe('/é'); + }); + + it('accepts omitted configured limits through explicit options objects', () => { + expect(validateSafeLinkHref('/safe', {})).toBe('/safe'); + expect(validateSafeLinkHref('/safe', { maxHrefBytes: undefined })).toBe( + '/safe', + ); + }); + + it.each([ + null, + 8, + [], + { maxHrefBytes: '8' }, + { maxHrefBytes: 1.5 }, + { maxHrefBytes: 0 }, + { maxHrefBytes: 1_048_577 }, + ])('fails closed for invalid resource configuration %#', (options) => { + let error: unknown; + try { + validateSafeLinkHref('/private/path?token=secret', options as never); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(SafeLinkHrefError); + expect(error).toMatchObject({ + code: 'invalid_configuration', + hrefPreview: '', + }); + expect(String(error)).not.toContain('private/path'); + }); + + it('rejects accessor-backed resource configuration without evaluating the accessor', () => { + const getter = vi.fn(() => 8); + const options = Object.defineProperty({}, 'maxHrefBytes', { + enumerable: true, + get: getter, + }); + + let error: unknown; + try { + validateSafeLinkHref('/private/path?token=secret', options as never); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(SafeLinkHrefError); + expect(error).toMatchObject({ + code: 'invalid_configuration', + hrefPreview: '', + }); + expect(String(error)).not.toContain('private/path'); + expect(getter).not.toHaveBeenCalled(); + }); + + it('rejects unknown string and symbol configuration keys before reading values', () => { + const getter = vi.fn(() => 8); + const unknownStringKey = Object.defineProperty({}, 'unexpected', { + enumerable: true, + get: getter, + }); + const unknownSymbolKey = { [Symbol('maxHrefBytes')]: 8 }; + + for (const options of [unknownStringKey, unknownSymbolKey]) { + let error: unknown; + try { + validateSafeLinkHref('/private/path?token=secret', options as never); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(SafeLinkHrefError); + expect(error).toMatchObject({ + code: 'invalid_configuration', + hrefPreview: '', + }); + expect(String(error)).not.toContain('private/path'); + } + expect(getter).not.toHaveBeenCalled(); + }); + + it('converts reflection failures into payload-redacted configuration errors', () => { + const options = new Proxy( + {}, + { + ownKeys() { + throw new Error('proxy-owned secret'); + }, + }, + ); + + let error: unknown; + try { + validateSafeLinkHref('/private/path?token=secret', options as never); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(SafeLinkHrefError); + expect(error).toMatchObject({ + code: 'invalid_configuration', + hrefPreview: '', + }); + expect(String(error)).not.toContain('proxy-owned secret'); + expect(String(error)).not.toContain('private/path'); + }); + it.each([ null, 42, @@ -68,6 +211,7 @@ describe('validateSafeLinkHref', () => { 'https://', 'https://user:secret@example.com/path', 'http://user@example.com/path', + 'https://:secret@example.com/path', ])('rejects unsafe link target %s', (href) => { expect(() => validateSafeLinkHref(href)).toThrow(SafeLinkHrefError); expect(isSafeLinkHref(href)).toBe(false); @@ -80,8 +224,17 @@ describe('validateSafeLinkHref', () => { const fragment = new SafeLinkHrefError('#private-section'); const scheme = new SafeLinkHrefError('JAVASCRIPT:secret()'); const relative = new SafeLinkHrefError('private/path?token=secret'); + const oversized = new SafeLinkHrefError( + 'https://secret.example/token', + 'input_too_large', + ); + const invalidConfiguration = new SafeLinkHrefError( + 'https://secret.example/token', + 'invalid_configuration', + ); expect(nonString.name).toBe('SafeLinkHrefError'); + expect(nonString.code).toBe('invalid_href'); expect(nonString.hrefPreview).toBe(''); expect(empty.hrefPreview).toBe(''); expect(protocolRelative.hrefPreview).toBe('//'); @@ -89,6 +242,10 @@ describe('validateSafeLinkHref', () => { expect(scheme.hrefPreview).toBe('javascript:'); expect(relative.hrefPreview).toBe(''); expect(relative.message).not.toContain('private/path'); + expect(oversized.hrefPreview).toBe(''); + expect(oversized.message).not.toContain('secret.example'); + expect(invalidConfiguration.hrefPreview).toBe(''); + expect(invalidConfiguration.message).not.toContain('secret.example'); }); }); diff --git a/src/extensions/SafeLink.ts b/src/extensions/SafeLink.ts index 2af0cbfd..0ae2a520 100644 --- a/src/extensions/SafeLink.ts +++ b/src/extensions/SafeLink.ts @@ -18,10 +18,16 @@ import { } from '../policy/safeLinkPolicy.js'; export { + DEFAULT_SAFE_LINK_MAX_HREF_BYTES, + MAXIMUM_SAFE_LINK_MAX_HREF_BYTES, SafeLinkHrefError, isSafeLinkHref, validateSafeLinkHref, } from '../policy/safeLinkPolicy.js'; +export type { + SafeLinkHrefErrorCode, + SafeLinkValidationOptions, +} from '../policy/safeLinkPolicy.js'; /** ProseMirror plugin key for the direct-transaction safety boundary. */ export const safeLinkPluginKey = new PluginKey('cwlSafeLink'); diff --git a/src/extensions/SafeLinkConsolidation.test.ts b/src/extensions/SafeLinkConsolidation.test.ts new file mode 100644 index 00000000..dd1a285b --- /dev/null +++ b/src/extensions/SafeLinkConsolidation.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + SafeLinkHrefError, + validateSafeLinkHref, +} from './SafeLink.js'; + +type RuntimeSafeLinkOptions = { + readonly maxHrefBytes?: number; +}; + +const validateWithOptions = validateSafeLinkHref as unknown as ( + href: unknown, + options?: RuntimeSafeLinkOptions, +) => string; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('safe-link resource consolidation', () => { + it('rejects an obviously oversized web target before URL construction', () => { + const urlConstructor = vi.fn(() => { + throw new Error('URL parser must not run for an obvious oversize target'); + }); + vi.stubGlobal('URL', urlConstructor); + + let failure: unknown; + try { + validateWithOptions('https://example.com/path', { maxHrefBytes: 8 }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(SafeLinkHrefError); + expect(failure).toMatchObject({ + code: 'input_too_large', + hrefPreview: '', + }); + expect(urlConstructor).not.toHaveBeenCalled(); + }); + + it('rejects unknown runtime option keys instead of silently defaulting', () => { + expect(() => + validateWithOptions( + 'https://example.com/', + { maxHREFBytes: 8 } as unknown as RuntimeSafeLinkOptions, + ), + ).toThrowError( + expect.objectContaining({ + code: 'invalid_configuration', + hrefPreview: '', + }), + ); + }); + + it('rejects accessor-backed options without evaluating the accessor', () => { + let getterCalls = 0; + const options = {}; + Object.defineProperty(options, 'maxHrefBytes', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('private option getter detail'); + }, + }); + + expect(() => + validateWithOptions( + 'https://example.com/', + options as RuntimeSafeLinkOptions, + ), + ).toThrowError( + expect.objectContaining({ + code: 'invalid_configuration', + hrefPreview: '', + }), + ); + expect(getterCalls).toBe(0); + }); +}); diff --git a/src/index.test.ts b/src/index.test.ts index 506096ad..c03980f9 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -47,6 +47,12 @@ describe('package entry point', () => { expect(typeof api.markdownToEmailHtml).toBe('function'); expect(typeof api.markdownToPlainText).toBe('function'); expect(typeof api.htmlToPlainText).toBe('function'); + expect(api.DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES).toBe(16_777_216); + expect(api.MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES).toBe(67_108_864); + expect(typeof api.HtmlToMarkdownResourceError).toBe('function'); + expect(api.DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES).toBe(16_777_216); + expect(api.MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES).toBe(67_108_864); + expect(typeof api.MarkdownToHtmlResourceError).toBe('function'); }); it('re-exports the standalone base64 converter', () => { diff --git a/src/index.ts b/src/index.ts index f7905449..042afc2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ export { encodeDocumentEnvelope, serializeDocumentEnvelope, } from './documentEnvelopeCanonical.js'; +export type { DocumentEnvelopeEncodingOptions } from './documentEnvelopeCanonical.js'; export { restoreDocumentEnvelopeBytesIfMatch, restoreDocumentEnvelopeIfMatch, @@ -138,26 +139,46 @@ export { } from './extensions/SafeClipboardExtension.js'; export type { SafeClipboardOptions } from './extensions/SafeClipboardExtension.js'; export { + DEFAULT_SAFE_LINK_MAX_HREF_BYTES, + MAXIMUM_SAFE_LINK_MAX_HREF_BYTES, SafeLink, SafeLinkHrefError, isSafeLinkHref, safeLinkPluginKey, validateSafeLinkHref, } from './extensions/SafeLink.js'; +export type { + SafeLinkHrefErrorCode, + SafeLinkValidationOptions, +} from './extensions/SafeLink.js'; export { buildExtensions } from './extensions/kit.js'; export type { BuildExtensionsOptions } from './extensions/kit.js'; // Markdown <-> HTML serialization (base64 image round-trip safe). +export { htmlToMarkdown } from './markdown/serializer.js'; +export type { HtmlToMarkdownOptions } from './markdown/serializer.js'; export { + markdownToEmailHtml, markdownToHtml, - htmlToMarkdown, normalizeMarkdown, - markdownToEmailHtml, -} from './markdown/serializer.js'; +} from './markdown/resourceBoundMarkdown.js'; export type { - HtmlToMarkdownOptions, MarkdownToEmailHtmlOptions, -} from './markdown/serializer.js'; + MarkdownToHtmlOptions, + NormalizeMarkdownOptions, +} from './markdown/resourceBoundMarkdown.js'; +export { + DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, + MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, + HtmlToMarkdownResourceError, +} from './markdown/htmlToMarkdownResourcePolicy.js'; +export type { HtmlToMarkdownResourceErrorCode } from './markdown/htmlToMarkdownResourcePolicy.js'; +export { + DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, + MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, + MarkdownToHtmlResourceError, +} from './markdown/markdownToHtmlResourcePolicy.js'; +export type { MarkdownToHtmlResourceErrorCode } from './markdown/markdownToHtmlResourcePolicy.js'; // Deterministic Markdown/HTML -> plain-text projection for AI/indexing paths. export { diff --git a/src/markdown/emailFullDocumentRuntime.test.ts b/src/markdown/emailFullDocumentRuntime.test.ts new file mode 100644 index 00000000..537e1f4f --- /dev/null +++ b/src/markdown/emailFullDocumentRuntime.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Lexer } from 'marked'; +import { markdownToEmailHtml } from './resourceBoundMarkdown.js'; + +const INVALID_FULL_DOCUMENT_MESSAGE = + 'Email document fullDocument must be a boolean when provided.'; +const INVALID_TEXT_DIRECTION_MESSAGE = + 'Email document direction must be ltr, rtl, or auto.'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('email full-document runtime contract', () => { + it.each([ + ['truthy string', 'true'], + ['falsey number', 0], + ['null', null], + ])('rejects a %s instead of coercing document representation', (_label, value) => { + expect(() => + markdownToEmailHtml('Hello', { + fullDocument: value as unknown as boolean, + }), + ).toThrowError(new RangeError(INVALID_FULL_DOCUMENT_MESSAGE)); + }); + + it('rejects invalid text direction before Markdown parser materialization', () => { + const lex = vi.spyOn(Lexer, 'lex'); + + expect(() => + markdownToEmailHtml('Hello', { + fullDocument: true, + textDirection: 'sideways' as never, + }), + ).toThrowError(new RangeError(INVALID_TEXT_DIRECTION_MESSAGE)); + expect(lex).not.toHaveBeenCalled(); + }); + + it('preserves omitted, fragment, and full-document boolean modes', () => { + expect(markdownToEmailHtml('Hello')).toBe('

Hello

'); + expect(markdownToEmailHtml('Hello', { fullDocument: false })).toBe( + '

Hello

', + ); + expect(markdownToEmailHtml('Hello', { fullDocument: true })).toContain( + '', + ); + }); +}); \ No newline at end of file diff --git a/src/markdown/emailLanguageResourceBoundary.test.ts b/src/markdown/emailLanguageResourceBoundary.test.ts new file mode 100644 index 00000000..dd16ed9b --- /dev/null +++ b/src/markdown/emailLanguageResourceBoundary.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + markdownToEmailHtml, + type MarkdownToEmailHtmlOptions, +} from './resourceBoundMarkdown.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('email document language resource boundary', () => { + it('rejects an obviously oversized language tag before Intl canonicalization', () => { + const canonicalize = vi.spyOn(Intl, 'getCanonicalLocales'); + + expect(() => + markdownToEmailHtml('bounded metadata', { + fullDocument: true, + languageTag: 'a'.repeat(257), + }), + ).toThrow(RangeError); + expect(canonicalize).not.toHaveBeenCalled(); + }); + + it('fails closed with the stable RangeError contract for non-string runtime metadata', () => { + const languageTag = 42 as unknown as MarkdownToEmailHtmlOptions['languageTag']; + + expect(() => + markdownToEmailHtml('runtime metadata', { + fullDocument: true, + languageTag, + }), + ).toThrow(RangeError); + }); + + it('preserves omitted full-document language metadata without Intl work', () => { + const canonicalize = vi.spyOn(Intl, 'getCanonicalLocales'); + + const html = markdownToEmailHtml('no language metadata', { + fullDocument: true, + }); + + expect(html).toContain(''); + expect(canonicalize).not.toHaveBeenCalled(); + }); +}); diff --git a/src/markdown/emailTitleResourceBoundary.test.ts b/src/markdown/emailTitleResourceBoundary.test.ts new file mode 100644 index 00000000..bf19e13d --- /dev/null +++ b/src/markdown/emailTitleResourceBoundary.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + markdownToEmailHtml, + type MarkdownToEmailHtmlOptions, +} from './resourceBoundMarkdown.js'; + +const EMAIL_TITLE_MAX_CODE_UNITS = 65_536; +const INVALID_EMAIL_TITLE_MESSAGE = + 'Email document title must be a string within the supported length.'; + +describe('email document title resource boundary', () => { + it('rejects an oversized full-document title with a stable redacted error', () => { + const privateMarker = 'customer-case-private-marker'; + const title = `${privateMarker}${'x'.repeat(EMAIL_TITLE_MAX_CODE_UNITS)}`; + let failure: unknown; + + try { + markdownToEmailHtml('bounded body', { + fullDocument: true, + title, + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(RangeError); + expect(failure).toMatchObject({ message: INVALID_EMAIL_TITLE_MESSAGE }); + expect(String(failure)).not.toContain(privateMarker); + }); + + it('rejects non-string full-document title metadata through the same contract', () => { + const title = 42 as unknown as MarkdownToEmailHtmlOptions['title']; + + expect(() => + markdownToEmailHtml('runtime metadata', { + fullDocument: true, + title, + }), + ).toThrowError(new RangeError(INVALID_EMAIL_TITLE_MESSAGE)); + }); + + it('accepts a title exactly at the local metadata ceiling', () => { + const title = 'x'.repeat(EMAIL_TITLE_MAX_CODE_UNITS); + + const html = markdownToEmailHtml('', { + fullDocument: true, + title, + }); + + expect(html).toContain(`${title}`); + }); + + it('preserves fragment mode where title metadata is not consumed', () => { + const html = markdownToEmailHtml('fragment body', { + title: 'x'.repeat(EMAIL_TITLE_MAX_CODE_UNITS + 1), + }); + + expect(html).toContain('

fragment body

'); + expect(html).not.toContain(''); + }); +}); diff --git a/src/markdown/htmlToMarkdownDomAllocation.test.ts b/src/markdown/htmlToMarkdownDomAllocation.test.ts new file mode 100644 index 00000000..ab8775f9 --- /dev/null +++ b/src/markdown/htmlToMarkdownDomAllocation.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { htmlToMarkdown } from './serializer.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('HTML-to-Markdown DOM allocation', () => { + it('sanitizes accepted HTML without copying the complete element NodeList', () => { + const arrayFrom = vi.spyOn(Array, 'from'); + + expect( + htmlToMarkdown('<p>Alpha <strong>Beta</strong></p>', { + maxHtmlBytes: 1024, + }), + ).toBe('Alpha **Beta**'); + + const copiedElementNodeList = arrayFrom.mock.calls.some(([value]) => + Object.prototype.toString.call(value) === '[object NodeList]', + ); + expect(copiedElementNodeList).toBe(false); + }); +}); diff --git a/src/markdown/htmlToMarkdownOptionBagRuntime.test.ts b/src/markdown/htmlToMarkdownOptionBagRuntime.test.ts new file mode 100644 index 00000000..64defe6c --- /dev/null +++ b/src/markdown/htmlToMarkdownOptionBagRuntime.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest'; +import { htmlToMarkdown, type HtmlToMarkdownOptions } from './serializer.js'; + +const INVALID_CONFIGURATION = { + name: 'HtmlToMarkdownResourceError', + code: 'invalid_configuration', + message: 'HTML-to-Markdown resource configuration is invalid.', +}; + +const INVALID_INPUT = { + name: 'HtmlToMarkdownResourceError', + code: 'invalid_input', + message: 'HTML-to-Markdown input must be a string.', +}; + +describe('HTML-to-Markdown runtime option bag boundary', () => { + it.each([ + ['primitive', 42], + ['null', null], + ['array', []], + ['custom prototype', Object.create({ inherited: true })], + ['unknown key', { unexpectedPolicy: true }], + ['non-boolean image-alt mode', { includeImageAlt: 'false' }], + ['symbol key', { [Symbol('policy')]: true }], + [ + 'non-enumerable option', + Object.defineProperty({}, 'maxHtmlBytes', { + enumerable: false, + value: 1024, + }), + ], + ])('rejects %s option bags through the stable resource error', (_label, options) => { + expect(() => + htmlToMarkdown('<p>safe</p>', options as HtmlToMarkdownOptions), + ).toThrowError(expect.objectContaining(INVALID_CONFIGURATION)); + }); + + it('rejects accessor-backed options without executing caller code or parsing HTML', () => { + const privateFailure = new Error('private-html-option-sentinel'); + const maxHtmlBytes = vi.fn(() => { + throw privateFailure; + }); + const createElement = vi.spyOn(document, 'createElement'); + const options = Object.defineProperty({}, 'maxHtmlBytes', { + enumerable: true, + get: maxHtmlBytes, + }) as HtmlToMarkdownOptions; + let failure: unknown; + + try { + htmlToMarkdown('<p>private body</p>', options); + } catch (error) { + failure = error; + } + + expect(maxHtmlBytes).not.toHaveBeenCalled(); + expect(createElement).not.toHaveBeenCalledWith('template'); + expect(failure).toMatchObject(INVALID_CONFIGURATION); + expect(String(failure)).not.toContain(privateFailure.message); + }); + + it('normalizes an executed hostile reflection trap without leaking caller failures', () => { + const privateFailure = new Error('private-reflection-sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateFailure; + }); + const options = new Proxy({}, { getPrototypeOf }) as HtmlToMarkdownOptions; + let failure: unknown; + + try { + htmlToMarkdown('<p>private body</p>', options); + } catch (error) { + failure = error; + } + + expect(getPrototypeOf).toHaveBeenCalledOnce(); + expect(failure).toMatchObject(INVALID_CONFIGURATION); + expect(String(failure)).not.toContain(privateFailure.message); + }); + + it('rejects hostile non-string HTML before caller access, encoding, or parser work', () => { + const privateFailure = new Error('private-html-input-sentinel'); + const inputAccess = vi.fn(() => { + throw privateFailure; + }); + const html = new Proxy({}, { get: inputAccess }); + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + const createElement = vi.spyOn(document, 'createElement'); + let failure: unknown; + + try { + htmlToMarkdown(html as never); + } catch (error) { + failure = error; + } + + expect(inputAccess).not.toHaveBeenCalled(); + expect(encode).not.toHaveBeenCalled(); + expect(createElement).not.toHaveBeenCalledWith('template'); + expect(failure).toMatchObject(INVALID_INPUT); + expect(String(failure)).not.toContain(privateFailure.message); + }); + + it('preserves null-prototype data option bags and accepted conversion behavior', () => { + const options = Object.assign(Object.create(null), { + includeImageAlt: false, + maxHtmlBytes: 1024, + }) as HtmlToMarkdownOptions; + + expect( + htmlToMarkdown( + '<p><img alt="private alt" src="https://example.test/a.png">text</p>', + options, + ), + ).toBe('text'); + }); +}); diff --git a/src/markdown/htmlToMarkdownResourcePolicy.ts b/src/markdown/htmlToMarkdownResourcePolicy.ts new file mode 100644 index 00000000..807d4977 --- /dev/null +++ b/src/markdown/htmlToMarkdownResourcePolicy.ts @@ -0,0 +1,67 @@ +/** 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_input' + | 'invalid_configuration'; + +const ERROR_MESSAGES: Readonly<Record<HtmlToMarkdownResourceErrorCode, string>> = + Object.freeze({ + input_too_large: + 'HTML-to-Markdown input exceeds the configured byte limit.', + invalid_input: 'HTML-to-Markdown input must be a string.', + 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 invalid or oversized HTML before any parser/DOM materialization. */ +export function assertHtmlToMarkdownInputSize( + html: string, + maxHtmlBytes: number, +): void { + if (typeof html !== 'string') { + throw new HtmlToMarkdownResourceError('invalid_input'); + } + + // 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'); + } +} diff --git a/src/markdown/markdownInputResourceBounds.test.ts b/src/markdown/markdownInputResourceBounds.test.ts new file mode 100644 index 00000000..75801b22 --- /dev/null +++ b/src/markdown/markdownInputResourceBounds.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Lexer } from 'marked'; +import { + markdownToEditorHtml, + markdownToEmailHtml, + markdownToHtml, + normalizeMarkdown, +} from './resourceBoundMarkdown.js'; +import { + DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, + MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, +} from './markdownToHtmlResourcePolicy.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Markdown parser resource bounds', () => { + it('uses the owned default ceiling without changing accepted Markdown', () => { + expect(markdownToHtml('plain')).toBe('<p>plain</p>\n'); + }); + + it('rejects configured oversized Markdown before encoding or Marked lexer materialization', () => { + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + const lex = vi.spyOn(Lexer, 'lex'); + let failure: unknown; + + try { + markdownToHtml('12345', { maxMarkdownBytes: 4 }); + } catch (error) { + failure = error; + } + + expect(encode).not.toHaveBeenCalled(); + expect(lex).not.toHaveBeenCalled(); + expect(failure).toMatchObject({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + message: 'Markdown-to-HTML input exceeds the configured byte limit.', + }); + }); + + it('uses exact UTF-8 bytes when code-unit length alone does not prove oversize', () => { + const lex = vi.spyOn(Lexer, 'lex'); + + expect(() => markdownToHtml('é', { maxMarkdownBytes: 1 })).toThrowError( + expect.objectContaining({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + }), + ); + expect(lex).not.toHaveBeenCalled(); + }); + + it('accepts input exactly at the configured UTF-8 ceiling', () => { + expect(markdownToHtml('é', { maxMarkdownBytes: 2 })).toBe('<p>é</p>\n'); + }); + + it.each([ + ['wrong type', '4'], + ['fractional', 1.5], + ['zero', 0], + ['above hard maximum', MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES + 1], + ])('fails closed for %s resource configuration', (_label, maxMarkdownBytes) => { + const secret = 'private-markdown'; + let failure: unknown; + + try { + markdownToHtml(secret, { maxMarkdownBytes } as never); + } catch (error) { + failure = error; + } + + expect(failure).toMatchObject({ + name: 'MarkdownToHtmlResourceError', + code: 'invalid_configuration', + message: 'Markdown-to-HTML resource configuration is invalid.', + }); + expect(String(failure)).not.toContain(secret); + }); + + it('accepts the hard maximum without changing ordinary conversion semantics', () => { + expect( + markdownToHtml('**safe**', { + maxMarkdownBytes: MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, + }), + ).toBe('<p><strong>safe</strong></p>\n'); + }); + + it('applies the owned default ceiling to editor Markdown ingress', () => { + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + const lex = vi.spyOn(Lexer, 'lex'); + const oversized = 'x'.repeat(DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES + 1); + + expect(() => markdownToEditorHtml(oversized)).toThrowError( + expect.objectContaining({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + }), + ); + expect(encode).not.toHaveBeenCalled(); + expect(lex).not.toHaveBeenCalled(); + }); + + it('honors a caller ceiling for Markdown normalization before Marked materialization', () => { + const lex = vi.spyOn(Lexer, 'lex'); + const normalizeWithOptions = normalizeMarkdown as unknown as ( + markdown: string, + options: { maxMarkdownBytes: number }, + ) => string; + let failure: unknown; + + try { + normalizeWithOptions('12345', { maxMarkdownBytes: 4 }); + } catch (error) { + failure = error; + } + + expect(lex).not.toHaveBeenCalled(); + expect(failure).toMatchObject({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + message: 'Markdown-to-HTML input exceeds the configured byte limit.', + }); + }); + + it('uses exact caller UTF-8 ceilings for Markdown normalization', () => { + const lex = vi.spyOn(Lexer, 'lex'); + const normalizeWithOptions = normalizeMarkdown as unknown as ( + markdown: string, + options: { maxMarkdownBytes: number }, + ) => string; + + expect(() => + normalizeWithOptions('é', { maxMarkdownBytes: 1 }), + ).toThrowError( + expect.objectContaining({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + }), + ); + expect(lex).not.toHaveBeenCalled(); + }); + + it('honors a caller ceiling for email conversion before Marked materialization', () => { + const lex = vi.spyOn(Lexer, 'lex'); + let failure: unknown; + + try { + markdownToEmailHtml('12345', { maxMarkdownBytes: 4 } as never); + } catch (error) { + failure = error; + } + + expect(lex).not.toHaveBeenCalled(); + expect(failure).toMatchObject({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + message: 'Markdown-to-HTML input exceeds the configured byte limit.', + }); + }); + + it('uses exact caller UTF-8 ceilings for email conversion', () => { + const lex = vi.spyOn(Lexer, 'lex'); + + expect(() => + markdownToEmailHtml('é', { maxMarkdownBytes: 1 } as never), + ).toThrowError( + expect.objectContaining({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + }), + ); + expect(lex).not.toHaveBeenCalled(); + }); +}); diff --git a/src/markdown/markdownOptionBagsRuntime.test.ts b/src/markdown/markdownOptionBagsRuntime.test.ts new file mode 100644 index 00000000..0b0b36ac --- /dev/null +++ b/src/markdown/markdownOptionBagsRuntime.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + markdownToEmailHtml, + markdownToHtml, + normalizeMarkdown, +} from './resourceBoundMarkdown.js'; + +const INVALID_CONFIGURATION = { + name: 'MarkdownToHtmlResourceError', + code: 'invalid_configuration', + message: 'Markdown-to-HTML resource configuration is invalid.', +}; + +type RuntimeAdapter = (markdown: string, options?: unknown) => string; + +const adapters: ReadonlyArray<readonly [string, RuntimeAdapter]> = [ + ['HTML conversion', markdownToHtml as unknown as RuntimeAdapter], + ['normalization', normalizeMarkdown as unknown as RuntimeAdapter], + ['email conversion', markdownToEmailHtml as unknown as RuntimeAdapter], +]; + +function expectInvalidConfiguration(run: () => unknown): void { + let failure: unknown; + try { + run(); + } catch (error) { + failure = error; + } + + expect(failure).toMatchObject(INVALID_CONFIGURATION); +} + +describe('Markdown public option-bag runtime contracts', () => { + it.each(adapters)('rejects malformed containers for %s', (_label, convert) => { + for (const options of [null, 1, 'options', [], new Date(0)]) { + expectInvalidConfiguration(() => convert('hello', options)); + } + }); + + it.each(adapters)('rejects unknown string and symbol keys for %s', (_label, convert) => { + expectInvalidConfiguration(() => convert('hello', { maxMarkdownByte: 1 })); + expectInvalidConfiguration(() => convert('hello', { [Symbol('option')]: true })); + }); + + it.each(adapters)('rejects accessors without evaluating them for %s', (_label, convert) => { + let getterCalled = false; + const options = Object.create(null) as Record<string, unknown>; + Object.defineProperty(options, 'maxMarkdownBytes', { + enumerable: true, + configurable: true, + get() { + getterCalled = true; + throw new Error('getter executed'); + }, + }); + + expectInvalidConfiguration(() => convert('hello', options)); + expect(getterCalled).toBe(false); + }); + + it.each(adapters)('rejects non-enumerable own properties for %s', (_label, convert) => { + const options = Object.create(null) as Record<string, unknown>; + Object.defineProperty(options, 'maxMarkdownBytes', { + enumerable: false, + configurable: true, + writable: true, + value: 1024, + }); + + expectInvalidConfiguration(() => convert('hello', options)); + }); + + it.each(adapters)('accepts null-prototype bags for %s', (_label, convert) => { + const options = Object.create(null) as Record<string, unknown>; + options.maxMarkdownBytes = 1024; + + expect(convert('hello', options)).toEqual(expect.any(String)); + }); + + it('keeps the email option vocabulary exact', () => { + const options = Object.create(null) as Record<string, unknown>; + options.maxMarkdownBytes = 1024; + options.fullDocument = true; + options.title = 'Title'; + options.languageTag = 'en'; + options.textDirection = 'ltr'; + + expect(markdownToEmailHtml('hello', options as never)).toContain('<!DOCTYPE html>'); + expectInvalidConfiguration(() => + markdownToEmailHtml('hello', { + maxMarkdownBytes: 1024, + unexpected: true, + } as never), + ); + }); +}); diff --git a/src/markdown/markdownSourceRuntime.test.ts b/src/markdown/markdownSourceRuntime.test.ts new file mode 100644 index 00000000..e5642169 --- /dev/null +++ b/src/markdown/markdownSourceRuntime.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Lexer } from 'marked'; +import { + markdownToEditorHtml, + markdownToEmailHtml, + markdownToHtml, + normalizeMarkdown, +} from './resourceBoundMarkdown.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Markdown source runtime contract', () => { + it.each([ + ['HTML conversion', markdownToHtml], + ['normalization', normalizeMarkdown], + ['email conversion', markdownToEmailHtml], + ['editor ingress', markdownToEditorHtml], + ])('rejects non-string input before caller code or parser work for %s', (_label, convert) => { + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + const lex = vi.spyOn(Lexer, 'lex'); + let propertyRead = false; + let coerced = false; + const hostile = { + get length() { + propertyRead = true; + throw new Error('private-length-getter'); + }, + toString() { + coerced = true; + throw new Error('private-string-coercion'); + }, + }; + let failure: unknown; + + try { + (convert as unknown as (markdown: unknown) => string)(hostile); + } catch (error) { + failure = error; + } + + expect(propertyRead).toBe(false); + expect(coerced).toBe(false); + expect(encode).not.toHaveBeenCalled(); + expect(lex).not.toHaveBeenCalled(); + expect(failure).toMatchObject({ + name: 'MarkdownToHtmlResourceError', + code: 'invalid_input', + message: 'Markdown-to-HTML input must be a string.', + }); + expect(String(failure)).not.toContain('private-'); + }); +}); diff --git a/src/markdown/markdownToHtmlResourcePolicy.ts b/src/markdown/markdownToHtmlResourcePolicy.ts new file mode 100644 index 00000000..52d9fb7a --- /dev/null +++ b/src/markdown/markdownToHtmlResourcePolicy.ts @@ -0,0 +1,67 @@ +/** Default UTF-8 byte ceiling for one Markdown-to-HTML conversion. */ +export const DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES = 16_777_216; + +/** Hard public ceiling for an explicitly raised Markdown-to-HTML input limit. */ +export const MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES = 67_108_864; + +/** Stable redacted Markdown input/resource failures from conversion. */ +export type MarkdownToHtmlResourceErrorCode = + | 'input_too_large' + | 'invalid_input' + | 'invalid_configuration'; + +const ERROR_MESSAGES: Readonly<Record<MarkdownToHtmlResourceErrorCode, string>> = + Object.freeze({ + input_too_large: + 'Markdown-to-HTML input exceeds the configured byte limit.', + invalid_input: 'Markdown-to-HTML input must be a string.', + invalid_configuration: + 'Markdown-to-HTML resource configuration is invalid.', + }); + +/** Error whose stable code/message never disclose caller-controlled Markdown. */ +export class MarkdownToHtmlResourceError extends Error { + /** Machine-readable rejection category safe for host telemetry. */ + readonly code: MarkdownToHtmlResourceErrorCode; + + /** Create one stable resource-bound conversion error. */ + constructor(code: MarkdownToHtmlResourceErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = 'MarkdownToHtmlResourceError'; + this.code = code; + } +} + +/** Resolve one optional per-call byte ceiling within the public hard maximum. */ +export function resolveMarkdownToHtmlMaxBytes(candidate: unknown): number { + if (candidate === undefined) return DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES; + if ( + typeof candidate !== 'number' || + !Number.isSafeInteger(candidate) || + candidate < 1 || + candidate > MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES + ) { + throw new MarkdownToHtmlResourceError('invalid_configuration'); + } + return candidate; +} + +/** Reject invalid or oversized Markdown before parser materialization. */ +export function assertMarkdownToHtmlInputSize( + markdown: string, + maxMarkdownBytes: number, +): void { + if (typeof markdown !== 'string') { + throw new MarkdownToHtmlResourceError('invalid_input'); + } + + // 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 (markdown.length > maxMarkdownBytes) { + throw new MarkdownToHtmlResourceError('input_too_large'); + } + if (new TextEncoder().encode(markdown).byteLength > maxMarkdownBytes) { + throw new MarkdownToHtmlResourceError('input_too_large'); + } +} diff --git a/src/markdown/package.ts b/src/markdown/package.ts index d943703d..724bdf7d 100644 --- a/src/markdown/package.ts +++ b/src/markdown/package.ts @@ -5,16 +5,30 @@ * runtime, collaboration providers, transport, persistence, credentials, and * model authority. It exposes only deterministic conversion/projection APIs. */ +export { htmlToMarkdown } from './serializer.js'; +export type { HtmlToMarkdownOptions } from './serializer.js'; export { - htmlToMarkdown, markdownToEmailHtml, markdownToHtml, normalizeMarkdown, -} from './serializer.js'; +} from './resourceBoundMarkdown.js'; export type { - HtmlToMarkdownOptions, MarkdownToEmailHtmlOptions, -} from './serializer.js'; + MarkdownToHtmlOptions, + NormalizeMarkdownOptions, +} from './resourceBoundMarkdown.js'; +export { + DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, + MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, + HtmlToMarkdownResourceError, +} from './htmlToMarkdownResourcePolicy.js'; +export type { HtmlToMarkdownResourceErrorCode } from './htmlToMarkdownResourcePolicy.js'; +export { + DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, + MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, + MarkdownToHtmlResourceError, +} from './markdownToHtmlResourcePolicy.js'; +export type { MarkdownToHtmlResourceErrorCode } from './markdownToHtmlResourcePolicy.js'; export { htmlToPlainText, markdownToPlainText, diff --git a/src/markdown/plainText.resourceBounds.test.ts b/src/markdown/plainText.resourceBounds.test.ts new file mode 100644 index 00000000..b6788d65 --- /dev/null +++ b/src/markdown/plainText.resourceBounds.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Lexer } from 'marked'; +import { + htmlToPlainText, + markdownToPlainText, +} from './plainText.js'; +import { MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES } from './markdownToHtmlResourcePolicy.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('plain-text Markdown resource bounds', () => { + it('rejects oversized Markdown before Marked lexer materialization', () => { + const lex = vi.spyOn(Lexer, 'lex'); + let failure: unknown; + + try { + markdownToPlainText('12345', { maxMarkdownBytes: 4 }); + } catch (error) { + failure = error; + } + + expect(lex).not.toHaveBeenCalled(); + expect(failure).toMatchObject({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + message: 'Markdown-to-HTML input exceeds the configured byte limit.', + }); + }); + + it('uses exact UTF-8 bytes when code-unit length alone does not prove oversize', () => { + const lex = vi.spyOn(Lexer, 'lex'); + + expect(() => + markdownToPlainText('é', { maxMarkdownBytes: 1 }), + ).toThrowError( + expect.objectContaining({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + }), + ); + expect(lex).not.toHaveBeenCalled(); + }); + + it('accepts Markdown exactly at the configured UTF-8 ceiling', () => { + expect(markdownToPlainText('é', { maxMarkdownBytes: 2 })).toBe('é'); + }); + + it.each([ + ['wrong type', '4'], + ['fractional', 1.5], + ['zero', 0], + ['above hard maximum', MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES + 1], + ])('fails closed for %s Markdown resource configuration', (_label, maxMarkdownBytes) => { + const secret = 'private-markdown'; + let failure: unknown; + + try { + // Invalid runtime configuration is intentionally forced past TypeScript + // so the public fail-closed validation remains regression-tested. + markdownToPlainText(secret, { maxMarkdownBytes } as never); + } catch (error) { + failure = error; + } + + expect(failure).toMatchObject({ + name: 'MarkdownToHtmlResourceError', + code: 'invalid_configuration', + message: 'Markdown-to-HTML resource configuration is invalid.', + }); + expect(String(failure)).not.toContain(secret); + }); + + it('forwards the HTML parser ceiling before HTML normalization', () => { + expect(() => + htmlToPlainText('<p>hello</p>', { maxHtmlBytes: 4 }), + ).toThrowError( + expect.objectContaining({ + name: 'HtmlToMarkdownResourceError', + code: 'input_too_large', + }), + ); + }); + + it('bounds generated Markdown before the plain-text lexer', () => { + const lex = vi.spyOn(Lexer, 'lex'); + + expect(() => + htmlToPlainText('<p>hello</p>', { + maxHtmlBytes: 32, + maxMarkdownBytes: 4, + }), + ).toThrowError( + expect.objectContaining({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + }), + ); + expect(lex).not.toHaveBeenCalled(); + }); +}); diff --git a/src/markdown/plainText.test.ts b/src/markdown/plainText.test.ts index 625b6f1a..923242a1 100644 --- a/src/markdown/plainText.test.ts +++ b/src/markdown/plainText.test.ts @@ -9,6 +9,8 @@ const PNG_DATA_URI = bytesToDataUri( ]), ); +const INVALID_PLAIN_TEXT_OPTIONS_MESSAGE = 'plain-text options are invalid.'; + describe('markdownToPlainText', () => { it('preserves authored reading order without Markdown syntax or destinations', () => { const plainText = markdownToPlainText(` @@ -108,6 +110,94 @@ const total = 120; expect(plainText).toBe('Before after.'); }); + it('accepts null-prototype option bags without weakening validation', () => { + const options = Object.assign(Object.create(null), { + includeImageAlt: false, + }); + + expect( + markdownToPlainText('Before ![Quarterly chart](x.png) after.', options), + ).toBe('Before after.'); + }); + + it('rejects invalid runtime image-alt policy instead of coercing it', () => { + const privateMarker = 'private-image-alt-policy'; + let failure: unknown; + + try { + markdownToPlainText('![Quarterly chart](x.png)', { + includeImageAlt: privateMarker, + } as never); + } catch (error) { + failure = error; + } + + expect(failure).toEqual( + new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE), + ); + expect(String(failure)).not.toContain(privateMarker); + }); + + it('rejects accessor-backed options without invoking caller code', () => { + let getterCalls = 0; + const options = Object.defineProperty({}, 'includeImageAlt', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('private getter failure'); + }, + }); + + expect(() => markdownToPlainText('Visible', options as never)).toThrowError( + new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE), + ); + expect(getterCalls).toBe(0); + }); + + it('rejects invalid containers and exotic option prototypes', () => { + const exoticOptions = Object.create({ inherited: true }); + + for (const options of [42, [], exoticOptions]) { + expect(() => + markdownToPlainText('Visible', options as never), + ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE)); + } + }); + + it('rejects non-enumerable and descriptor-hostile option properties', () => { + const hiddenOptions = Object.defineProperty({}, 'includeImageAlt', { + configurable: true, + enumerable: false, + value: false, + }); + const missingDescriptorOptions = new Proxy( + {}, + { + ownKeys: () => ['includeImageAlt'], + getOwnPropertyDescriptor: () => undefined, + }, + ); + + expect(() => + markdownToPlainText('Visible', hiddenOptions as never), + ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE)); + expect(() => + markdownToPlainText('Visible', missingDescriptorOptions as never), + ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE)); + }); + + it('rejects unknown and symbol option keys fail-closed', () => { + expect(() => + markdownToPlainText('Visible', { maxMarkdownByte: 4 } as never), + ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE)); + expect(() => + markdownToPlainText( + 'Visible', + { [Symbol('private')]: true } as never, + ), + ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE)); + }); + it('omits raw HTML blocks and link-definition records instead of interpreting them', () => { const plainText = markdownToPlainText(` <script>alert('secret')</script> @@ -148,4 +238,10 @@ describe('htmlToPlainText', () => { }), ).toBe('Beforeafter'); }); -}); + + it('rejects malformed option containers before HTML normalization', () => { + expect(() => htmlToPlainText('<p>Visible</p>', null as never)).toThrowError( + new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE), + ); + }); +}); \ No newline at end of file diff --git a/src/markdown/plainText.ts b/src/markdown/plainText.ts index 18870bcc..244afe43 100644 --- a/src/markdown/plainText.ts +++ b/src/markdown/plainText.ts @@ -1,4 +1,8 @@ import { Marked } from 'marked'; +import { + assertMarkdownToHtmlInputSize, + resolveMarkdownToHtmlMaxBytes, +} from './markdownToHtmlResourcePolicy.js'; import { htmlToMarkdown } from './serializer.js'; const plainTextMarked = new Marked({ @@ -17,6 +21,13 @@ const BLOCK_TOKEN_TYPES = new Set([ 'table', ]); +const PLAIN_TEXT_OPTION_KEYS = new Set([ + 'includeImageAlt', + 'maxMarkdownBytes', + 'maxHtmlBytes', +]); +const INVALID_PLAIN_TEXT_OPTIONS_MESSAGE = 'plain-text options are invalid.'; + interface PlainTextToken { type: string; text?: string; @@ -60,6 +71,12 @@ interface PlainTextSegment { value: string; } +interface ResolvedPlainTextOptions { + includeImageAlt: boolean; + maxMarkdownBytes: number | undefined; + maxHtmlBytes: number | undefined; +} + /** Options for Markdown/HTML plain-text projection. */ export interface PlainTextOptions { /** @@ -67,6 +84,67 @@ export interface PlainTextOptions { * Decorative images with an empty alternative remain silent. */ includeImageAlt?: boolean; + /** + * Maximum UTF-8 Markdown bytes accepted before plain-text lexing. + * Defaults to 16 MiB; values above the 64 MiB hard maximum are rejected. + */ + maxMarkdownBytes?: number; + /** + * Maximum UTF-8 HTML bytes accepted before HTML normalization by + * `htmlToPlainText`. Defaults to 16 MiB; values above the 64 MiB hard + * maximum are rejected. + */ + maxHtmlBytes?: number; +} + +/** + * Read the public option bag without executing caller accessors or silently + * accepting misspelled/unknown keys. Resource-limit values are copied without + * reinterpretation so their existing policy modules retain error authority. + */ +function resolvePlainTextOptions(options: unknown): ResolvedPlainTextOptions { + let values: Record<string, unknown>; + try { + if ( + typeof options !== 'object' || + options === null || + Array.isArray(options) + ) { + throw new TypeError('invalid options container'); + } + + const prototype = Object.getPrototypeOf(options); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('invalid options prototype'); + } + + values = Object.create(null) as Record<string, unknown>; + for (const key of Reflect.ownKeys(options)) { + if (typeof key !== 'string' || !PLAIN_TEXT_OPTION_KEYS.has(key)) { + throw new TypeError('unknown option'); + } + const descriptor = Object.getOwnPropertyDescriptor(options, key); + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new TypeError('invalid option property'); + } + values[key] = descriptor.value; + } + + if ( + values.includeImageAlt !== undefined && + typeof values.includeImageAlt !== 'boolean' + ) { + throw new TypeError('invalid image alternative policy'); + } + } catch { + throw new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE); + } + + return { + includeImageAlt: values.includeImageAlt !== false, + maxMarkdownBytes: values.maxMarkdownBytes as number | undefined, + maxHtmlBytes: values.maxHtmlBytes as number | undefined, + }; } /** @@ -76,15 +154,21 @@ export interface PlainTextOptions { * The projection keeps authored reading order, paragraph boundaries, explicit * line breaks, code text, list structure, table cells, link labels, and image * alternative text. Raw HTML blocks and link-definition records are omitted - * instead of interpreted. + * instead of interpreted. The Markdown byte ceiling is enforced before Marked + * materializes lexer tokens. */ export function markdownToPlainText( markdown: string, options: PlainTextOptions = {}, ): string { + const resolvedOptions = resolvePlainTextOptions(options); + const maxMarkdownBytes = resolveMarkdownToHtmlMaxBytes( + resolvedOptions.maxMarkdownBytes, + ); + assertMarkdownToHtmlInputSize(markdown, maxMarkdownBytes); const tokens = plainTextMarked.lexer(markdown) as unknown as PlainTextToken[]; const state: PlainTextRenderState = { - includeImageAlt: options.includeImageAlt !== false, + includeImageAlt: resolvedOptions.includeImageAlt, listDepth: 0, }; return normalizePlainText(renderTokenSequence(tokens, state)); @@ -95,16 +179,23 @@ export function markdownToPlainText( * HTML-to-Markdown normalization boundary. * * Element names, attributes, hyperlink destinations, and image sources are not - * emitted. Image alternative text is included unless explicitly disabled. + * emitted. Image alternative text is included unless explicitly disabled. The + * HTML ceiling applies before normalization and the Markdown ceiling applies + * to the normalized Markdown before plain-text lexing. */ export function htmlToPlainText( html: string, options: PlainTextOptions = {}, ): string { - return markdownToPlainText( - htmlToMarkdown(html, { includeImageAlt: options.includeImageAlt }), - options, - ); + const resolvedOptions = resolvePlainTextOptions(options); + const markdown = htmlToMarkdown(html, { + includeImageAlt: resolvedOptions.includeImageAlt, + maxHtmlBytes: resolvedOptions.maxHtmlBytes, + }); + return markdownToPlainText(markdown, { + includeImageAlt: resolvedOptions.includeImageAlt, + maxMarkdownBytes: resolvedOptions.maxMarkdownBytes, + }); } /** Render an ordered token sequence while preserving block boundaries. */ @@ -254,4 +345,4 @@ function trimSegmentEdges(segments: PlainTextSegment[]): PlainTextSegment[] { if (index === segments.length - 1) value = value.trimEnd(); return { kind: 'text', value }; }); -} +} \ No newline at end of file diff --git a/src/markdown/resourceBoundMarkdown.ts b/src/markdown/resourceBoundMarkdown.ts new file mode 100644 index 00000000..90bbb2ee --- /dev/null +++ b/src/markdown/resourceBoundMarkdown.ts @@ -0,0 +1,173 @@ +import { + markdownToEditorHtml as serializeMarkdownToEditorHtml, + markdownToEmailHtml as serializeMarkdownToEmailHtml, + markdownToHtml as serializeMarkdownToHtml, + normalizeMarkdown as serializeNormalizedMarkdown, +} from './serializer.js'; +import type { MarkdownToEmailHtmlOptions as SerializerMarkdownToEmailHtmlOptions } from './serializer.js'; +import { + DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, + MarkdownToHtmlResourceError, + assertMarkdownToHtmlInputSize, + resolveMarkdownToHtmlMaxBytes, +} from './markdownToHtmlResourcePolicy.js'; + +const EMAIL_LANGUAGE_TAG_MAX_CODE_UNITS = 256; +const EMAIL_TITLE_MAX_CODE_UNITS = 65_536; +const INVALID_EMAIL_FULL_DOCUMENT_MESSAGE = + 'Email document fullDocument must be a boolean when provided.'; +const INVALID_EMAIL_LANGUAGE_MESSAGE = + 'Email document language must be a valid BCP 47 language tag within the supported length.'; +const INVALID_EMAIL_TITLE_MESSAGE = + 'Email document title must be a string within the supported length.'; +const MARKDOWN_OPTION_KEYS = new Set(['maxMarkdownBytes']); +const EMAIL_OPTION_KEYS = new Set([ + 'maxMarkdownBytes', + 'fullDocument', + 'title', + 'languageTag', + 'textDirection', +]); + +type ResolvedOptionBag = Record<string, unknown>; + +/** Options for public Markdown-to-HTML conversion. */ +export interface MarkdownToHtmlOptions { + /** Maximum UTF-8 bytes accepted before Marked lexing. Defaults to 16 MiB. */ + maxMarkdownBytes?: number; +} + +/** Options for public Markdown normalization. */ +export interface NormalizeMarkdownOptions { + /** Maximum UTF-8 bytes accepted before Marked lexing. Defaults to 16 MiB. */ + maxMarkdownBytes?: number; +} + +/** Options for public Markdown-to-email-HTML conversion. */ +export interface MarkdownToEmailHtmlOptions + extends SerializerMarkdownToEmailHtmlOptions { + /** Maximum UTF-8 bytes accepted before Marked lexing. Defaults to 16 MiB. */ + maxMarkdownBytes?: number; +} + +function resolveOptionBag( + options: unknown, + allowedKeys: ReadonlySet<string>, +): ResolvedOptionBag { + try { + if (typeof options !== 'object' || options === null || Array.isArray(options)) { + throw new MarkdownToHtmlResourceError('invalid_configuration'); + } + const prototype = Object.getPrototypeOf(options); + if (prototype !== Object.prototype && prototype !== null) { + throw new MarkdownToHtmlResourceError('invalid_configuration'); + } + const descriptors = Object.getOwnPropertyDescriptors(options); + const resolved = Object.create(null) as ResolvedOptionBag; + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== 'string' || !allowedKeys.has(key)) { + throw new MarkdownToHtmlResourceError('invalid_configuration'); + } + const descriptor = descriptors[key]; + if (!descriptor.enumerable || !('value' in descriptor)) { + throw new MarkdownToHtmlResourceError('invalid_configuration'); + } + resolved[key] = descriptor.value; + } + return resolved; + } catch { + throw new MarkdownToHtmlResourceError('invalid_configuration'); + } +} + +/** Apply the owned default Markdown ceiling before an internal conversion. */ +function assertDefaultMarkdownInputSize(markdown: string): void { + assertMarkdownToHtmlInputSize(markdown, DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES); +} + +/** Apply one caller-selectable Markdown ceiling before Marked materialization. */ +function assertConfiguredMarkdownInputSize( + markdown: string, + maxMarkdownBytes: unknown, +): void { + const resolvedMaxBytes = resolveMarkdownToHtmlMaxBytes(maxMarkdownBytes); + assertMarkdownToHtmlInputSize(markdown, resolvedMaxBytes); +} + +/** Reject malformed runtime document-mode values without coercing representation. */ +function assertEmailFullDocumentMode( + value: unknown, +): asserts value is boolean | undefined { + if (value !== undefined && typeof value !== 'boolean') { + throw new RangeError(INVALID_EMAIL_FULL_DOCUMENT_MESSAGE); + } +} + +/** Reject invalid or oversized full-document title metadata before HTML escaping. */ +function assertBoundedEmailTitle(value: unknown): void { + if (value === undefined) return; + if (typeof value !== 'string' || value.length > EMAIL_TITLE_MAX_CODE_UNITS) { + throw new RangeError(INVALID_EMAIL_TITLE_MESSAGE); + } +} + +/** Reject invalid or oversized full-document language metadata before Intl work. */ +function assertBoundedEmailLanguageTag(value: unknown): void { + if (value === undefined) return; + if ( + typeof value !== 'string' || + value.length > EMAIL_LANGUAGE_TAG_MAX_CODE_UNITS + ) { + throw new RangeError(INVALID_EMAIL_LANGUAGE_MESSAGE); + } +} + +/** Convert bounded Markdown to parser HTML for TipTap ingress. */ +export function markdownToEditorHtml(markdown: string): string { + assertDefaultMarkdownInputSize(markdown); + return serializeMarkdownToEditorHtml(markdown); +} + +/** Convert bounded Markdown to safe standalone HTML. */ +export function markdownToHtml( + markdown: string, + options: MarkdownToHtmlOptions = {}, +): string { + const resolvedOptions = resolveOptionBag(options, MARKDOWN_OPTION_KEYS); + assertConfiguredMarkdownInputSize(markdown, resolvedOptions.maxMarkdownBytes); + return serializeMarkdownToHtml(markdown); +} + +/** Normalize bounded Markdown through the existing deterministic serializer. */ +export function normalizeMarkdown( + markdown: string, + options: NormalizeMarkdownOptions = {}, +): string { + const resolvedOptions = resolveOptionBag(options, MARKDOWN_OPTION_KEYS); + assertConfiguredMarkdownInputSize(markdown, resolvedOptions.maxMarkdownBytes); + return serializeNormalizedMarkdown(markdown); +} + +/** Convert bounded Markdown to the existing safe email HTML representation. */ +export function markdownToEmailHtml( + markdown: string, + options: MarkdownToEmailHtmlOptions = {}, +): string { + const resolvedOptions = resolveOptionBag(options, EMAIL_OPTION_KEYS); + assertConfiguredMarkdownInputSize(markdown, resolvedOptions.maxMarkdownBytes); + const fullDocument = resolvedOptions.fullDocument; + assertEmailFullDocumentMode(fullDocument); + if (fullDocument === true) { + assertBoundedEmailTitle(resolvedOptions.title); + assertBoundedEmailLanguageTag(resolvedOptions.languageTag); + } + const serializerOptions: SerializerMarkdownToEmailHtmlOptions = { + fullDocument, + title: resolvedOptions.title as SerializerMarkdownToEmailHtmlOptions['title'], + languageTag: + resolvedOptions.languageTag as SerializerMarkdownToEmailHtmlOptions['languageTag'], + textDirection: + resolvedOptions.textDirection as SerializerMarkdownToEmailHtmlOptions['textDirection'], + }; + return serializeMarkdownToEmailHtml(markdown, serializerOptions); +} diff --git a/src/markdown/serializer.resourceBounds.test.ts b/src/markdown/serializer.resourceBounds.test.ts new file mode 100644 index 00000000..d51f592d --- /dev/null +++ b/src/markdown/serializer.resourceBounds.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { htmlToMarkdown } from './serializer.js'; +import { + MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, +} from './htmlToMarkdownResourcePolicy.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('HTML-to-Markdown resource bounds', () => { + it('rejects obvious oversize before UTF-8 encoding or browser parser materialization', () => { + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + const createElement = vi.spyOn(document, 'createElement'); + let failure: unknown; + + try { + htmlToMarkdown('<p>12345</p>', { maxHtmlBytes: 4 }); + } catch (error) { + failure = error; + } + + expect(encode).not.toHaveBeenCalled(); + expect(createElement).not.toHaveBeenCalledWith('template'); + expect(failure).toMatchObject({ + name: 'HtmlToMarkdownResourceError', + code: 'input_too_large', + message: 'HTML-to-Markdown input exceeds the configured byte limit.', + }); + }); + + it('uses exact UTF-8 bytes when code-unit length alone does not prove oversize', () => { + const createElement = vi.spyOn(document, 'createElement'); + + expect(() => htmlToMarkdown('é', { maxHtmlBytes: 1 })).toThrowError( + expect.objectContaining({ + name: 'HtmlToMarkdownResourceError', + code: 'input_too_large', + }), + ); + expect(createElement).not.toHaveBeenCalledWith('template'); + }); + + it('accepts input exactly at the configured UTF-8 ceiling', () => { + expect(htmlToMarkdown('<p>x</p>', { maxHtmlBytes: 8 })).toBe('x'); + }); + + it.each([ + ['wrong type', '4'], + ['fractional', 1.5], + ['zero', 0], + ['above hard maximum', MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES + 1], + ])('fails closed for %s resource configuration', (_label, maxHtmlBytes) => { + const secret = '<p>private-content</p>'; + let failure: unknown; + + try { + htmlToMarkdown(secret, { maxHtmlBytes } as never); + } catch (error) { + failure = error; + } + + expect(failure).toMatchObject({ + name: 'HtmlToMarkdownResourceError', + code: 'invalid_configuration', + message: 'HTML-to-Markdown resource configuration is invalid.', + }); + expect(String(failure)).not.toContain(secret); + }); + + it('accepts the hard maximum without changing ordinary conversion semantics', () => { + expect( + htmlToMarkdown('<strong>safe</strong>', { + maxHtmlBytes: MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, + }), + ).toBe('**safe**'); + }); +}); diff --git a/src/markdown/serializer.ts b/src/markdown/serializer.ts index 4141d32a..784dab55 100644 --- a/src/markdown/serializer.ts +++ b/src/markdown/serializer.ts @@ -14,6 +14,11 @@ import TurndownService from 'turndown'; import { gfm } from 'turndown-plugin-gfm'; import { validateInlineImageSource } from '../policy/inlineImagePolicy.js'; import { isSafeLinkHref } from '../policy/safeLinkPolicy.js'; +import { + HtmlToMarkdownResourceError, + assertHtmlToMarkdownInputSize, + resolveHtmlToMarkdownMaxBytes, +} from './htmlToMarkdownResourcePolicy.js'; const SERIALIZED_IMAGE_MAX_BYTES = 10 * 1024 * 1024; @@ -142,7 +147,7 @@ function formatMarkdownTitle(value: string | null): string { * inert browser template fragment before Turndown sees it. */ function sanitizeInertHtmlFragment(fragment: DocumentFragment): DocumentFragment { - for (const element of Array.from(fragment.querySelectorAll('*'))) { + for (const element of fragment.querySelectorAll('*')) { const elementName = element.localName; if (REMOVED_HTML_ELEMENTS.has(elementName)) { element.remove(); @@ -187,9 +192,11 @@ function sanitizeInertHtmlFragment(fragment: DocumentFragment): DocumentFragment /** Parse browser HTML into an inert, detached template document fragment. */ function createInertBrowserFragment(html: string): DocumentFragment | null { + const browserWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + ?.value as Window | undefined; /* v8 ignore next -- browserless runtimes must not touch ambient document. */ - if (typeof window === 'undefined') return null; - const template = window.document.createElement('template'); + if (!browserWindow) return null; + const template = browserWindow.document.createElement('template'); template.innerHTML = html; return sanitizeInertHtmlFragment(template.content); } @@ -246,12 +253,59 @@ 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; +} + +type ResolvedHtmlToMarkdownOptions = Record<string, unknown> & { + includeImageAlt?: boolean; + maxHtmlBytes?: unknown; +}; + +const HTML_TO_MARKDOWN_OPTION_KEYS = new Set(['includeImageAlt', 'maxHtmlBytes']); + +/** Snapshot one runtime option bag without invoking caller accessors or coercions. */ +function resolveHtmlToMarkdownOptions(options: unknown): ResolvedHtmlToMarkdownOptions { + try { + if (typeof options !== 'object' || options === null || Array.isArray(options)) { + throw new HtmlToMarkdownResourceError('invalid_configuration'); + } + const prototype = Object.getPrototypeOf(options); + if (prototype !== Object.prototype && prototype !== null) { + throw new HtmlToMarkdownResourceError('invalid_configuration'); + } + const descriptors = Object.getOwnPropertyDescriptors(options); + const resolved = Object.create(null) as ResolvedHtmlToMarkdownOptions; + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== 'string' || !HTML_TO_MARKDOWN_OPTION_KEYS.has(key)) { + throw new HtmlToMarkdownResourceError('invalid_configuration'); + } + const descriptor = descriptors[key]; + if (!descriptor.enumerable || !('value' in descriptor)) { + throw new HtmlToMarkdownResourceError('invalid_configuration'); + } + resolved[key] = descriptor.value; + } + if ( + resolved.includeImageAlt !== undefined && + typeof resolved.includeImageAlt !== 'boolean' + ) { + throw new HtmlToMarkdownResourceError('invalid_configuration'); + } + return resolved; + } catch { + throw new HtmlToMarkdownResourceError('invalid_configuration'); + } } /** * Convert an HTML string to Markdown through an inert, fail-closed boundary. * - * In browsers, the raw string is parsed only inside a detached `<template>` and + * The runtime option bag is snapshotted before source sizing or parser work so + * accessors, unknown keys, exotic prototypes, and malformed values cannot run + * caller code or silently alter policy. The input byte ceiling is then enforced + * before any browser DOM or browserless Turndown parser materialization. In + * browsers, accepted raw input is parsed only inside a detached `<template>` and * sanitized before the resulting `DocumentFragment` reaches Turndown. In * browserless Node runtimes, Turndown 7 uses its non-fetching Domino parser. * Both paths emit Markdown links only for Inkspan-safe targets and Markdown @@ -261,8 +315,11 @@ export function htmlToMarkdown( html: string, options: HtmlToMarkdownOptions = {}, ): string { + const resolvedOptions = resolveHtmlToMarkdownOptions(options); + const maxHtmlBytes = resolveHtmlToMarkdownMaxBytes(resolvedOptions.maxHtmlBytes); + assertHtmlToMarkdownInputSize(html, maxHtmlBytes); const fragment = createInertBrowserFragment(html); - const turndown = options.includeImageAlt === false + const turndown = resolvedOptions.includeImageAlt === false ? turndownWithoutImageAlt : turndownWithImageAlt; /* v8 ignore next 3 -- packed Node consumer verification exercises this DOM-free fallback. */ @@ -347,11 +404,11 @@ export function markdownToEmailHtml( markdown: string, options: MarkdownToEmailHtmlOptions = {}, ): string { - const body = markdownToHtml(markdown).trim(); - if (!options.fullDocument) return body; + if (!options.fullDocument) return markdownToHtml(markdown).trim(); const title = escapeHtml(options.title ?? 'Message'); const languageTag = canonicalizeEmailLanguageTag(options.languageTag); const textDirection = validateEmailTextDirection(options.textDirection); + const body = markdownToHtml(markdown).trim(); const languageAttribute = languageTag ? ` lang="${escapeHtml(languageTag)}"` : ''; @@ -376,4 +433,4 @@ function escapeHtml(value: string): string { .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); -} +} \ No newline at end of file diff --git a/src/markdownPackage.test.ts b/src/markdownPackage.test.ts index d46215d8..9f792876 100644 --- a/src/markdownPackage.test.ts +++ b/src/markdownPackage.test.ts @@ -3,6 +3,12 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { + DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, + DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, + MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, + MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, + HtmlToMarkdownResourceError, + MarkdownToHtmlResourceError, htmlToMarkdown, htmlToPlainText, markdownToEmailHtml, @@ -25,6 +31,26 @@ describe('headless deterministic Markdown package contract', () => { it('executes the intended deterministic conversion surface through the source barrel', () => { expect(markdownToHtml('**Alpha**')).toContain('<strong>Alpha</strong>'); expect(htmlToMarkdown('<p>Alpha</p>')).toBe('Alpha'); + expect(DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES).toBe(16_777_216); + expect(MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES).toBe(67_108_864); + expect(HtmlToMarkdownResourceError).toBeInstanceOf(Function); + expect(DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES).toBe(16_777_216); + expect(MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES).toBe(67_108_864); + expect(MarkdownToHtmlResourceError).toBeInstanceOf(Function); + expect(() => + htmlToMarkdown('<p>Alpha</p>', { maxHtmlBytes: 4 }), + ).toThrowError( + expect.objectContaining({ + name: 'HtmlToMarkdownResourceError', + code: 'input_too_large', + }), + ); + expect(() => markdownToHtml('Alpha', { maxMarkdownBytes: 4 })).toThrowError( + expect.objectContaining({ + name: 'MarkdownToHtmlResourceError', + code: 'input_too_large', + }), + ); expect(normalizeMarkdown('**Alpha**')).toContain('**Alpha**'); expect(markdownToPlainText('[Alpha](https://example.com)')).toBe('Alpha'); expect(htmlToPlainText('<p>Alpha</p>')).toBe('Alpha'); @@ -106,6 +132,10 @@ describe('headless deterministic Markdown package contract', () => { expect(verifier).toContain('moduleAuthority.length'); expect(verifier).toContain('ambientAuthorityPattern'); expect(verifier).toContain('ambient document access is forbidden'); + expect(verifier).toContain('maxHtmlBytes'); + expect(verifier).toContain('HtmlToMarkdownResourceError'); + expect(verifier).toContain('maxMarkdownBytes'); + expect(verifier).toContain('MarkdownToHtmlResourceError'); expect(verifier).toContain('React'); expect(verifier).toContain('@tiptap'); expect(verifier).toContain('yjs'); diff --git a/src/policy/safeLinkPolicy.ts b/src/policy/safeLinkPolicy.ts index 82ea5570..66d8c0d6 100644 --- a/src/policy/safeLinkPolicy.ts +++ b/src/policy/safeLinkPolicy.ts @@ -10,8 +10,28 @@ const SAFE_ABSOLUTE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']); const URI_SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/i; const FORBIDDEN_LINK_CHARACTER_PATTERN = /[\u0000-\u0020\u007f-\u009f\\]/u; +/** Default maximum UTF-8 byte length accepted for one hyperlink target. */ +export const DEFAULT_SAFE_LINK_MAX_HREF_BYTES = 65_536; +/** Hard maximum caller-selected UTF-8 byte length for one hyperlink target. */ +export const MAXIMUM_SAFE_LINK_MAX_HREF_BYTES = 1_048_576; +const SAFE_LINK_CONFIGURATION_KEYS = ['maxHrefBytes'] as const; + +/** Runtime configuration for one safe-link validation operation. */ +export interface SafeLinkValidationOptions { + /** Maximum UTF-8 bytes accepted before URI parsing. Defaults to 64 KiB. */ + readonly maxHrefBytes?: number; +} + +/** Machine-readable safe-link failure category. */ +export type SafeLinkHrefErrorCode = + | 'invalid_href' + | 'input_too_large' + | 'invalid_configuration'; + /** Return a bounded, secret-free category for an untrusted hyperlink target. */ -function redactLinkHref(href: unknown): string { +function redactLinkHref(href: unknown, code: SafeLinkHrefErrorCode): string { + if (code === 'input_too_large') return '<oversized>'; + if (code === 'invalid_configuration') return '<configuration>'; if (typeof href !== 'string') return `<${typeof href}>`; if (href.length === 0) return '<empty>'; if (href.startsWith('//')) return '//<redacted>'; @@ -23,17 +43,87 @@ function redactLinkHref(href: unknown): string { /** Error thrown when a hyperlink target violates Inkspan's safe-URI policy. */ export class SafeLinkHrefError extends Error { + /** Stable machine-readable failure category. */ + readonly code: SafeLinkHrefErrorCode; /** Redacted target category safe for logs and host telemetry. */ readonly hrefPreview: string; - constructor(href: unknown) { - const hrefPreview = redactLinkHref(href); - super(`Link target violates the Inkspan safe-URI policy (${hrefPreview}).`); + constructor( + href: unknown, + code: SafeLinkHrefErrorCode = 'invalid_href', + ) { + const hrefPreview = redactLinkHref(href, code); + const message = + code === 'input_too_large' + ? `Link target exceeds the Inkspan resource boundary (${hrefPreview}).` + : code === 'invalid_configuration' + ? `Link validation configuration is invalid (${hrefPreview}).` + : `Link target violates the Inkspan safe-URI policy (${hrefPreview}).`; + super(message); this.name = 'SafeLinkHrefError'; + this.code = code; this.hrefPreview = hrefPreview; } } +/** Resolve and validate the caller-selected resource ceiling without invoking accessors. */ +function resolveSafeLinkMaxHrefBytes(options: unknown): number { + if (options === undefined) return DEFAULT_SAFE_LINK_MAX_HREF_BYTES; + try { + if ( + typeof options !== 'object' || + options === null || + Array.isArray(options) + ) { + throw new TypeError('invalid configuration container'); + } + + const keys = Reflect.ownKeys(options); + if ( + keys.some( + (key) => + typeof key !== 'string' || + !SAFE_LINK_CONFIGURATION_KEYS.includes( + key as (typeof SAFE_LINK_CONFIGURATION_KEYS)[number], + ), + ) + ) { + throw new TypeError('unknown configuration key'); + } + if (!keys.includes('maxHrefBytes')) return DEFAULT_SAFE_LINK_MAX_HREF_BYTES; + + const descriptor = Object.getOwnPropertyDescriptor(options, 'maxHrefBytes'); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError('invalid configuration property'); + } + const value = descriptor.value as unknown; + if (value === undefined) return DEFAULT_SAFE_LINK_MAX_HREF_BYTES; + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value <= 0 || + value > MAXIMUM_SAFE_LINK_MAX_HREF_BYTES + ) { + throw new TypeError('invalid resource ceiling'); + } + return value; + } catch { + throw new SafeLinkHrefError(undefined, 'invalid_configuration'); + } +} + +/** Enforce one UTF-8 byte ceiling before URI parsing. */ +function assertSafeLinkResourceBound(href: string, maxHrefBytes: number): void { + // UTF-8 uses at least one byte per UTF-16 code unit. Reject the obvious + // oversize case before allocating a complete encoded copy. + if (href.length > maxHrefBytes) { + throw new SafeLinkHrefError(href, 'input_too_large'); + } + if (new TextEncoder().encode(href).byteLength > maxHrefBytes) { + throw new SafeLinkHrefError(href, 'input_too_large'); + } +} + /** Validate an HTTP(S) URL and reject deceptive embedded credentials. */ function validateWebHref(href: string): void { let parsed: URL; @@ -54,8 +144,17 @@ function validateWebHref(href: string): void { * document-relative/query/fragment references. Literal whitespace, control * characters, and backslashes are rejected rather than canonicalized so an * obfuscated executable scheme cannot acquire a different browser meaning. + * The complete target must fit the configured local UTF-8 resource ceiling + * before any HTTP(S) URL parser is invoked. */ -export function validateSafeLinkHref(href: unknown): string { +export function validateSafeLinkHref( + href: unknown, + options?: SafeLinkValidationOptions, +): string { + const maxHrefBytes = resolveSafeLinkMaxHrefBytes(options); + if (typeof href === 'string') { + assertSafeLinkResourceBound(href, maxHrefBytes); + } if ( typeof href !== 'string' || href.length === 0 || @@ -85,9 +184,12 @@ export function validateSafeLinkHref(href: unknown): string { } /** Boolean adapter for callers that need predicate-style URI validation. */ -export function isSafeLinkHref(href: unknown): href is string { +export function isSafeLinkHref( + href: unknown, + options?: SafeLinkValidationOptions, +): href is string { try { - validateSafeLinkHref(href); + validateSafeLinkHref(href, options); return true; } catch { return false; diff --git a/vite.markdown.config.ts b/vite.markdown.config.ts index b07d1d31..41f93fcc 100644 --- a/vite.markdown.config.ts +++ b/vite.markdown.config.ts @@ -8,6 +8,33 @@ const turndownStandaloneEntry = nodeRequire.resolve('turndown/lib/turndown.es.js const dominoStandaloneEntry = createRequire(turndownStandaloneEntry).resolve( '@mixmark-io/domino', ); +const turndownAmbientWindowProbe = + /\bvar root = typeof window !== ['"]undefined['"] \? window : \{\};/u; + +// Turndown's standalone build still probes the ambient `window` binding during +// module evaluation before it decides whether to use its bundled Domino parser. +// A hostile accessor-backed global therefore executes caller code merely by +// importing Inkspan's framework-free `/markdown` subpath. Replace only that +// exact upstream probe with the empty root that selects the already-pinned, +// non-fetching Domino path. Fail the build if the pinned upstream source shape +// changes so a dependency update cannot silently restore ambient authority. +const boundTurndownStandaloneParser = { + name: 'inkspan-bound-turndown-standalone-parser', + enforce: 'pre' as const, + transform(source: string, id: string) { + if (id.split('?', 1)[0] !== turndownStandaloneEntry) return null; + const matches = source.match(new RegExp(turndownAmbientWindowProbe.source, 'gu')); + if (matches?.length !== 1) { + throw new Error( + `Expected exactly one Turndown ambient window probe, found ${matches?.length ?? 0}.`, + ); + } + return { + code: source.replace(turndownAmbientWindowProbe, 'var root = {};'), + map: null, + }; + }, +}; // Headless deterministic serializer build: bundle the conversion dependencies so // consumers can use the public subpath without importing the React/TipTap graph. @@ -26,6 +53,7 @@ export default defineConfig({ mainFields: ['module', 'jsnext:main', 'jsnext', 'main'], }, plugins: [ + boundTurndownStandaloneParser, dts({ include: [ 'src/markdown',