diff --git a/.github/workflows/database-migrations-main.yml b/.github/workflows/database-migrations-main.yml index 3fd87ba7d3..87a710c345 100644 --- a/.github/workflows/database-migrations-main.yml +++ b/.github/workflows/database-migrations-main.yml @@ -4,6 +4,10 @@ on: branches: - main workflow_dispatch: # Allows manual triggering + +permissions: + contents: read # only needs to check out the repo to run migrations + jobs: migrate: name: Run Database Migrations diff --git a/.github/workflows/database-migrations-release.yml b/.github/workflows/database-migrations-release.yml index b64c4798d0..6191bf3086 100644 --- a/.github/workflows/database-migrations-release.yml +++ b/.github/workflows/database-migrations-release.yml @@ -4,6 +4,10 @@ on: branches: - release workflow_dispatch: # Allows manual triggering + +permissions: + contents: read # only needs to check out the repo to run migrations + jobs: migrate: name: Run Database Migrations diff --git a/.github/workflows/trigger-tasks-deploy-main.yml b/.github/workflows/trigger-tasks-deploy-main.yml index b86b1a4166..8a171da69b 100644 --- a/.github/workflows/trigger-tasks-deploy-main.yml +++ b/.github/workflows/trigger-tasks-deploy-main.yml @@ -3,6 +3,10 @@ on: push: branches: - main + +permissions: + contents: read # only needs to check out the repo to build & deploy + jobs: deploy: runs-on: warp-ubuntu-latest-arm64-4x diff --git a/.github/workflows/trigger-tasks-deploy-release.yml b/.github/workflows/trigger-tasks-deploy-release.yml index 2b066c359b..30c922bac7 100644 --- a/.github/workflows/trigger-tasks-deploy-release.yml +++ b/.github/workflows/trigger-tasks-deploy-release.yml @@ -5,6 +5,9 @@ on: branches: - release +permissions: + contents: read # only needs to check out the repo to build & deploy + jobs: deploy: runs-on: warp-ubuntu-latest-arm64-4x diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/preview/preview.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/preview/preview.tsx index 0f54f92a96..81233c2880 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/preview/preview.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/preview/preview.tsx @@ -13,6 +13,24 @@ interface Props { url?: string; } +/** + * Only allow http(s) URLs to reach an iframe `src` or anchor `href`. User-typed + * input flows into the preview iframe, so an unvalidated value like + * `javascript:...` or `data:text/html,...` would execute in the preview context + * (XSS). Returns the normalized href, or undefined if the value isn't http(s). + */ +function toSafeUrl(value: string | undefined | null): string | undefined { + if (!value) return undefined; + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + ? parsed.href + : undefined; + } catch { + return undefined; + } +} + export function Preview({ className, disabled, url }: Props) { const [currentUrl, setCurrentUrl] = useState(url); const [error, setError] = useState(null); @@ -21,36 +39,46 @@ export function Preview({ className, disabled, url }: Props) { const iframeRef = useRef(null); const loadStartTime = useRef(null); + // Sanitized view of currentUrl used for every DOM sink (iframe src, anchor href). + const safeUrl = toSafeUrl(currentUrl); + useEffect(() => { setCurrentUrl(url); setInputValue(url || ''); }, [url]); const refreshIframe = () => { - if (iframeRef.current && currentUrl) { + if (iframeRef.current && safeUrl) { setIsLoading(true); setError(null); loadStartTime.current = Date.now(); iframeRef.current.src = ''; setTimeout(() => { if (iframeRef.current) { - iframeRef.current.src = currentUrl; + iframeRef.current.src = safeUrl; } }, 10); } }; const loadNewUrl = () => { - if (iframeRef.current && inputValue) { - if (inputValue !== currentUrl) { - setIsLoading(true); - setError(null); - loadStartTime.current = Date.now(); - iframeRef.current.src = inputValue; - } else { - refreshIframe(); - } + if (!inputValue) return; + const safeInput = toSafeUrl(inputValue); + if (!safeInput) { + setError('Enter a valid http(s) URL'); + return; } + if (safeInput === safeUrl) { + refreshIframe(); + return; + } + // Drive the iframe through state (not iframeRef.current.src) so the src + // prop, the external-link href, and refresh/try-again all stay in sync + // with the URL actually shown. + setIsLoading(true); + setError(null); + loadStartTime.current = Date.now(); + setCurrentUrl(safeInput); }; const handleIframeLoad = () => { @@ -67,7 +95,12 @@ export function Preview({ className, disabled, url }: Props) {
- +