diff --git a/shared/editor/components/Image.tsx b/shared/editor/components/Image.tsx
index 2f41d1f0d6..ceab3a8b17 100644
--- a/shared/editor/components/Image.tsx
+++ b/shared/editor/components/Image.tsx
@@ -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)
diff --git a/shared/editor/lib/Lightbox.ts b/shared/editor/lib/Lightbox.ts
index 9d1643501c..91f02e4c90 100644
--- a/shared/editor/lib/Lightbox.ts
+++ b/shared/editor/lib/Lightbox.ts
@@ -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;
diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx
index 1abbbbf291..a01f85ca20 100644
--- a/shared/editor/nodes/Image.tsx
+++ b/shared/editor/nodes/Image.tsx
@@ -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",
diff --git a/shared/editor/nodes/SimpleImage.tsx b/shared/editor/nodes/SimpleImage.tsx
index 07d12e2571..5f7439fac6 100644
--- a/shared/editor/nodes/SimpleImage.tsx
+++ b/shared/editor/nodes/SimpleImage.tsx
@@ -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",
},
],
diff --git a/shared/utils/urls.test.ts b/shared/utils/urls.test.ts
index 4225433050..80f402c091 100644
--- a/shared/utils/urls.test.ts
+++ b/shared/utils/urls.test.ts
@@ -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,")
+ ).toEqual("https://data:image/svg+xml,");
+ });
+
+ it("should sanitize non-image data URIs", () => {
+ expect(
+ urlsUtils.sanitizeImageSrc("data:text/html,")
+ ).toEqual("https://data:text/html,");
+ });
+
+ 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(
diff --git a/shared/utils/urls.ts b/shared/utils/urls.ts
index 7836363a59..03b5987af9 100644
--- a/shared/utils/urls.ts
+++ b/shared/utils/urls.ts
@@ -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.
*