From 857c470c9e94643234040933ddab2ba9e6d35de0 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 10 Sep 2026 17:30:56 +0200 Subject: [PATCH] fix(iframe): open only configured origins from the iframe query parameter ?iframe= loaded any http(s) page into the dashboard frame, and "Open in new tab" handed the same query value to window.open unchecked, so a crafted link could show an arbitrary site inside the trusted page. CodeQL flagged both as client-side URL redirection and XSS. The scheme check sat before the fade callback that sets the iframe src, so it did not guard that sink, and isSafeUrl itself assigned the untrusted value to an anchor's href to parse it. isAllowedIframeUrl now requires a safe scheme and an origin that is the page's own or one of the configured .iframe-link targets; both query string entry points check it before openIframe or window.open run. Clicks on configured links and popup entries keep calling openIframe directly, which still rejects unsafe schemes. isSafeUrl parses with new URL instead of a detached anchor. The Cypress case that expected https://example.com/ to open from the query string encoded the redirection, so it now asserts that a configured target opens and that a foreign origin neither loads in the frame nor reaches window.open. make test passes with 109 Cypress tests. Co-Authored-By: Claude Opus 5 (1M context) --- app/cypress/e2e/injection.spec.js | 30 ++++++++++++++++++++++++++---- app/static/js/iframe.js | 13 +++++++++++-- app/static/js/modal.js | 9 ++++++--- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/app/cypress/e2e/injection.spec.js b/app/cypress/e2e/injection.spec.js index cc1c542..559eb3a 100644 --- a/app/cypress/e2e/injection.spec.js +++ b/app/cypress/e2e/injection.spec.js @@ -253,11 +253,33 @@ describe('Untrusted content reaching the iframe', () => { cy.window().should('not.have.property', '__xss'); }); - it('still opens an ordinary URL from the query string', () => { + it('still opens a configured iframe target from the query string', () => { + cy.visit('/'); + cy.get('a.iframe-link').first().invoke('prop', 'href').then((href) => { + cy.visit(`/?iframe=${encodeURIComponent(href)}`); + + cy.get('#main') + .find('iframe', { timeout: AFTER_THE_FADE }) + .should('have.attr', 'src', href); + }); + }); + + it('refuses a foreign origin supplied through the query string', () => { cy.visit('/?iframe=https://example.com/'); - cy.get('#main') - .find('iframe', { timeout: AFTER_THE_FADE }) - .should('have.attr', 'src', 'https://example.com/'); + cy.wait(AFTER_THE_FADE); + cy.get('#main').find('iframe').should('not.exist'); + }); + + it('does not open a foreign query-string origin in a new tab', () => { + cy.visit('/?iframe=https://example.com/', { + onBeforeLoad(win) { + cy.stub(win, 'open').as('open'); + cy.stub(win, 'alert'); + }, + }); + + cy.window().then((win) => win.openIframeInNewTab()); + cy.get('@open').should('not.have.been.called'); }); }); diff --git a/app/static/js/iframe.js b/app/static/js/iframe.js index 1895ee5..72332c5 100644 --- a/app/static/js/iframe.js +++ b/app/static/js/iframe.js @@ -2,10 +2,19 @@ let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer; let currentIframeUrl = null; +function isAllowedIframeUrl(url) { + if (!isSafeUrl(url)) { + return false; + } + const allowedOrigins = new Set([window.location.origin]); + document.querySelectorAll('a.iframe-link[href]').forEach((link) => allowedOrigins.add(link.origin)); + return allowedOrigins.has(new URL(url, window.location.href).origin); +} + // === Auto-open iframe if URL parameter is present === window.addEventListener('DOMContentLoaded', () => { const paramUrl = new URLSearchParams(window.location.search).get('iframe'); - if (paramUrl) { + if (paramUrl && isAllowedIframeUrl(paramUrl)) { currentIframeUrl = paramUrl; enterFullscreen(); openIframe(paramUrl); @@ -140,7 +149,7 @@ document.addEventListener("DOMContentLoaded", function() { function openIframeInNewTab() { const params = new URLSearchParams(window.location.search); const iframeUrl = params.get('iframe'); - if (iframeUrl) { + if (iframeUrl && isAllowedIframeUrl(iframeUrl)) { window.open(iframeUrl, '_blank'); } else { alert('No iframe is currently open.'); diff --git a/app/static/js/modal.js b/app/static/js/modal.js index ed77827..470071a 100644 --- a/app/static/js/modal.js +++ b/app/static/js/modal.js @@ -5,9 +5,12 @@ function t(source) { const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:']; function isSafeUrl(url) { - const probe = document.createElement('a'); - probe.href = String(url == null ? '' : url); - return SAFE_URL_SCHEMES.includes(probe.protocol); + try { + const parsed = new URL(String(url == null ? '' : url), window.location.href); + return SAFE_URL_SCHEMES.includes(parsed.protocol); + } catch (error) { + return false; + } } function iconAndName(item) {