mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-09-23 19:03:18 +00:00
Compare commits
5 Commits
v2.2.1
...
fix/iframe
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de3c74f3c | |||
| 7353e8d96a | |||
| 4cc8052a88 | |||
| 87c4405aa4 | |||
| ff6e7d3689 |
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: app/package.json
|
cache-dependency-path: app/package.json
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -153,7 +153,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: app/package.json
|
cache-dependency-path: app/package.json
|
||||||
|
|
||||||
|
|||||||
31
app/cypress/e2e/iframe_param_absent.spec.js
Normal file
31
app/cypress/e2e/iframe_param_absent.spec.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
describe('A page without an iframe parameter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays out of fullscreen', () => {
|
||||||
|
cy.get('body').should('not.have.class', 'fullscreen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frames nothing', () => {
|
||||||
|
cy.get('#main').find('iframe').should('not.exist');
|
||||||
|
cy.url().should('not.include', 'iframe=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never grows an iframe parameter out of its own URL', () => {
|
||||||
|
cy.wait(2000);
|
||||||
|
|
||||||
|
cy.url().then((url) => {
|
||||||
|
expect((url.match(/iframe/g) || []).length, 'iframe parameters').to.equal(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an absent URL as unsafe', () => {
|
||||||
|
cy.window().then((win) => {
|
||||||
|
expect(win.safeUrl(null), 'null').to.equal(null);
|
||||||
|
expect(win.safeUrl(''), 'empty string').to.equal(null);
|
||||||
|
expect(win.safeUrl('javascript:alert(1)'), 'script URL').to.equal(null);
|
||||||
|
expect(win.safeUrl('/de/'), 'relative path').to.equal(`${win.location.origin}/de/`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ const SHARED = {
|
|||||||
openDynamicPopup: 'readonly',
|
openDynamicPopup: 'readonly',
|
||||||
closeAllModals: 'readonly',
|
closeAllModals: 'readonly',
|
||||||
isSafeUrl: 'readonly',
|
isSafeUrl: 'readonly',
|
||||||
|
safeUrl: 'readonly',
|
||||||
openIframe: 'readonly',
|
openIframe: 'readonly',
|
||||||
enterFullscreen: 'readonly',
|
enterFullscreen: 'readonly',
|
||||||
exitFullscreen: 'readonly',
|
exitFullscreen: 'readonly',
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const markedCandidates = [
|
|||||||
path.join(NM, 'marked', 'marked.min.js'), // v4.x
|
path.join(NM, 'marked', 'marked.min.js'), // v4.x
|
||||||
path.join(NM, 'marked', 'lib', 'marked.umd.min.js'), // v5.x
|
path.join(NM, 'marked', 'lib', 'marked.umd.min.js'), // v5.x
|
||||||
path.join(NM, 'marked', 'dist', 'marked.min.js'), // v9+
|
path.join(NM, 'marked', 'dist', 'marked.min.js'), // v9+
|
||||||
|
path.join(NM, 'marked', 'lib', 'marked.umd.js'), // v16+
|
||||||
];
|
];
|
||||||
const markedSrc = markedCandidates.find(p => fs.existsSync(p));
|
const markedSrc = markedCandidates.find(p => fs.existsSync(p));
|
||||||
if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');
|
if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');
|
||||||
|
|||||||
@@ -2,19 +2,20 @@
|
|||||||
let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
|
let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
|
||||||
let currentIframeUrl = null;
|
let currentIframeUrl = null;
|
||||||
|
|
||||||
function isAllowedIframeUrl(url) {
|
function allowedIframeUrl(url) {
|
||||||
if (!isSafeUrl(url)) {
|
const candidate = safeUrl(url);
|
||||||
return false;
|
if (candidate === null) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
const allowedOrigins = new Set([window.location.origin]);
|
const allowedOrigins = new Set([window.location.origin]);
|
||||||
document.querySelectorAll('a.iframe-link[href]').forEach((link) => allowedOrigins.add(link.origin));
|
document.querySelectorAll('a.iframe-link[href]').forEach((link) => allowedOrigins.add(link.origin));
|
||||||
return allowedOrigins.has(new URL(url, window.location.href).origin);
|
return allowedOrigins.has(new URL(candidate).origin) ? candidate : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Auto-open iframe if URL parameter is present ===
|
// === Auto-open iframe if URL parameter is present ===
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
const paramUrl = new URLSearchParams(window.location.search).get('iframe');
|
const paramUrl = new URLSearchParams(window.location.search).get('iframe');
|
||||||
if (paramUrl && isAllowedIframeUrl(paramUrl)) {
|
if (paramUrl && allowedIframeUrl(paramUrl)) {
|
||||||
currentIframeUrl = paramUrl;
|
currentIframeUrl = paramUrl;
|
||||||
enterFullscreen();
|
enterFullscreen();
|
||||||
openIframe(paramUrl);
|
openIframe(paramUrl);
|
||||||
@@ -43,7 +44,8 @@ function syncIframeHeight() {
|
|||||||
|
|
||||||
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
|
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
|
||||||
function openIframe(url) {
|
function openIframe(url) {
|
||||||
if (!isSafeUrl(url)) {
|
const target = safeUrl(url);
|
||||||
|
if (target === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ function openIframe(url) {
|
|||||||
|
|
||||||
// Quelle setzen und mit 1500 ms einblenden
|
// Quelle setzen und mit 1500 ms einblenden
|
||||||
$iframe
|
$iframe
|
||||||
.attr('src', url)
|
.attr('src', target)
|
||||||
.fadeIn(1500, function() {
|
.fadeIn(1500, function() {
|
||||||
syncIframeHeight();
|
syncIframeHeight();
|
||||||
observeIframeNavigation();
|
observeIframeNavigation();
|
||||||
@@ -148,8 +150,8 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||||||
*/
|
*/
|
||||||
function openIframeInNewTab() {
|
function openIframeInNewTab() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const iframeUrl = params.get('iframe');
|
const iframeUrl = allowedIframeUrl(params.get('iframe'));
|
||||||
if (iframeUrl && isAllowedIframeUrl(iframeUrl)) {
|
if (iframeUrl) {
|
||||||
window.open(iframeUrl, '_blank');
|
window.open(iframeUrl, '_blank');
|
||||||
} else {
|
} else {
|
||||||
alert('No iframe is currently open.');
|
alert('No iframe is currently open.');
|
||||||
|
|||||||
@@ -7,13 +7,20 @@ function t(source) {
|
|||||||
|
|
||||||
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:'];
|
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:'];
|
||||||
|
|
||||||
function isSafeUrl(url) {
|
function safeUrl(url) {
|
||||||
try {
|
if (url == null || String(url) === '') {
|
||||||
const parsed = new URL(String(url == null ? '' : url), window.location.href);
|
return null;
|
||||||
return SAFE_URL_SCHEMES.includes(parsed.protocol);
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(String(url), window.location.href);
|
||||||
|
return SAFE_URL_SCHEMES.includes(parsed.protocol) ? parsed.href : null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeUrl(url) {
|
||||||
|
return safeUrl(url) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconAndName(item) {
|
function iconAndName(item) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from html.parser import HTMLParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
@@ -112,18 +113,29 @@ class TestEscaping(AppRouteMixin, unittest.TestCase):
|
|||||||
self.assertIn("<script>alert('config')", body)
|
self.assertIn("<script>alert('config')", body)
|
||||||
|
|
||||||
|
|
||||||
|
class InlineScriptCollector(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.inline = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag != "script":
|
||||||
|
return
|
||||||
|
attributes = dict(attrs)
|
||||||
|
if "src" in attributes or attributes.get("type") == "application/json":
|
||||||
|
return
|
||||||
|
self.inline.append(self.get_starttag_text())
|
||||||
|
|
||||||
|
|
||||||
class TestContentSecurityPolicy(AppRouteMixin, unittest.TestCase):
|
class TestContentSecurityPolicy(AppRouteMixin, unittest.TestCase):
|
||||||
def test_page_ships_no_executable_inline_script(self):
|
def test_page_ships_no_executable_inline_script(self):
|
||||||
body = self.client.get("/de/").get_data(as_text=True)
|
body = self.client.get("/de/").get_data(as_text=True)
|
||||||
|
|
||||||
inline = [
|
collector = InlineScriptCollector()
|
||||||
tag
|
collector.feed(body)
|
||||||
for tag in re.findall(r"<script\b[^>]*>", body)
|
|
||||||
if "src=" not in tag and 'type="application/json"' not in tag
|
|
||||||
]
|
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
inline,
|
collector.inline,
|
||||||
[],
|
[],
|
||||||
"a host CSP can only hash an inline script whose content it knows, "
|
"a host CSP can only hash an inline script whose content it knows, "
|
||||||
"and this one changes with every language",
|
"and this one changes with every language",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ class TestCatalogMerge(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_an_unsupported_code_never_becomes_a_path(self):
|
def test_an_unsupported_code_never_becomes_a_path(self):
|
||||||
with mock.patch.object(i18n, "read_catalog") as read:
|
with unittest.mock.patch.object(i18n, "read_catalog") as read:
|
||||||
self.assertEqual(i18n.catalog("../content/de"), {})
|
self.assertEqual(i18n.catalog("../content/de"), {})
|
||||||
|
|
||||||
read.assert_not_called()
|
read.assert_not_called()
|
||||||
|
|||||||
Reference in New Issue
Block a user