From 9f2da39048d0d351866e0940ea99dadc40d272c3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 23:41:31 +0200 Subject: [PATCH] fix: both importers drop a link whose scheme the editor never opens The render-time guard of the link mark was the only thing between a file's javascript: href and the page; the importers now read such a link as text. --- src/lib/import/docx.ts | 5 ++++- src/lib/import/odt.ts | 6 ++++-- tests/unit/link-scheme.test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 tests/unit/link-scheme.test.ts diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 2334b57..de9b4c1 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -3,6 +3,7 @@ import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, togg import { lengthToPt, WATERMARK_NAME } from './styleResolver'; import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt'; import { PLACEHOLDER_SDT_TAG } from '../export/docx'; +import { isAllowedUri } from '@tiptap/extension-link'; import { FOLD_MARK_NAME } from '../storage/foldMarks'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineNumbering } from '../styles/outlineNumbering'; @@ -1661,7 +1662,9 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD const rid = el.getAttributeNS(R, 'id'); // No relationship id: an internal link to a bookmark in this document. const anchor = el.getAttributeNS(W, 'anchor'); - const href = rid ? ctx.rels.get(rid)?.target : anchor ? `#${anchor}` : undefined; + let href = rid ? ctx.rels.get(rid)?.target : anchor ? `#${anchor}` : undefined; + // A scheme the editor would never open (javascript:) stops here, not at render time. + if (!isAllowedUri(href)) href = undefined; // A revision wraps its runs inside the link element, so walk both shapes. for (const child of Array.from(el.children)) { if (child.localName === 'r') handleRun(child, href); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index b66d7dd..0c4f7ba 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1,6 +1,7 @@ import { unzipSync, strFromU8 } from 'fflate'; import { StyleResolver, NS, WATERMARK_NAME, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver'; import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, ODF_LOOK_ATTRS, normalizeColor } from '../export/odt'; +import { isAllowedUri } from '@tiptap/extension-link'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineLevel, type OutlineNumbering } from '../styles/outlineNumbering'; import { LIST_LEVEL_STEP_CM, MAX_LIST_LEVELS, type ListLevelStyle, type ListStyle } from '../styles/listStyles'; @@ -2242,9 +2243,10 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl } case 'a': { // ODF hyperlink → link mark on the contained text; an internal #bookmark - // href is kept as-is and resolved against the document's bookmarks. + // href is kept as-is and resolved against the document's bookmarks. A scheme + // the editor would never open (javascript:) stops here, not at render time. const href = e.getAttributeNS(NS.xlink, 'href') ?? ''; - walk(e, props, href || linkHref); + walk(e, props, isAllowedUri(href) ? href || linkHref : linkHref); continue; } case 'ruby': { diff --git a/tests/unit/link-scheme.test.ts b/tests/unit/link-scheme.test.ts new file mode 100644 index 0000000..8ff9840 --- /dev/null +++ b/tests/unit/link-scheme.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { importDocx } from '../../src/lib/import/docx'; +import { importOdt } from '../../src/lib/import/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { buildOdt } from '../../src/lib/export/odt'; + +// A file can carry any href; one with a scheme the editor would never open is read as +// plain text, so the boundary is the import and not a render-time guard. +type N = { type: string; attrs?: any; content?: N[]; marks?: any[]; text?: string }; + +function links(node: N, out: string[] = []): string[] { + for (const m of node.marks ?? []) if (m.type === 'link') out.push(m.attrs.href); + for (const c of node.content ?? []) links(c, out); + return out; +} + +const doc: N = { type: 'doc', content: [{ type: 'paragraph', content: [ + { type: 'text', text: 'run', marks: [{ type: 'link', attrs: { href: 'javascript:alert(1)' } }] }, + { type: 'text', text: ' site', marks: [{ type: 'link', attrs: { href: 'https://example.com' } }] }, + { type: 'text', text: ' here', marks: [{ type: 'link', attrs: { href: '#top' } }] }, +] }] }; +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + +describe('a link with a scheme the editor never opens is dropped on import', () => { + it('through ODT', async () => { + const back = importOdt(await buildOdt(doc as any, margins, 'portrait')).content as unknown as N; + expect(links(back)).toEqual(['https://example.com', '#top']); + }); + it('through DOCX', async () => { + const back = importDocx(await buildDocx(doc as any, margins, 'portrait')).content as unknown as N; + expect(links(back)).toEqual(['https://example.com', '#top']); + }); +});