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. *