editor: sandbox clip iframe (#9521)

* editor: sandbox clip iframe
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: render webclips via bloburl with strict sandboxing

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
01zulfi
2026-03-17 09:15:57 +05:00
committed by GitHub
parent eadd23de4d
commit 5185273b38
3 changed files with 63 additions and 108 deletions

View File

@@ -28,6 +28,7 @@ import { Icon } from "@notesnook/ui";
import { Resizer } from "../../components/resizer/index.js";
import { useThemeEngineStore } from "@notesnook/theme";
import { useToolbarStore } from "../../toolbar/stores/toolbar-store.js";
import { getSandboxFeatures } from "../../utils/sandbox.js";
export function EmbedComponent(
props: ReactNodeViewProps<EmbedAttributes & EmbedAlignmentOptions>
@@ -157,27 +158,6 @@ export function EmbedComponent(
);
}
function getSandboxFeatures(src: string) {
const features = [];
try {
const url = new URL(src);
if (url.protocol === "http:" || url.protocol === "https:")
features.push(
"allow-scripts",
"allow-same-origin",
"allow-popups",
"allow-popups-to-escape-sandbox",
"allow-forms",
"allow-modals",
"allow-downloads",
"allow-presentation"
);
} catch {
// ignore
}
return features.join(" ");
}
function isYouTubeEmbed(urlString: string) {
try {
const url = new URL(urlString);

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Flex, Text } from "@theme-ui/components";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { ReactNodeViewProps } from "../react/index.js";
import { Icons } from "../../toolbar/index.js";
import { Icon } from "@notesnook/ui";
@@ -36,45 +36,34 @@ const FAILED_CONTENT = `<html><head>
export function WebClipComponent(props: ReactNodeViewProps<WebClipAttributes>) {
const { editor, selected, node, updateAttributes } = props;
const [isLoading, setIsLoading] = useState(true);
const embedRef = useRef<HTMLIFrameElement>(null);
const resizeObserverRef = useRef<ResizeObserver>();
const { src, title, fullscreen, progress } = node.attrs;
const { src, title, progress } = node.attrs;
const [source, setSource] = useState<string>();
useEffect(() => {
(async function () {
if (!isLoading) return;
const html = await editor.storage
.getAttachmentData?.(node.attrs)
.catch(() => null);
const iframe = embedRef.current;
if (!iframe || !iframe.contentDocument) return;
iframe.contentDocument.open();
iframe.contentDocument.write(
typeof html !== "string" || !html ? FAILED_CONTENT : html
const doc = new DOMParser().parseFromString(
html || FAILED_CONTENT,
"text/html"
);
iframe.contentDocument.close();
iframe.contentDocument.head.innerHTML += `<base target="_blank">`;
setIsLoading(false);
doc.head.innerHTML += `<base target="_blank">`;
const blob = new Blob([doc.documentElement.outerHTML], {
type: "text/html"
});
const blobUrl = URL.createObjectURL(blob);
setSource(blobUrl);
return () => {
URL.revokeObjectURL(blobUrl);
};
})();
}, []);
useEffect(() => {
function fullscreenchanged() {
if (document.fullscreenElement) {
resizeObserverRef.current?.disconnect();
resetIframeSize(embedRef.current);
} else {
if (!document.fullscreenElement) {
updateAttributes({ fullscreen: false });
resizeIframe(node.attrs, embedRef.current);
if (embedRef.current?.contentDocument) {
resizeObserverRef.current?.observe(
embedRef.current?.contentDocument?.body
);
}
}
}
@@ -84,20 +73,6 @@ export function WebClipComponent(props: ReactNodeViewProps<WebClipAttributes>) {
};
}, [updateAttributes]);
useEffect(() => {
if (embedRef.current?.contentDocument) {
resizeObserverRef.current = new ResizeObserver(() => {
setTimeout(() => resizeIframe(node.attrs, embedRef.current), 100);
});
resizeObserverRef.current.observe(
embedRef.current?.contentDocument?.body
);
}
return () => {
resizeObserverRef.current?.disconnect();
};
}, []);
return (
<>
<Box
@@ -183,20 +158,18 @@ export function WebClipComponent(props: ReactNodeViewProps<WebClipAttributes>) {
>
<Box sx={{ overflow: "hidden" }}>
<iframe
ref={embedRef}
width="auto"
frameBorder={"0"}
scrolling={fullscreen ? "yes" : "no"}
style={{ transformOrigin: "0 0", overflow: "hidden" }}
onLoad={() => {
if (fullscreen) return;
resizeIframe(node.attrs, embedRef.current);
style={{
width: "100%",
height: "100vh",
background: "var(--background)"
}}
src={source}
sandbox="allow-popups allow-popups-to-escape-sandbox"
/>
</Box>
</Box>
{isLoading && (
{!source && (
<Flex
sx={{
position: "absolute",
@@ -218,40 +191,3 @@ export function WebClipComponent(props: ReactNodeViewProps<WebClipAttributes>) {
</>
);
}
function resizeIframe(
attributes: WebClipAttributes,
iframe?: HTMLIFrameElement | null
) {
if (!iframe || !iframe.contentDocument || !iframe.contentDocument.body)
return;
const height = attributes.height
? parseInt(attributes.height)
: iframe.contentDocument.body.scrollHeight;
const width = attributes.width
? parseInt(attributes.width)
: iframe.contentDocument.body.scrollWidth;
iframe.style.height = `${height}px`;
iframe.style.width = `${width}px`;
const container = iframe.parentElement;
if (!container || container.clientWidth > width) return;
const scale = container.clientWidth / width;
iframe.style.scale = `${scale}`;
container.style.height = `${height * scale}px`;
}
function resetIframeSize(iframe?: HTMLIFrameElement | null) {
if (!iframe || !iframe.contentDocument || !iframe.contentDocument.body)
return;
const height = iframe.contentDocument.body.scrollHeight;
const width = iframe.contentDocument.body.scrollWidth;
iframe.style.height = `${height}px`;
iframe.style.width = `${width}px`;
iframe.style.scale = `1`;
}

View File

@@ -0,0 +1,39 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export function getSandboxFeatures(src: string) {
const features = [];
try {
const url = new URL(src);
if (url.protocol === "http:" || url.protocol === "https:")
features.push(
"allow-scripts",
"allow-same-origin",
"allow-popups",
"allow-popups-to-escape-sandbox",
"allow-forms",
"allow-modals",
"allow-downloads",
"allow-presentation"
);
} catch {
// ignore
}
return features.join(" ");
}