6 Commits
v2.2.1 ... main

Author SHA1 Message Date
d386160237 Release version 2.2.2 2026-09-23 10:27:21 +02:00
7de3c74f3c fix(iframe): stop an absent iframe parameter from framing the page in itself
An empty value resolved against window.location.href, so a page without an iframe parameter passed both the scheme check and the origin allowlist carrying its own URL. Every load entered fullscreen and framed the page inside itself, and the navigation observer wrote that URL back into the parameter, nesting it deeper on every poll until the URL ran to kilometres. safeUrl now rejects a missing value, the caller tests the parameter before validating it, and the history entry keeps the URL it was given rather than the resolved one.

The regression spec covers the four properties a page without the parameter must have, and fails on each of them when the defect is put back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 10:15:52 +02:00
7353e8d96a fix(iframe): hand the sinks the validated URL, not the parameter
The scheme and origin checks lived in a boolean guard in another function, so the raw query parameter still reached the iframe src, the history entry and window.open. The validator now returns the normalised href or null, and every sink consumes only that. Which URLs are accepted does not change: openIframe still checks the scheme alone, because the modal opens configured targets that are not among the page's iframe links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 02:10:05 +02:00
4cc8052a88 test: parse the page instead of matching tags with a regex
The inline-script check matched <script\b[^>]*> and then filtered the matched text, so a > inside an attribute value split one tag into a fragment that had already lost the attribute the filter looks for. An html.parser subclass decides on the parsed attributes instead. The i18n test imported unittest twice, once plain and once as a from-import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 02:09:23 +02:00
87c4405aa4 ci: run the Node jobs on the version Cypress supports
Cypress 16 declares node ^22 || ^24 || >=26, and both jobs installed it on Node 20, which npm reported as EBADENGINE on every run. Node 25 would match the Dockerfile but falls in the gap between ^24 and >=26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 02:08:24 +02:00
ff6e7d3689 fix(vendor): find the marked 18 browser build
marked 18 ships lib/marked.umd.js and no minified bundle at all, so the postinstall hook threw 'no browser UMD build found' and took npm install down with it. Both the JavaScript lint job and the end-to-end job died there. Teach the candidate list that layout; the older, minified layouts keep their precedence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 02:07:20 +02:00
11 changed files with 89 additions and 26 deletions

View File

@@ -51,7 +51,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: npm
cache-dependency-path: app/package.json

View File

@@ -153,7 +153,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: npm
cache-dependency-path: app/package.json

View File

@@ -1,5 +1,14 @@
# Changelog
## [2.2.2] - 2026-09-23
* Vendor assets: marked 18 is found again, so the image build stops failing
* Iframe: a page without an iframe parameter no longer frames itself
* Security: the iframe and the new tab only open a URL the validator returned
* Tests: the inline-script check parses the page instead of matching a regex
* Tests: a regression spec guards the page that carries no iframe parameter
* CI: the Node jobs run the version Cypress 16 supports
## [2.2.1] - 2026-09-23
* Images: releases ship arm64 next to amd64, for ARM servers and Apple Silicon

View 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/`);
});
});
});

View File

@@ -21,6 +21,7 @@ const SHARED = {
openDynamicPopup: 'readonly',
closeAllModals: 'readonly',
isSafeUrl: 'readonly',
safeUrl: 'readonly',
openIframe: 'readonly',
enterFullscreen: 'readonly',
exitFullscreen: 'readonly',

View File

@@ -63,6 +63,7 @@ const markedCandidates = [
path.join(NM, 'marked', 'marked.min.js'), // v4.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', 'lib', 'marked.umd.js'), // v16+
];
const markedSrc = markedCandidates.find(p => fs.existsSync(p));
if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');

View File

@@ -2,19 +2,20 @@
let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
let currentIframeUrl = null;
function isAllowedIframeUrl(url) {
if (!isSafeUrl(url)) {
return false;
function allowedIframeUrl(url) {
const candidate = safeUrl(url);
if (candidate === null) {
return null;
}
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);
return allowedOrigins.has(new URL(candidate).origin) ? candidate : null;
}
// === Auto-open iframe if URL parameter is present ===
window.addEventListener('DOMContentLoaded', () => {
const paramUrl = new URLSearchParams(window.location.search).get('iframe');
if (paramUrl && isAllowedIframeUrl(paramUrl)) {
if (paramUrl && allowedIframeUrl(paramUrl)) {
currentIframeUrl = paramUrl;
enterFullscreen();
openIframe(paramUrl);
@@ -43,7 +44,8 @@ function syncIframeHeight() {
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
function openIframe(url) {
if (!isSafeUrl(url)) {
const target = safeUrl(url);
if (target === null) {
return;
}
@@ -73,7 +75,7 @@ function openIframe(url) {
// Quelle setzen und mit 1500 ms einblenden
$iframe
.attr('src', url)
.attr('src', target)
.fadeIn(1500, function() {
syncIframeHeight();
observeIframeNavigation();
@@ -148,8 +150,8 @@ document.addEventListener("DOMContentLoaded", function() {
*/
function openIframeInNewTab() {
const params = new URLSearchParams(window.location.search);
const iframeUrl = params.get('iframe');
if (iframeUrl && isAllowedIframeUrl(iframeUrl)) {
const iframeUrl = allowedIframeUrl(params.get('iframe'));
if (iframeUrl) {
window.open(iframeUrl, '_blank');
} else {
alert('No iframe is currently open.');

View File

@@ -7,13 +7,20 @@ function t(source) {
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:'];
function isSafeUrl(url) {
try {
const parsed = new URL(String(url == null ? '' : url), window.location.href);
return SAFE_URL_SCHEMES.includes(parsed.protocol);
} catch (error) {
return false;
function safeUrl(url) {
if (url == null || String(url) === '') {
return null;
}
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) {

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portfolio-ui"
version = "2.2.1"
version = "2.2.2"
description = "A lightweight YAML-driven portfolio and landing-page generator."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -6,6 +6,7 @@ import subprocess
import sys
import tempfile
import unittest
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import Mock, patch
@@ -112,18 +113,29 @@ class TestEscaping(AppRouteMixin, unittest.TestCase):
self.assertIn("&lt;script&gt;alert(&#39;config&#39;)", 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):
def test_page_ships_no_executable_inline_script(self):
body = self.client.get("/de/").get_data(as_text=True)
inline = [
tag
for tag in re.findall(r"<script\b[^>]*>", body)
if "src=" not in tag and 'type="application/json"' not in tag
]
collector = InlineScriptCollector()
collector.feed(body)
self.assertEqual(
inline,
collector.inline,
[],
"a host CSP can only hash an inline script whose content it knows, "
"and this one changes with every language",

View File

@@ -2,8 +2,8 @@ import re
import shutil
import tempfile
import unittest
import unittest.mock
from pathlib import Path
from unittest import mock
import yaml
@@ -170,7 +170,7 @@ class TestCatalogMerge(unittest.TestCase):
)
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"), {})
read.assert_not_called()