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) {