Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/sheet-engine/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export {
jfrefreshgrid,
// iframe
sanitizeDuneUrl,
sanitizeSheetIframes,
insertDuneChart,
onIframeMoveStart,
onIframeResizeStart,
Expand Down
78 changes: 78 additions & 0 deletions src/sheet-engine/core/modules/iframe.sanitize.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Minimal self-check for sanitizeDuneUrl / sanitizeSheetIframes.
* Run: node src/sheet-engine/core/modules/iframe.sanitize.test.mjs
*
* Mirrors the logic in iframe.ts (keep in sync when changing allowlist).
*/

function sanitizeDuneUrl(input) {
if (typeof input !== 'string') return null;
const trimmed = input.trim();
if (!trimmed) return null;

const iframeAttrMatch = trimmed.match(
/src=["']?(https:\/\/dune\.com\/embeds\/\d+\/\d+)/i,
);
if (iframeAttrMatch) return iframeAttrMatch[1];

const embedMatch = trimmed.match(
/^https:\/\/dune\.com\/embeds\/(\d+)\/(\d+)\/?(?:\?.*)?$/i,
);
if (embedMatch) {
return `https://dune.com/embeds/${embedMatch[1]}/${embedMatch[2]}`;
}

const queryMatch = trimmed.match(
/^https:\/\/dune\.com\/queries\/(\d+)\/(\d+)\/?(?:\?.*)?$/i,
);
if (queryMatch) {
return `https://dune.com/embeds/${queryMatch[1]}/${queryMatch[2]}`;
}

return null;
}

function sanitizeSheetIframes(iframes) {
if (!Array.isArray(iframes)) return [];
const out = [];
for (const frame of iframes) {
const safeSrc = sanitizeDuneUrl(frame?.src ?? '');
if (!safeSrc) continue;
out.push({ ...frame, src: safeSrc });
}
return out;
}

function assert(cond, msg) {
if (!cond) throw new Error(msg);
}

assert(
sanitizeDuneUrl('https://dune.com/embeds/1/2') ===
'https://dune.com/embeds/1/2',
'bare embed',
);
assert(
sanitizeDuneUrl('src="https://dune.com/embeds/9/8"') ===
'https://dune.com/embeds/9/8',
'src attr',
);
assert(
sanitizeDuneUrl('https://dune.com/queries/3/4') ===
'https://dune.com/embeds/3/4',
'query → embed',
);
assert(sanitizeDuneUrl('https://evil.example/x') === null, 'evil rejected');
assert(sanitizeDuneUrl('http://dune.com/embeds/1/2') === null, 'http rejected');
assert(
sanitizeDuneUrl('https://dune.com.evil.com/embeds/1/2') === null,
'host suffix rejected',
);

const cleaned = sanitizeSheetIframes([
{ id: 'a', src: 'https://dune.com/embeds/1/2' },
{ id: 'b', src: 'https://evil.example' },
]);
assert(cleaned.length === 1 && cleaned[0].id === 'a', 'filter list');

console.log('iframe.sanitize.test.mjs: ok');
48 changes: 37 additions & 11 deletions src/sheet-engine/core/modules/iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,47 @@ import { getSheetIndex } from '../utils';
import { mergeBorder } from './cell';
import { generateRandomId } from './image';

// Allowlist Dune chart URLs only (HTTPS embeds / queries).
export function sanitizeDuneUrl(input: string): string | null {
if (typeof input !== 'string') return null;
const trimmed = input.trim();
if (!trimmed) return null;

// Match iframe embed src
const iframeMatch = trimmed.match(
/src=["']?(https:\/\/dune\.com\/embeds\/\d+\/\d+)/,
const iframeAttrMatch = trimmed.match(
/src=["']?(https:\/\/dune\.com\/embeds\/\d+\/\d+)/i,
);
if (iframeMatch) {
return iframeMatch[1];
if (iframeAttrMatch) {
return iframeAttrMatch[1];
}

const embedMatch = trimmed.match(
/^https:\/\/dune\.com\/embeds\/(\d+)\/(\d+)\/?(?:\?.*)?$/i,
);
if (embedMatch) {
return `https://dune.com/embeds/${embedMatch[1]}/${embedMatch[2]}`;
}

// Match query-to-embed conversion
const queryMatch = trimmed.match(
/^https:\/\/dune\.com\/queries\/(\d+)\/(\d+)/,
/^https:\/\/dune\.com\/queries\/(\d+)\/(\d+)\/?(?:\?.*)?$/i,
);
if (queryMatch) {
const [, queryId, vizId] = queryMatch;
return `https://dune.com/embeds/${queryId}/${vizId}`;
return `https://dune.com/embeds/${queryMatch[1]}/${queryMatch[2]}`;
}

return null; // Not a supported chart
return null;
}

export function sanitizeSheetIframes<T extends { src?: string }>(
iframes: T[] | null | undefined,
): T[] {
if (!Array.isArray(iframes)) return [];
const out: T[] = [];
for (const frame of iframes) {
const safeSrc = sanitizeDuneUrl(frame?.src ?? '');
if (!safeSrc) continue;
out.push({ ...frame, src: safeSrc });
}
return out;
}

export function saveIframe(ctx: Context) {
Expand All @@ -37,6 +57,12 @@ export function saveIframe(ctx: Context) {

export function insertIframe(ctx: Context, src: string) {
try {
const safeSrc = sanitizeDuneUrl(src);
if (!safeSrc) {
console.warn('Unsupported iframe src:', src);
return;
}

const last =
ctx.luckysheet_select_save?.[ctx.luckysheet_select_save.length - 1];
const rowIndex = last?.row_focus ?? last?.row?.[0] ?? 0;
Expand All @@ -55,7 +81,7 @@ export function insertIframe(ctx: Context, src: string) {

const iframe = {
id: generateRandomId('iframe'),
src,
src: safeSrc,
left,
top,
width: 400,
Expand Down
1 change: 1 addition & 0 deletions src/sheet-engine/core/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export { jfrefreshgrid } from './refresh';
// iframe
export {
sanitizeDuneUrl,
sanitizeSheetIframes,
insertDuneChart,
onIframeMoveStart,
onIframeResizeStart,
Expand Down
45 changes: 34 additions & 11 deletions src/sheet-engine/react/components/IFrameBoxs/iFrameBoxs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
onIframeMoveEnd,
onIframeResize,
onIframeResizeEnd,
sanitizeDuneUrl,
} from '@sheet-engine/core';
import WorkbookContext from '../../context';
import './iFrameBoxs.css';
Expand All @@ -15,7 +16,6 @@ const IframeBoxs: React.FC = () => {
const { context, setContext, refs } = useContext(WorkbookContext);
const containerRef = useRef<HTMLDivElement>(null);

// Get current sheet's iframes
const currentSheetIframes = useMemo(() => {
const currentSheet = context.luckysheetfile.find(
(sheet) => sheet.id === context.currentSheetId,
Expand Down Expand Up @@ -52,6 +52,7 @@ const IframeBoxs: React.FC = () => {
<div id="fortune-iframe-boxes" ref={containerRef}>
{currentSheetIframes?.map((frame: any) => {
const isActive = frame.id === context.activeIframe;
const safeSrc = sanitizeDuneUrl(frame.src ?? '');
const style = {
width: frame.width * context.zoomRatio,
height: frame.height * context.zoomRatio,
Expand Down Expand Up @@ -97,16 +98,38 @@ const IframeBoxs: React.FC = () => {
className="luckysheet-modal-dialog-content"
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
>
<iframe
title={`iframe-${frame.id}`}
src={frame.src}
style={{
width: '100%',
height: '100%',
border: 'none',
pointerEvents: 'none',
}}
/>
{safeSrc ? (
<iframe
title={`iframe-${frame.id}`}
src={safeSrc}
sandbox="allow-scripts allow-same-origin allow-popups"
referrerPolicy="no-referrer"
style={{
width: '100%',
height: '100%',
border: 'none',
pointerEvents: 'none',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 12,
boxSizing: 'border-box',
fontSize: 12,
color: '#666',
background: '#f5f5f5',
textAlign: 'center',
}}
>
Embed blocked: unsupported or disallowed source
</div>
)}
</div>

<div className="luckysheet-modal-dialog-border" />
Expand Down
6 changes: 4 additions & 2 deletions src/sheet-engine/react/components/Workbook/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
execFunctionGroup,
jfrefreshgrid,
isFormulaEvalPending,
sanitizeSheetIframes,
} from '@sheet-engine/core';
import { applyPatches } from 'immer';
import _ from 'lodash';
Expand Down Expand Up @@ -484,9 +485,10 @@ export function generateAPIs(
options.id ?? draftCtx.currentSheetId,
);
if (idx == null) return;
draftCtx.luckysheetfile[idx].iframes = iframes;
const safeIframes = sanitizeSheetIframes(iframes);
draftCtx.luckysheetfile[idx].iframes = safeIframes;
if (draftCtx.luckysheetfile[idx].id === draftCtx.currentSheetId) {
draftCtx.insertedIframes = iframes;
draftCtx.insertedIframes = safeIframes;
}
}),

Expand Down