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.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:12 +02:00
1 parent efed52440d
commit 9f2da39048
3 files changed
+41 -3

No files matched your search

+4 -1
View File
@@ -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);
+4 -2
View File
@@ -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': {
+33
View File
@@ -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']);
});
});