diff --git a/docs/doctoring/canonical-encoder-reuse.md b/docs/doctoring/canonical-encoder-reuse.md new file mode 100644 index 000000000..15a045c18 --- /dev/null +++ b/docs/doctoring/canonical-encoder-reuse.md @@ -0,0 +1,27 @@ +# Canonical encoder source reconciliation + +Status: Active PR / Proposed. Recorded 2026-09-05; not protected-main or release evidence. + +## Problem and retained change + +Performance PR #379 retained commit `9258bdbdf8ec8ca87ab25b576fe2599c88ba9040`, which reused the native UTF-8 encoder. Canonical source owner #176 subsequently added bounded output and hostile-option validation to the same function. Their merge preflight reported one conflict in `src/documentEnvelopeCanonical.ts`. + +The source owner now incorporates the original reuse delta while preserving both output-size checks and the complete option/Unicode validation paths. The public encoder, revision generation and editor byte export share that internal boundary. Benchmark infrastructure remains in #379; it does not move into the source owner. Reconciliation uses ordinary history and explicit source resolution, not a force push or dropped delta. + +## Safety and alternatives + +WHATWG's Encoding Standard specifies per-call output and no streaming scalar buffer for `TextEncoder`. Reusing the encoder therefore needs no document cache, shared output buffer, extra dependency or custom codec. Native `encode()` still returns a separate byte array per call. Reuse occurs within each loaded module instance, not across workers or processes. + +Retaining per-call construction would drop the earlier accepted optimization; importing the whole performance branch into the source owner would mix unrelated responsibilities. A shared byte-buffer pool was rejected because it could corrupt outstanding digest input and adds lifetime coordination absent from native encoding. Import-time construction assumes the native Encoding API available in the supported runtime; packed ESM/CJS/SSR and actual browser checks must verify that boundary. + +The added regression first failed on different encoder receiver identities. It also checks identical canonical bytes, distinct output buffers and preservation of the second result after mutating the first. Existing tests retain early oversize rejection before byte allocation, exact UTF-8 byte limits, hostile option descriptors, lone surrogates, canonical ordering and revision parity. + +## Evidence limits and continuation + +The [original retained experiment](https://github.com/ContextualWisdomLab/inkspan/pull/379#issuecomment-5547196070) reported an 8.7% lower median p95 for its locked synthetic **no-op** transition on the earlier unbounded source. This is historical local evidence only. It does not transfer to the current bounded implementation, changed-transition scenario, real editor interaction, protected hardware or the 20 ms target. The separate rejected container-concatenation experiment remains rejected. + +After normal owner integration, #379 must inherit the owner and reacquire clean exact-source, artifact and scenario-bound evidence. Reviews/checks and parent-before-child protected integration remain required; source reconciliation is not product acceptance. + +## References + +WHATWG. (2026, May 21). *Encoding: Interface TextEncoder* [Living standard]. https://encoding.spec.whatwg.org/#interface-textencoder 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 000000000..cf630c153 --- /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 ccb611dcd..79272550c 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 d4e988f3a..cfa9bf2de 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 f0d1ec432..a2a0a8104 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 000000000..a3675185f --- /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 f698107a6..0394ba74e 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,7 +11,23 @@ import { } from './documentEnvelopeCanonical.js'; import * as publicApi from './index.js'; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('canonical document envelope serialization', () => { + it('preserves empty containers and lexically orders numeric-looking names', () => { + const envelope = createDocumentEnvelope({ + type: 'doc', + attrs: { '2': [], '10': {}, mixed: [null, false, true, 0, '', '한 😀'] }, + content: [], + }); + + expect(serializeDocumentEnvelope(envelope)).toBe( + `{"documentJson":{"attrs":{"10":{},"2":[],"mixed":[null,false,true,0,"","한 😀"]},"content":[],"type":"doc"},"schemaId":"${DOCUMENT_ENVELOPE_SCHEMA_ID}","schemaVersion":${DOCUMENT_ENVELOPE_SCHEMA_VERSION}}`, + ); + }); + it('sorts object properties recursively while preserving array order', () => { const envelope = createDocumentEnvelope({ type: 'doc', @@ -105,6 +121,99 @@ 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('reuses the native encoder without sharing output buffers', () => { + const envelope = createDocumentEnvelope({ + type: 'doc', + attrs: { label: '문서 😀' }, + }); + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + const firstBytes = encodeDocumentEnvelope(envelope); + const secondBytes = encodeDocumentEnvelope(envelope); + + expect(encode).toHaveBeenCalledTimes(2); + expect(encode.mock.contexts[0]).toBe(encode.mock.contexts[1]); + expect(firstBytes).toEqual(secondBytes); + expect(firstBytes.buffer).not.toBe(secondBytes.buffer); + firstBytes.fill(0); + expect(new TextDecoder().decode(secondBytes)).toBe( + serializeDocumentEnvelope(envelope), + ); + }); + + 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 c4dffea69..b057cd54a 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,23 @@ 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 UTF8_ENCODER = new TextEncoder(); +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 +47,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 +75,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 = UTF8_ENCODER.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 000000000..577ead63f --- /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 000000000..ffd7ca76b --- /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 845d723af..e78673b89 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 2af0cbfdf..0ae2a5201 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 000000000..dd1a285b8 --- /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 edc46671e..7d2d6a517 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 editor theme token catalog', () => { diff --git a/src/index.ts b/src/index.ts index ff676baad..b0b3bf1f0 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 000000000..537e1f4f5 --- /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 000000000..dd16ed9b5 --- /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 000000000..bf19e13d3 --- /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 000000000..ab8775f96 --- /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 000000000..64defe6cf --- /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 000000000..807d49776 --- /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 000000000..75801b22c --- /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 000000000..0b0b36acd --- /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 000000000..e56421695 --- /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 000000000..52d9fb7a7 --- /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 d943703d7..724bdf7df 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 000000000..b6788d653 --- /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 625b6f1af..923242a14 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 18870bcc1..244afe438 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 000000000..90bbb2ee1 --- /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 000000000..d51f592d6 --- /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 4141d32a2..784dab55b 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 d46215d84..9f7928766 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 82ea55707..66d8c0d6b 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 b07d1d310..41f93fcc7 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',