diff --git a/src/sheet-engine/core/index.ts b/src/sheet-engine/core/index.ts index 6311d374..0793512c 100644 --- a/src/sheet-engine/core/index.ts +++ b/src/sheet-engine/core/index.ts @@ -296,6 +296,7 @@ export { jfrefreshgrid, // iframe sanitizeDuneUrl, + sanitizeSheetIframes, insertDuneChart, onIframeMoveStart, onIframeResizeStart, diff --git a/src/sheet-engine/core/modules/iframe.sanitize.test.mjs b/src/sheet-engine/core/modules/iframe.sanitize.test.mjs new file mode 100644 index 00000000..45d79147 --- /dev/null +++ b/src/sheet-engine/core/modules/iframe.sanitize.test.mjs @@ -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'); diff --git a/src/sheet-engine/core/modules/iframe.ts b/src/sheet-engine/core/modules/iframe.ts index 00948e95..668cc37c 100644 --- a/src/sheet-engine/core/modules/iframe.ts +++ b/src/sheet-engine/core/modules/iframe.ts @@ -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( + 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) { @@ -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; @@ -55,7 +81,7 @@ export function insertIframe(ctx: Context, src: string) { const iframe = { id: generateRandomId('iframe'), - src, + src: safeSrc, left, top, width: 400, diff --git a/src/sheet-engine/core/modules/index.ts b/src/sheet-engine/core/modules/index.ts index e5d3fb2d..dba85666 100644 --- a/src/sheet-engine/core/modules/index.ts +++ b/src/sheet-engine/core/modules/index.ts @@ -371,6 +371,7 @@ export { jfrefreshgrid } from './refresh'; // iframe export { sanitizeDuneUrl, + sanitizeSheetIframes, insertDuneChart, onIframeMoveStart, onIframeResizeStart, diff --git a/src/sheet-engine/react/components/IFrameBoxs/iFrameBoxs.tsx b/src/sheet-engine/react/components/IFrameBoxs/iFrameBoxs.tsx index 73184b04..8f92cacd 100644 --- a/src/sheet-engine/react/components/IFrameBoxs/iFrameBoxs.tsx +++ b/src/sheet-engine/react/components/IFrameBoxs/iFrameBoxs.tsx @@ -7,6 +7,7 @@ import { onIframeMoveEnd, onIframeResize, onIframeResizeEnd, + sanitizeDuneUrl, } from '@sheet-engine/core'; import WorkbookContext from '../../context'; import './iFrameBoxs.css'; @@ -15,7 +16,6 @@ const IframeBoxs: React.FC = () => { const { context, setContext, refs } = useContext(WorkbookContext); const containerRef = useRef(null); - // Get current sheet's iframes const currentSheetIframes = useMemo(() => { const currentSheet = context.luckysheetfile.find( (sheet) => sheet.id === context.currentSheetId, @@ -52,6 +52,7 @@ const IframeBoxs: React.FC = () => {
{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, @@ -97,16 +98,38 @@ const IframeBoxs: React.FC = () => { className="luckysheet-modal-dialog-content" style={{ width: '100%', height: '100%', overflow: 'hidden' }} > -