Every one of these paths checked a string that the browser reinterprets
afterwards. isSafeUrl now hangs the value on an <a> and reads back
probe.protocol, so the check sees what the browser will see: a pre-parse
test reads "javascript:" as a relative path and passes it, and the
HTML parser then decodes it to "javascript:".
marked passes raw HTML through and emits hrefs unescaped. renderMarkdown
escapes the angle brackets before parsing, parses into an inert DOMParser
document where no script runs and no image loads, and drops anchors and
images whose scheme is not http, https or mailto. Blockquotes and
<autolinks> stop working as a result; neither appears in the configuration.
modalTitle and the alternatives list interpolated subitem.name and
icon.class into innerHTML. Both are built as nodes now. name is a
translatable key, so it arrives from the machine-written catalogues.
The link kept its click handler and its class across popups, because one
anchor serves all of them: a later, unrelated click opened whatever an
earlier popup pointed at, and addEventListener stacked one handler per
open. Both are reset per popup and the handler is assigned, not added.
openIframe guards its own argument. Removing the href alone left the
handler passing the raw URL on, and ?iframe= in the query string reaches
the same sink with no configuration involved at all.
Verified in headless Chromium: decimal and hex character references,
	- and 
-split schemes, reference-style links, raw HTML as a
link's text, and the two name sinks all executed before these changes.
injection.spec.js keeps all fifteen payloads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test-e2e drove Cypress through act, which fails to start where it cannot
resolve a host address for its artifact server. It now starts Flask and
Cypress in one shell via scripts/run-e2e.sh, so both share a network
namespace; the act path stays available as test-e2e-act.
The script guards the cases that made the old target lie: it aborts when
something already serves the port instead of testing that server, checks
that its own Flask is alive before trusting a response, pins Cypress to
the same origin Flask binds, and drops ELECTRON_RUN_AS_NODE, which VS Code
exports and which makes Cypress' bundled Electron reject its own flags.
30 YAML and 18 JavaScript files had no linter. yamllint runs correctness
rules only, because the repository predates it and its cosmetic findings
would be noise; key-duplicates is the one that earns its keep, since
PyYAML keeps the last of two identical keys without complaining. eslint
runs the recommended set and already found a dead getBoundingClientRect()
call in navigation.js. Both get a CI job so make lint and the workflows
stop diverging.
flask>=3.1 because app.config["TRUSTED_HOSTS"] arrived in 3.1 and an older
Flask accepts the key and ignores it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Move <header> overflow:hidden into body.fullscreen scope and drop the
implicit-vertical-clip overflow-x:auto from .navbar-nav so dropdown
menus can escape the navbar.
- Drive top-level dropdowns through bootstrap.Dropdown (popperConfig
strategy:'fixed'), and add a chooseDirection() helper that toggles
.dropup/.dropdown on the .nav-item based on space above vs below
before each show. Split the navigation.css rules to position the menu
with top:100% or bottom:100% accordingly.
- Mark the dropdown toggle with data-bs-toggle="dropdown" in the
template; cover that with a Jinja-rendered unit test and add Cypress
specs for the header (opens downward) and footer (flips to .dropup)
cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Set flex-wrap: nowrap on navbar-nav to keep all items in one row
- Add hidden overflow-x scroll (no visible scrollbar) as fallback
- Fix #navbar_logo taking up space when invisible via max-width transition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>