Skip to content
Merged
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
43 changes: 43 additions & 0 deletions web/e2e/visual/app-iframe-embed-visual.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
189 changes: 8 additions & 181 deletions web/src/components/cards/IframeEmbed.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useCardExpanded } from './CardWrapper'
import {
ExternalLink, Settings, X, AlertTriangle,
Expand All @@ -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: '📊' },
Expand All @@ -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<HTMLIFrameElement>(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<string | null>(null)
const [, setLastRefresh] = useState(new Date())
const [urlInput, setUrlInput] = useState(config?.url || '')
const [titleInput, setTitleInput] = useState(config?.title || '')

const [savedEmbeds, setSavedEmbeds] = useState<SavedEmbed[]>(() => {
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

Expand Down
117 changes: 117 additions & 0 deletions web/src/components/cards/useIframeEmbed.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading