Add support for data uri for images (#12294)

* Add support for data uri for images

* hoist
This commit is contained in:
Tom Moor
2026-05-08 09:10:32 -04:00
committed by GitHub
parent 9c26535815
commit 571463710e
6 changed files with 114 additions and 19 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { find } from "es-toolkit/compat";
import Flex from "../../components/Flex";
import { s } from "../../styles";
import { isExternalUrl, sanitizeUrl } from "../../utils/urls";
import { isExternalUrl, sanitizeImageSrc } from "../../utils/urls";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import type { ComponentProps } from "../types";
import { ResizeLeft, ResizeRight } from "./ResizeHandle";
@@ -68,7 +68,7 @@ const Image = (props: Props) => {
}
}, [node.attrs.width]);
const sanitizedSrc = sanitizeUrl(src);
const sanitizedSrc = sanitizeImageSrc(src);
const linkMarkType = props.view.state.schema.marks.link;
const imgLink =
find(node.attrs.marks ?? [], (mark) => mark.type === linkMarkType.name)
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Node } from "prosemirror-model";
import { isMermaid } from "./isCode";
import type { EditorView } from "prosemirror-view";
import { sanitizeUrl } from "@shared/utils/urls";
import { sanitizeImageSrc } from "@shared/utils/urls";
export abstract class LightboxImage {
public pos: number;
@@ -21,7 +21,7 @@ class LightboxRegularImage extends LightboxImage {
super();
this.pos = pos;
const node = view.state.doc.nodeAt(pos);
this.src = sanitizeUrl(node?.attrs.src) ?? "";
this.src = sanitizeImageSrc(node?.attrs.src) ?? "";
this.alt = node?.attrs.alt ?? "";
this.source = node?.attrs.source;
this.element = view.nodeDOM(pos) as HTMLSpanElement;
+3 -3
View File
@@ -9,7 +9,7 @@ import type {
import type { Command } from "prosemirror-state";
import { NodeSelection, Plugin, TextSelection } from "prosemirror-state";
import * as React from "react";
import { sanitizeUrl } from "../../utils/urls";
import { sanitizeImageSrc } from "../../utils/urls";
import Caption from "../components/Caption";
import ImageComponent from "../components/Image";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
@@ -88,7 +88,7 @@ export const downloadImageNode = async (
if (cache !== "reload") {
await downloadImageNode(node, "reload");
} else {
window.open(sanitizeUrl(node.attrs.src), "_blank");
window.open(sanitizeImageSrc(node.attrs.src), "_blank");
}
}
};
@@ -211,7 +211,7 @@ export default class Image extends SimpleImage {
"img",
{
...node.attrs,
src: sanitizeUrl(node.attrs.src),
src: sanitizeImageSrc(node.attrs.src),
width: node.attrs.width,
height: node.attrs.height,
contentEditable: "false",
+2 -2
View File
@@ -10,7 +10,7 @@ import { TextSelection, NodeSelection } from "prosemirror-state";
import * as React from "react";
import type { Primitive } from "utility-types";
import { getEventFiles } from "../../utils/files";
import { sanitizeUrl } from "../../utils/urls";
import { sanitizeImageSrc } from "../../utils/urls";
import { AttachmentValidation } from "../../validations";
import type { Options } from "../commands/insertFiles";
import insertFiles from "../commands/insertFiles";
@@ -77,7 +77,7 @@ export default class SimpleImage extends Node {
"img",
{
...node.attrs,
src: sanitizeUrl(node.attrs.src),
src: sanitizeImageSrc(node.attrs.src),
contentEditable: "false",
},
],
+66
View File
@@ -193,6 +193,16 @@ describe("sanitizeUrl", () => {
});
describe("Blocked protocols", () => {
it("should sanitize base64-encoded image data URIs (links should not embed data)", () => {
expect(
urlsUtils.sanitizeUrl(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
).toEqual(
"https://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
);
});
it("should be sanitized", () => {
expect(urlsUtils.sanitizeUrl("file://localhost.com/outline.txt")).toEqual(
"https://file://localhost.com/outline.txt"
@@ -210,6 +220,62 @@ describe("sanitizeUrl", () => {
});
});
describe("sanitizeImageSrc", () => {
it("should return undefined if not a src", () => {
expect(urlsUtils.sanitizeImageSrc(undefined)).toBeUndefined();
expect(urlsUtils.sanitizeImageSrc(null)).toBeUndefined();
expect(urlsUtils.sanitizeImageSrc("")).toBeUndefined();
});
it("should return base64-encoded image data URIs unchanged", () => {
const png =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
expect(urlsUtils.sanitizeImageSrc(png)).toEqual(png);
const gif =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
expect(urlsUtils.sanitizeImageSrc(gif)).toEqual(gif);
const jpeg = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
expect(urlsUtils.sanitizeImageSrc(jpeg)).toEqual(jpeg);
const webp = "data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAA";
expect(urlsUtils.sanitizeImageSrc(webp)).toEqual(webp);
const avif = "data:image/avif;base64,AAAAHGZ0eXBhdmlmAAAAAGF2aWY";
expect(urlsUtils.sanitizeImageSrc(avif)).toEqual(avif);
});
it("should sanitize svg data URIs (can contain inline scripts)", () => {
expect(
urlsUtils.sanitizeImageSrc("data:image/svg+xml;base64,PHN2Zy8+")
).toEqual("https://data:image/svg+xml;base64,PHN2Zy8+");
expect(
urlsUtils.sanitizeImageSrc("data:image/svg;base64,PHN2Zy8+")
).toEqual("https://data:image/svg;base64,PHN2Zy8+");
expect(
urlsUtils.sanitizeImageSrc("data:image/SVG+XML;base64,PHN2Zy8+")
).toEqual("https://data:image/SVG+XML;base64,PHN2Zy8+");
expect(
urlsUtils.sanitizeImageSrc("data:image/svg+xml,<svg></svg>")
).toEqual("https://data:image/svg+xml,<svg></svg>");
});
it("should sanitize non-image data URIs", () => {
expect(
urlsUtils.sanitizeImageSrc("data:text/html,<script>alert('hi');</script>")
).toEqual("https://data:text/html,<script>alert('hi');</script>");
});
it("should fall through to sanitizeUrl behavior for non-data-URI input", () => {
expect(urlsUtils.sanitizeImageSrc("https://example.com/a.png")).toEqual(
"https://example.com/a.png"
);
expect(urlsUtils.sanitizeImageSrc("/uploads/a.png")).toEqual(
"/uploads/a.png"
);
expect(urlsUtils.sanitizeImageSrc("javascript:alert(1)")).toEqual(
"https://javascript:alert(1)"
);
});
});
describe("parseShareIdFromUrl", () => {
it("should return share id from url with doc path", () => {
expect(
+39 -10
View File
@@ -179,6 +179,24 @@ export function isBase64Url(url: string) {
return match ? match : false;
}
const allowedSchemes = [
"mailto:",
"sms:",
"fax:",
"tel:",
"geo:",
"maps:",
"magnet:",
];
const allowedImageDataUris = [
"data:image/png;base64,",
"data:image/jpeg;base64,",
"data:image/gif;base64,",
"data:image/webp;base64,",
"data:image/avif;base64,",
];
/**
* For use in the editor, this function will ensure that a url is
* potentially valid, and filter out unsupported and malicious protocols.
@@ -191,17 +209,7 @@ export function sanitizeUrl(url: string | null | undefined) {
return undefined;
}
const allowedSchemes = [
"mailto:",
"sms:",
"fax:",
"tel:",
"geo:",
"maps:",
"magnet:",
];
const lower = url.toLowerCase();
if (
!isUrl(url, { requireHostname: false }) &&
!url.startsWith("/") &&
@@ -213,6 +221,27 @@ export function sanitizeUrl(url: string | null | undefined) {
return url;
}
/**
* For use in the editor on image-like elements, this function will ensure
* that a src is potentially valid. In addition to the protocols allowed by
* `sanitizeUrl`, base64-encoded image data URIs are permitted (excluding
* SVG, which can contain inline scripts).
*
* @param src The src to sanitize.
* @returns The sanitized src.
*/
export function sanitizeImageSrc(src: string | null | undefined) {
if (!src) {
return undefined;
}
const lower = src.toLowerCase();
if (allowedImageDataUris.some((scheme) => lower.startsWith(scheme))) {
return src;
}
return sanitizeUrl(src);
}
/**
* Returns a regex to match the given url.
*