mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Add support for data uri for images (#12294)
* Add support for data uri for images * hoist
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user