From 51a48283b010d26b5223d57461efa1fd4c61b5fc Mon Sep 17 00:00:00 2001 From: Guillaume Flambard Date: Wed, 2 Sep 2026 11:33:57 +0200 Subject: [PATCH 1/2] fix(image): keep data: URI images on markdown round-trip Markdown files can legitimately contain base64 images. Both image parse rules excluded img[src^=data:] because the TipTap allowBase64 option defaults to false, so those images never made it into the document and were silently dropped from the file on the next save (#9108). Enable allowBase64 on the Image and ImageInline extensions: the file content is the user's own, and the editor is expected to preserve it, not to filter it. Regression test covers both the block (figure) and the inline image path. Signed-off-by: Guillaume Flambard --- src/nodes/Image.ts | 4 ++++ src/nodes/ImageInline.ts | 3 +++ src/tests/markdown.spec.js | 7 +++++++ 3 files changed, 14 insertions(+) diff --git a/src/nodes/Image.ts b/src/nodes/Image.ts index 405dc92a694..303564640a7 100644 --- a/src/nodes/Image.ts +++ b/src/nodes/Image.ts @@ -54,6 +54,10 @@ const Image = TiptapImage.extend({ return { ...this.parent?.() as ImageOptions, noLazyImages: false, + // Markdown files can legitimately contain base64 data: URI images. + // Parsing them is required, otherwise they are silently dropped on + // the next save (issue #9108). + allowBase64: true, } }, diff --git a/src/nodes/ImageInline.ts b/src/nodes/ImageInline.ts index 75dbeeea00f..168aaabab32 100644 --- a/src/nodes/ImageInline.ts +++ b/src/nodes/ImageInline.ts @@ -49,6 +49,9 @@ const ImageInline = TiptapImage.extend({ ...this.parent?.() as ImageOptions, noLazyImages: false, inline: true, + // See Image.ts: data: URI images must survive an edit round-trip + // instead of being dropped on save (issue #9108). + allowBase64: true, } }, diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 2f5bb9e5011..57015b673c1 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -38,6 +38,13 @@ describe('Markdown though editor', () => { expect(markdownThroughEditor('~~Test~~')).toBe('~~Test~~') expect(markdownThroughEditor('Have an `inline code` element')).toBe('Have an `inline code` element') }) + test('images with data: URI survive a round-trip (#9108)', ({ markdownThroughEditor }) => { + const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' + // standalone image (block level, wrapped in a figure) + expect(markdownThroughEditor(`![pixel](${dataUri})`)).toBe(`![pixel](${dataUri})`) + // inline image inside a paragraph + expect(markdownThroughEditor(`Before ![pixel](${dataUri}) after`)).toBe(`Before ![pixel](${dataUri}) after`) + }) test('ul', ({ markdownThroughEditor }) => { expect(markdownThroughEditor('+ foo\n+ bar')).toBe('+ foo\n+ bar') expect(markdownThroughEditor('* foo\n* bar')).toBe('* foo\n* bar') From 072c571a874a48ea9c7cb342fec075089709ae0d Mon Sep 17 00:00:00 2001 From: Guillaume Flambard Date: Sun, 6 Sep 2026 19:25:24 +0200 Subject: [PATCH 2/2] fix(image): only parse data: URIs whose mime type is an image one allowBase64 flips the parse selector to accept any data: URI, with no mime filtering. The CSP already refuses to load a data: script, object or frame, and the src only ever reaches an , so a non image payload cannot execute. This narrows the parse rules anyway, so a malformed mime type never becomes a node in the first place rather than relying on those two properties. The syntax then stays literal text, which keeps the user's characters intact and preserves the intent of #9108: nothing is dropped on save, it is simply not promoted to an image whose src could never render. Assisted-by: claude-code:claude-opus-5 Signed-off-by: Guillaume Flambard --- src/nodes/Image.ts | 13 ++++++++----- src/nodes/ImageInline.ts | 13 ++++++++----- src/tests/markdown.spec.js | 9 +++++++++ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/nodes/Image.ts b/src/nodes/Image.ts index 303564640a7..825eb1dd0de 100644 --- a/src/nodes/Image.ts +++ b/src/nodes/Image.ts @@ -35,12 +35,15 @@ const Image = TiptapImage.extend({ }, parseHTML() { + if (!this.options.allowBase64) { + return [{ tag: 'figure img[src]:not([src^="data:"])' }] + } + // With base64 parsing on, admit a data: URI only when its mime type is an + // image one. A data:text/html src then never becomes a node, so the parse + // rule does not have to rely on the content security policy alone. return [ - { - tag: this.options.allowBase64 - ? 'figure img[src]' - : 'figure img[src]:not([src^="data:"])', - }, + { tag: 'figure img[src]:not([src^="data:"])' }, + { tag: 'figure img[src^="data:image/"]' }, ] }, diff --git a/src/nodes/ImageInline.ts b/src/nodes/ImageInline.ts index 168aaabab32..4f123d0c03f 100644 --- a/src/nodes/ImageInline.ts +++ b/src/nodes/ImageInline.ts @@ -35,12 +35,15 @@ const ImageInline = TiptapImage.extend({ }, parseHTML() { + if (!this.options.allowBase64) { + return [{ tag: 'img[src]:not([src^="data:"])' }] + } + // With base64 parsing on, admit a data: URI only when its mime type is an + // image one. A data:text/html src then never becomes a node, so the parse + // rule does not have to rely on the content security policy alone. return [ - { - tag: this.options.allowBase64 - ? 'img[src]' - : 'img[src]:not([src^="data:"])', - }, + { tag: 'img[src]:not([src^="data:"])' }, + { tag: 'img[src^="data:image/"]' }, ] }, diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 57015b673c1..614bcd69bdf 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -45,6 +45,15 @@ describe('Markdown though editor', () => { // inline image inside a paragraph expect(markdownThroughEditor(`Before ![pixel](${dataUri}) after`)).toBe(`Before ![pixel](${dataUri}) after`) }) + test('a non image data: URI does not become an image node (#9108)', ({ markdownThroughEditor }) => { + // allowBase64 on its own admits any mime type. The parse rules narrow it to + // data:image/, so the syntax stays literal text instead of turning into an + // image whose src could never render. The user's characters are kept either + // way, which is the point of #9108. + const htmlUri = 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==' + expect(markdownThroughEditor(`![x](${htmlUri})`)).toBe(`\\![x](${htmlUri})`) + expect(markdownThroughEditor(`Before ![x](${htmlUri}) after`)).toBe(`Before \\![x](${htmlUri}) after`) + }) test('ul', ({ markdownThroughEditor }) => { expect(markdownThroughEditor('+ foo\n+ bar')).toBe('+ foo\n+ bar') expect(markdownThroughEditor('* foo\n* bar')).toBe('* foo\n* bar')