Skip to content

[Bug]: Editing a Markdown file with inline (data: URI) images deletes them from the file #9108

Description

@yaiol

⚠️ This issue respects the following points: ⚠️

  • This is not a troubleshooting question, general support matter, or webserver/proxy problem, but likely a bug (if unsure, ask the Community Help Forum).
  • This issue is not already reported on Github OR solved at the Community Help Forum (I've searched!).
  • I'm using a maintained major version of Nextcloud Server and tested against the latest patch level. (Supported major versions and current patch levels).
  • I agree to follow Nextcloud's Code of Conduct.
  • I've tried my best to provide clear reproduction steps that someone unfamiliar with this bug could use to reproduce it.

Bug description

Text does not render Markdown images whose source is a data: URI, and — more seriously — once
such a document is opened and edited, the images are removed from the stored file. They are not
shown as broken, or as a placeholder: the image is absent from the document Text builds, so the
next save writes the file back without it. A one-word text edit is enough to destroy every picture
in the document.

Inline data: images are valid Markdown (CommonMark places any URL in ![alt](url)), and they are
the natural format for self-contained documents — a single file that stays complete when it is
moved, copied, shared, or read offline. Several tools export chats, notes and reports this way.

Steps to reproduce

  1. Create a Markdown file containing one inline image, e.g.
    ![a picture](data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ…) — a normal JPEG, nothing exotic.
  2. Upload it to Nextcloud (WebDAV or the Files web UI). Confirm the file on the server still
    contains the data: URI.
  3. Open it in Files. Text displays the document — the image is missing, with no indication
    that anything was omitted.
  4. Change one word of the text and let Text save.
  5. Download the file, or inspect it on the server.

Expected behavior

Either the image renders, or — at minimum — content Text cannot represent is preserved on save.
Silently discarding part of a user's file is the part that matters: the document was complete when
it was opened and is not when it is closed.

Actual behaviour

The image is gone from the stored file.

Measured on a live instance (sizes and MD5s of the same document before and after):

copy size md5
as uploaded 56344 ad73141d3179f82d35e97bf482fe23b7
after opening in Text and editing one line 2888 97f7f445021d9b82fbaa4ff015ae3757

The ![…](data:image/jpeg;base64,…) line is not altered or replaced — it is deleted. Nextcloud's
own file versioning kept the original, which is the only reason nothing was lost; a user without
versions enabled, or one who notices after the retention window, loses the images permanently.

Where it comes from

Text builds image nodes from markdown-it tokens via the image node's parseMarkdown, and the
TipTap Image extension it uses declares:

addOptions() { return { inline: false, allowBase64: false, HTMLAttributes: {}, resize: false } }

parseHTML() { return [{ tag: this.options.allowBase64 ? "img[src]" : 'img[src]:not([src^="data:"])' }] }

allowBase64 is TipTap's own default and Text never sets it, so data: sources are excluded. The
node is therefore never created; the document Text holds has no image in it; and the Markdown
serializer, which writes what the document contains, emits a file without the image.

Worth noting that every other layer already accepts these images:

  • markdown-it, which Text bundles, explicitly permits them — its own validator is
    /^data:image\/(gif|png|jpeg|webp);/, i.e. raster images pass and data:image/svg+xml does not.
  • Nextcloud's CSP already allows data: images (img-src * data: blob:).

So the exclusion is not a considered security position — it is an unset option, inherited from a
library default, that happens to also delete user data.

Suggested fix

  1. Never drop unrepresentable content on save. Whatever is decided about rendering, a document
    that arrives with content Text cannot display should not be written back without it. This is the
    data-loss half and it is independent of the rest.
  2. Allow inert raster data URIs. Enable base64 images for data:image/png, image/jpeg,
    image/gif, image/webp and keep rejecting data:image/svg+xml, which is the only genuinely
    dangerous case (SVG can carry script). markdown-it's existing allow-list is exactly this set, so
    the two layers would finally agree.
  3. If inline images are unwanted as a matter of product design, then say so in the UI — a
    placeholder reading "1 image not displayed" would at least warn the user before they edit and
    lose it.

Workaround (for anyone finding this issue)

Removing the guard from the built chunk restores both rendering and round-trip safety:

:not([src^="data:"])   →   (removed, 3 occurrences)

in apps/text/js/Wrapper-<hash>.chunk.mjs. It is wiped by every update, it fails the code
integrity check, and it allows SVG data URIs too — so it is a stopgap, not a fix.

Nextcloud Server version

34

Operating system

Debian/Ubuntu

PHP engine version

PHP 8.3

Web server

Nginx

Database engine version

MariaDB

Is this bug present after an update or on a fresh install?

Fresh Nextcloud Server install

Are you using the Nextcloud Server Encryption module?

None

What user-backends are you using?

  • Default user-backend (database)
  • LDAP/ Active Directory
  • SSO - SAML
  • Other

Configuration report

List of activated Apps

### Steps to reproduce

1. Create a Markdown file containing one inline image, e.g.
   `![a picture](data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ…)` — a normal JPEG, nothing exotic.
2. Upload it to Nextcloud (WebDAV or the Files web UI). Confirm the file on the server still
   contains the `data:` URI.
3. Open it in Files. Text displays the document — **the image is missing**, with no indication
   that anything was omitted.
4. Change one word of the text and let Text save.
5. Download the file, or inspect it on the server.

### Expected behaviour

Either the image renders, or — at minimum — content Text cannot represent is **preserved on save**.
Silently discarding part of a user's file is the part that matters: the document was complete when
it was opened and is not when it is closed.

### Actual behaviour

The image is gone from the stored file.

Measured on a live instance (sizes and MD5s of the same document before and after):

| copy | size | md5 |
|---|---|---|
| as uploaded | 56344 | `ad73141d3179f82d35e97bf482fe23b7` |
| after opening in Text and editing one line | **2888** | `97f7f445021d9b82fbaa4ff015ae3757` |

The `![…](data:image/jpeg;base64,…)` line is not altered or replaced — it is deleted. Nextcloud's
own file versioning kept the original, which is the only reason nothing was lost; a user without
versions enabled, or one who notices after the retention window, loses the images permanently.

### Where it comes from

Text builds image nodes from markdown-it tokens via the image node's `parseMarkdown`, and the
TipTap `Image` extension it uses declares:


addOptions() { return { inline: false, allowBase64: false, HTMLAttributes: {}, resize: false } }

parseHTML() { return [{ tag: this.options.allowBase64 ? "img[src]" : 'img[src]:not([src^="data:"])' }] }


`allowBase64` is TipTap's own default and Text never sets it, so `data:` sources are excluded. The
node is therefore never created; the document Text holds has no image in it; and the Markdown
serializer, which writes what the document contains, emits a file without the image.

Worth noting that every *other* layer already accepts these images:

- **markdown-it**, which Text bundles, explicitly permits them — its own validator is
  `/^data:image\/(gif|png|jpeg|webp);/`, i.e. raster images pass and `data:image/svg+xml` does not.
- **Nextcloud's CSP** already allows `data:` images (`img-src * data: blob:`).

So the exclusion is not a considered security position — it is an unset option, inherited from a
library default, that happens to also delete user data.

### Suggested fix

1. **Never drop unrepresentable content on save.** Whatever is decided about rendering, a document
   that arrives with content Text cannot display should not be written back without it. This is the
   data-loss half and it is independent of the rest.
2. **Allow inert raster data URIs.** Enable base64 images for `data:image/png`, `image/jpeg`,
   `image/gif`, `image/webp` and keep rejecting `data:image/svg+xml`, which is the only genuinely
   dangerous case (SVG can carry script). markdown-it's existing allow-list is exactly this set, so
   the two layers would finally agree.
3. If inline images are unwanted as a matter of product design, then **say so in the UI** — a
   placeholder reading "1 image not displayed" would at least warn the user before they edit and
   lose it.

### Workaround (for anyone finding this issue)

Removing the guard from the built chunk restores both rendering and round-trip safety:


:not([src^="data:"])   →   (removed, 3 occurrences)


in `apps/text/js/Wrapper-<hash>.chunk.mjs`. It is wiped by every update, it fails the code
integrity check, and it allows SVG data URIs too — so it is a stopgap, not a fix.

### Server configuration

- Nextcloud: **34.0.3**
- Text app: **7.0.1**
- PHP: 8.3.6
- OS: Ubuntu 24.04 LTS
- Web server: nginx
- Database: MariaDB 10.11

### Client

Chromium-based browser, desktop. Not browser-specific — the content never reaches the DOM.

Nextcloud Signing status

Nextcloud Logs

Additional info

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions