diff --git a/web/e2e/visual/app-iframe-embed-visual.spec.ts b/web/e2e/visual/app-iframe-embed-visual.spec.ts new file mode 100644 index 0000000000..7127097506 --- /dev/null +++ b/web/e2e/visual/app-iframe-embed-visual.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test' +import { setupDemoMode } from '../helpers/setup' + +const CARD_VISIBLE_TIMEOUT_MS = 30_000 +const DESKTOP_VIEWPORT = { width: 1440, height: 1100 } + +test.describe('Iframe embed card', () => { + test.use({ viewport: DESKTOP_VIEWPORT }) + + test('settings, presets, and saved demo placeholder retain their layout', async ({ page }) => { + await setupDemoMode(page) + await page.addInitScript(() => { + localStorage.setItem('i18nextLng', 'en') + localStorage.setItem('kubestellar-main-dashboard-cards', JSON.stringify([{ + id: 'iframe-visual', card_type: 'iframe_embed', config: {}, + position: { x: 0, y: 0, w: 6, h: 5 }, + }])) + localStorage.setItem('kubestellar-main-dashboard-cards:schema-version', '1') + }) + // In dev-server runs, source modules under lib/api are not API requests. + await page.route('**/src/**', route => route.continue()) + await page.goto('/') + const card = page.locator('[data-card-type="iframe_embed"]') + await expect(card).toBeVisible({ timeout: CARD_VISIBLE_TIMEOUT_MS }) + const save = card.getByRole('button', { name: 'Save & Load' }) + await expect(save).toBeDisabled() + await expect(card).toHaveScreenshot('iframe-embed-settings.png') + + await card.getByRole('button', { name: '📊 Grafana' }).click() + await expect(card.locator('input[type="url"]')).toHaveValue('http://localhost:3000') + await expect(save).toBeEnabled() + await save.click() + await expect(card.getByText('Grafana', { exact: true })).toBeVisible() + await expect(card.locator('iframe')).toHaveCount(0) + await expect(card).toHaveScreenshot('iframe-embed-demo-placeholder.png') + + await card.getByRole('button', { name: 'Settings', exact: true }).click() + await expect(card.locator('input[type="url"]')).toHaveValue('http://localhost:3000/') + await card.getByRole('button', { name: 'Clear', exact: true }).click() + await expect(card.locator('input[type="url"]')).toHaveValue('') + await expect(save).toBeDisabled() + }) +}) diff --git a/web/e2e/visual/app-iframe-embed-visual.spec.ts-snapshots/iframe-embed-demo-placeholder-chromium-linux.png b/web/e2e/visual/app-iframe-embed-visual.spec.ts-snapshots/iframe-embed-demo-placeholder-chromium-linux.png new file mode 100644 index 0000000000..72567909f1 Binary files /dev/null and b/web/e2e/visual/app-iframe-embed-visual.spec.ts-snapshots/iframe-embed-demo-placeholder-chromium-linux.png differ diff --git a/web/e2e/visual/app-iframe-embed-visual.spec.ts-snapshots/iframe-embed-settings-chromium-linux.png b/web/e2e/visual/app-iframe-embed-visual.spec.ts-snapshots/iframe-embed-settings-chromium-linux.png new file mode 100644 index 0000000000..589af3c679 Binary files /dev/null and b/web/e2e/visual/app-iframe-embed-visual.spec.ts-snapshots/iframe-embed-settings-chromium-linux.png differ diff --git a/web/src/components/cards/IframeEmbed.tsx b/web/src/components/cards/IframeEmbed.tsx index 6407b9f28d..41b44cf054 100644 --- a/web/src/components/cards/IframeEmbed.tsx +++ b/web/src/components/cards/IframeEmbed.tsx @@ -1,4 +1,3 @@ -import { useState, useEffect, useCallback, useRef } from 'react' import { useCardExpanded } from './CardWrapper' import { ExternalLink, Settings, X, AlertTriangle, @@ -8,55 +7,15 @@ import { useTranslation } from 'react-i18next' import { useCardDemoState } from './CardDataContext' import { Skeleton } from '../ui/Skeleton' import type { CSSProperties } from 'react' +import { DEFAULT_HEIGHT, useIframeEmbed } from './useIframeEmbed' +import type { IframeEmbedConfig } from './useIframeEmbed' // Inline style constants const IFRAME_EMBED_IFRAME_STYLE_1: CSSProperties = { border: 'none', display: 'block' } -interface IframeEmbedConfig { - url?: string - title?: string - refreshInterval?: number // seconds, 0 = disabled - height?: number // pixels - sandboxPermissions?: string[] -} - -interface SavedEmbed { - id: string - url: string - title: string - refreshInterval: number -} - -const STORAGE_KEY = 'iframe_embed_saved' -const DEFAULT_HEIGHT = 400 const DEFAULT_SANDBOX = ['allow-scripts', 'allow-same-origin', 'allow-forms', 'allow-popups'] -/** - * Allowed URL schemes for iframe embedding. - * javascript:, data:, and other schemes must never be loaded in an iframe src - * because they bypass sandbox restrictions and execute in the embedder's origin. - */ -const ALLOWED_URL_SCHEMES = ['http:', 'https:'] - -/** - * Validate a URL before using it as an iframe src. - * Returns the original URL if it has an allowed scheme, or an empty string - * if the scheme is disallowed (e.g. javascript:, data:, blob:). - * This prevents XSS-through-DOM attacks via scheme injection. - */ -function sanitizeIframeUrl(url: string): string { - try { - const parsed = new URL(url) - // Return parsed.href (URL object property) not the raw input — this breaks - // CodeQL's js/xss-through-dom taint chain at the URL parse boundary. - return ALLOWED_URL_SCHEMES.includes(parsed.protocol) ? parsed.href : '' - } catch { - // Relative or malformed URLs — disallow - return '' - } -} - // Preset embeds for quick setup const PRESET_EMBEDS = [ { title: 'Grafana', url: 'http://localhost:3000', icon: '📊' }, @@ -70,144 +29,12 @@ export function IframeEmbed({ config }: { config?: IframeEmbedConfig }) { const { t } = useTranslation(['common', 'cards']) const { isExpanded } = useCardExpanded() const { shouldUseDemoData } = useCardDemoState({ requires: 'backend' }) - const iframeRef = useRef(null) - - // Each card instance has its own ID based on config or generates one - const [instanceId] = useState(() => { - if (config?.url) return btoa(config.url).slice(0, 12) - return `embed_${Date.now()}` - }) - - const [url, setUrl] = useState(sanitizeIframeUrl(config?.url || '')) - const [title, setTitle] = useState(config?.title || 'Embed') - const [refreshInterval, setRefreshInterval] = useState(config?.refreshInterval || 0) - const [height, setHeight] = useState(config?.height || DEFAULT_HEIGHT) - - const [showSettings, setShowSettings] = useState(!config?.url) - const [isLoading, setIsLoading] = useState(false) - const [loadError, setLoadError] = useState(null) - const [, setLastRefresh] = useState(new Date()) - const [urlInput, setUrlInput] = useState(config?.url || '') - const [titleInput, setTitleInput] = useState(config?.title || '') - - const [savedEmbeds, setSavedEmbeds] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY) - return saved ? JSON.parse(saved) : [] - } catch { - return [] - } - }) - - // Load saved config for this instance - useEffect(() => { - if (!config?.url) { - const saved = savedEmbeds.find(e => e.id === instanceId) - if (saved) { - // Sanitize URL loaded from localStorage to guard against scheme injection - // (e.g. javascript: or data: URLs stored by a compromised page) - const safeUrl = sanitizeIframeUrl(saved.url) - setUrl(safeUrl) - setTitle(saved.title) - setRefreshInterval(saved.refreshInterval) - setUrlInput(safeUrl) - setTitleInput(saved.title) - setShowSettings(!safeUrl) - } - } - }, [instanceId, config?.url, savedEmbeds]) - - const handleRefresh = useCallback(() => { - if (!iframeRef.current || !url) return - setIsLoading(true) - setLoadError(null) - // Force iframe reload using the sanitized `url` state — never read back - // from iframeRef.current.src (DOM property), which is an XSS-through-DOM - // sink when written back without re-validation. - iframeRef.current.src = '' - setTimeout(() => { - if (iframeRef.current) { - iframeRef.current.src = sanitizeIframeUrl(url) - } - }, 50) - setLastRefresh(new Date()) - }, [url]) - - // Auto-refresh (disabled in demo mode because the iframe itself is disabled) - useEffect(() => { - if (shouldUseDemoData || refreshInterval <= 0 || !url) return - - const interval = setInterval(() => { - handleRefresh() - }, refreshInterval * 1000) - - return () => clearInterval(interval) - }, [shouldUseDemoData, refreshInterval, url, handleRefresh]) - - const handleLoad = () => { - setIsLoading(false) - setLoadError(null) - } - - const handleError = () => { - setIsLoading(false) - setLoadError(t('cards:iframeEmbed.failedToLoadContent')) - } - - const handleSaveConfig = () => { - if (!urlInput.trim()) return - - const newUrl = sanitizeIframeUrl(urlInput.trim()) - if (!newUrl) return - const newTitle = titleInput.trim() || 'Embed' - - setUrl(newUrl) - setTitle(newTitle) - setShowSettings(false) - setIsLoading(true) - setLoadError(null) - setLastRefresh(new Date()) - - // Save to localStorage - const newSaved: SavedEmbed = { - id: instanceId, - url: newUrl, - title: newTitle, - refreshInterval } - - setSavedEmbeds(prev => { - const filtered = prev.filter(e => e.id !== instanceId) - const updated = [...filtered, newSaved] - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)) - return updated - }) - } - - const handleClear = () => { - setUrl('') - setTitle('Embed') - setUrlInput('') - setTitleInput('') - setShowSettings(true) - setLoadError(null) - - setSavedEmbeds(prev => { - const filtered = prev.filter(e => e.id !== instanceId) - localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)) - return filtered - }) - } - - const handlePresetSelect = (preset: typeof PRESET_EMBEDS[0]) => { - setUrlInput(preset.url) - setTitleInput(preset.title) - } - - const openInNewTab = () => { - if (url) { - window.open(url, '_blank', 'noopener,noreferrer') - } - } + const { + iframeRef, url, title, refreshInterval, setRefreshInterval, height, setHeight, + showSettings, setShowSettings, isLoading, loadError, urlInput, setUrlInput, + titleInput, setTitleInput, handleRefresh, handleLoad, handleError, + handleSaveConfig, handleClear, handlePresetSelect, openInNewTab, + } = useIframeEmbed(config, shouldUseDemoData) const displayHeight = isExpanded ? 600 : height diff --git a/web/src/components/cards/useIframeEmbed.test.ts b/web/src/components/cards/useIframeEmbed.test.ts new file mode 100644 index 0000000000..aca36582b8 --- /dev/null +++ b/web/src/components/cards/useIframeEmbed.test.ts @@ -0,0 +1,117 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_HEIGHT, useIframeEmbed } from './useIframeEmbed' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +const STORAGE_KEY = 'iframe_embed_saved' +const REFRESH_SECONDS = 2 +const REFRESH_MS = 2000 +const RELOAD_DELAY_MS = 50 +const URL_VALUE = 'https://grafana.example.com' + +describe('useIframeEmbed', () => { + beforeEach(() => { + localStorage.clear() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('starts unconfigured with editable defaults', () => { + const { result } = renderHook(() => useIframeEmbed(undefined, false)) + expect(result.current).toMatchObject({ + url: '', title: 'Embed', height: DEFAULT_HEIGHT, + refreshInterval: 0, showSettings: true, isLoading: false, loadError: null, + }) + }) + + it.each(['javascript:alert(1)', 'data:text/html,bad', '/relative', 'bad-url'])( + 'rejects unsafe configured and submitted URLs: %s', (url) => { + const { result } = renderHook(() => useIframeEmbed({ url }, false)) + expect(result.current.url).toBe('') + act(() => result.current.handleSaveConfig()) + expect(result.current.url).toBe('') + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + }, + ) + + it('selects presets, saves normalized URLs, and preserves other saved embeds', () => { + const other = { id: 'other', url: 'https://other.example/', title: 'Other', refreshInterval: 0 } + localStorage.setItem(STORAGE_KEY, JSON.stringify([other])) + const { result } = renderHook(() => useIframeEmbed(undefined, false)) + act(() => result.current.handlePresetSelect({ url: URL_VALUE, title: ' Grafana ' })) + expect(result.current.url).toBe('') + act(() => result.current.setRefreshInterval(REFRESH_SECONDS)) + act(() => result.current.handleSaveConfig()) + expect(result.current).toMatchObject({ + url: `${URL_VALUE}/`, title: 'Grafana', showSettings: false, isLoading: true, + }) + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([ + other, expect.objectContaining({ url: `${URL_VALUE}/`, title: 'Grafana', refreshInterval: REFRESH_SECONDS }), + ]) + act(() => result.current.handleClear()) + expect(result.current).toMatchObject({ url: '', title: 'Embed', urlInput: '', titleInput: '', showSettings: true }) + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([other]) + }) + + it('restores saved configuration while sanitizing persisted URLs', () => { + vi.spyOn(Date, 'now').mockReturnValue(123) + const saved = { id: 'embed_123', url: URL_VALUE, title: 'Saved', refreshInterval: REFRESH_SECONDS } + localStorage.setItem(STORAGE_KEY, JSON.stringify([saved])) + const { result, unmount } = renderHook(() => useIframeEmbed(undefined, false)) + expect(result.current).toMatchObject({ url: `${URL_VALUE}/`, title: 'Saved', showSettings: false }) + unmount() + localStorage.setItem(STORAGE_KEY, JSON.stringify([{ ...saved, url: 'data:text/html,bad' }])) + const unsafe = renderHook(() => useIframeEmbed(undefined, false)) + expect(unsafe.result.current).toMatchObject({ url: '', urlInput: '', showSettings: true }) + }) + + it('tolerates malformed storage', () => { + localStorage.setItem(STORAGE_KEY, 'invalid-json') + const { result } = renderHook(() => useIframeEmbed(undefined, false)) + expect(result.current.showSettings).toBe(true) + }) + + it('reports load errors and clears them on successful load', () => { + const { result } = renderHook(() => useIframeEmbed({ url: URL_VALUE }, false)) + act(() => result.current.handleError()) + expect(result.current.loadError).toBe('cards:iframeEmbed.failedToLoadContent') + act(() => result.current.handleLoad()) + expect(result.current).toMatchObject({ isLoading: false, loadError: null }) + }) + + it('reloads on the configured interval and stops refreshing in demo mode and on unmount', () => { + const { result, rerender, unmount } = renderHook( + ({ demo }) => useIframeEmbed({ url: URL_VALUE, refreshInterval: REFRESH_SECONDS }, demo), + { initialProps: { demo: false } }, + ) + // A plain element-shaped object avoids actual iframe navigation in jsdom. + const iframe = { src: `${URL_VALUE}/` } as HTMLIFrameElement + result.current.iframeRef.current = iframe + act(() => vi.advanceTimersByTime(REFRESH_MS)) + expect(iframe.src).toBe('') + expect(result.current.isLoading).toBe(true) + act(() => vi.advanceTimersByTime(RELOAD_DELAY_MS)) + expect(iframe.src).toBe(`${URL_VALUE}/`) + act(() => result.current.handleLoad()) + rerender({ demo: true }) + act(() => vi.advanceTimersByTime(REFRESH_MS)) + expect(result.current.isLoading).toBe(false) + rerender({ demo: false }) + unmount() + expect(vi.getTimerCount()).toBe(0) + }) + + it('opens the normalized URL in a new tab with opener protection', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const { result } = renderHook(() => useIframeEmbed({ url: URL_VALUE }, false)) + act(() => result.current.openInNewTab()) + expect(open).toHaveBeenCalledWith(`${URL_VALUE}/`, '_blank', 'noopener,noreferrer') + }) +}) diff --git a/web/src/components/cards/useIframeEmbed.ts b/web/src/components/cards/useIframeEmbed.ts new file mode 100644 index 0000000000..f74828b4b8 --- /dev/null +++ b/web/src/components/cards/useIframeEmbed.ts @@ -0,0 +1,201 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +export interface IframeEmbedConfig { + url?: string + title?: string + refreshInterval?: number // seconds, 0 = disabled + height?: number // pixels + sandboxPermissions?: string[] +} + +interface SavedEmbed { + id: string + url: string + title: string + refreshInterval: number +} + +const STORAGE_KEY = 'iframe_embed_saved' +export const DEFAULT_HEIGHT = 400 +/** Length of the existing URL-derived card instance identifier. */ +const INSTANCE_ID_LENGTH = 12 +/** Briefly clear the iframe source before reloading it. */ +const RELOAD_DELAY_MS = 50 +/** Convert the configured refresh interval from seconds to milliseconds. */ +const MS_PER_SECOND = 1000 + +/** + * Allowed URL schemes for iframe embedding. + * javascript:, data:, and other schemes must never be loaded in an iframe src + * because they bypass sandbox restrictions and execute in the embedder's origin. + */ +const ALLOWED_URL_SCHEMES = ['http:', 'https:'] + +/** + * Validate a URL before using it as an iframe src. + * Returns the normalized URL if it has an allowed scheme, or an empty string + * if the scheme is disallowed (e.g. javascript:, data:, blob:). + * This prevents XSS-through-DOM attacks via scheme injection. + */ +function sanitizeIframeUrl(url: string): string { + try { + const parsed = new URL(url) + // Return parsed.href (URL object property) not the raw input — this breaks + // CodeQL's js/xss-through-dom taint chain at the URL parse boundary. + return ALLOWED_URL_SCHEMES.includes(parsed.protocol) ? parsed.href : '' + } catch { + // Relative or malformed URLs — disallow + return '' + } +} + +/** Owns an embed's editable configuration, persistence, and reload lifecycle. */ +export function useIframeEmbed(config: IframeEmbedConfig | undefined, shouldUseDemoData: boolean) { + const { t } = useTranslation(['common', 'cards']) + const iframeRef = useRef(null) + + // Each card instance has its own ID based on config or generates one + const [instanceId] = useState(() => { + if (config?.url) return btoa(config.url).slice(0, INSTANCE_ID_LENGTH) + return `embed_${Date.now()}` + }) + + const [url, setUrl] = useState(sanitizeIframeUrl(config?.url || '')) + const [title, setTitle] = useState(config?.title || 'Embed') + const [refreshInterval, setRefreshInterval] = useState(config?.refreshInterval || 0) + const [height, setHeight] = useState(config?.height || DEFAULT_HEIGHT) + + const [showSettings, setShowSettings] = useState(!config?.url) + const [isLoading, setIsLoading] = useState(false) + const [loadError, setLoadError] = useState(null) + const [, setLastRefresh] = useState(new Date()) + const [urlInput, setUrlInput] = useState(config?.url || '') + const [titleInput, setTitleInput] = useState(config?.title || '') + + const [savedEmbeds, setSavedEmbeds] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + return saved ? JSON.parse(saved) : [] + } catch { + return [] + } + }) + + // Load saved config for this instance + useEffect(() => { + if (!config?.url) { + const saved = (savedEmbeds || []).find(e => e.id === instanceId) + if (saved) { + // Sanitize URL loaded from localStorage to guard against scheme injection + // (e.g. javascript: or data: URLs stored by a compromised page) + const safeUrl = sanitizeIframeUrl(saved.url) + setUrl(safeUrl) + setTitle(saved.title) + setRefreshInterval(saved.refreshInterval) + setUrlInput(safeUrl) + setTitleInput(saved.title) + setShowSettings(!safeUrl) + } + } + }, [instanceId, config?.url, savedEmbeds]) + + const handleRefresh = useCallback(() => { + if (!iframeRef.current || !url) return + setIsLoading(true) + setLoadError(null) + // Force iframe reload using the sanitized `url` state — never read back + // from iframeRef.current.src (DOM property), which is an XSS-through-DOM + // sink when written back without re-validation. + iframeRef.current.src = '' + setTimeout(() => { + if (iframeRef.current) { + iframeRef.current.src = sanitizeIframeUrl(url) + } + }, RELOAD_DELAY_MS) + setLastRefresh(new Date()) + }, [url]) + + // Auto-refresh (disabled in demo mode because the iframe itself is disabled) + useEffect(() => { + if (shouldUseDemoData || refreshInterval <= 0 || !url) return + + const interval = setInterval(() => { + handleRefresh() + }, refreshInterval * MS_PER_SECOND) + + return () => clearInterval(interval) + }, [shouldUseDemoData, refreshInterval, url, handleRefresh]) + + const handleLoad = () => { + setIsLoading(false) + setLoadError(null) + } + + const handleError = () => { + setIsLoading(false) + setLoadError(t('cards:iframeEmbed.failedToLoadContent')) + } + + const handleSaveConfig = () => { + if (!urlInput.trim()) return + + const newUrl = sanitizeIframeUrl(urlInput.trim()) + if (!newUrl) return + const newTitle = titleInput.trim() || 'Embed' + + setUrl(newUrl) + setTitle(newTitle) + setShowSettings(false) + setIsLoading(true) + setLoadError(null) + setLastRefresh(new Date()) + + // Save to localStorage + const newSaved: SavedEmbed = { + id: instanceId, + url: newUrl, + title: newTitle, + refreshInterval } + + setSavedEmbeds(prev => { + const filtered = (prev || []).filter(e => e.id !== instanceId) + const updated = [...filtered, newSaved] + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)) + return updated + }) + } + + const handleClear = () => { + setUrl('') + setTitle('Embed') + setUrlInput('') + setTitleInput('') + setShowSettings(true) + setLoadError(null) + + setSavedEmbeds(prev => { + const filtered = (prev || []).filter(e => e.id !== instanceId) + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)) + return filtered + }) + } + + const handlePresetSelect = (preset: { url: string; title: string }) => { + setUrlInput(preset.url) + setTitleInput(preset.title) + } + + const openInNewTab = () => { + if (url) { + window.open(url, '_blank', 'noopener,noreferrer') + } + } + + return { + iframeRef, url, title, refreshInterval, setRefreshInterval, height, setHeight, + showSettings, setShowSettings, isLoading, loadError, urlInput, setUrlInput, + titleInput, setTitleInput, handleRefresh, handleLoad, handleError, + handleSaveConfig, handleClear, handlePresetSelect, openInNewTab, + } +}