Skip to content

Harden inert snapshots and honor redirect URLs - #74

Open
SihanTeng wants to merge 3 commits into
tamnd:mainfrom
SihanTeng:fix/inert-snapshots-redirects
Open

Harden inert snapshots and honor redirect URLs#74
SihanTeng wants to merge 3 commits into
tamnd:mainfrom
SihanTeng:fix/inert-snapshots-redirects

Conversation

@SihanTeng

Copy link
Copy Markdown

Summary

  • Close active-content escape paths so the inert-snapshot promise holds: drop <base href>, neutralize iframe srcdoc / active data: URLs, strip live remote frames, and remove HTML/SVG object/embed (and remote plugins).
  • Resolve relative links against the browser final URL and any document <base href>, while still writing the page under the discovered URL so existing offline links keep working.
  • Rewrite non-UTF-8 charset declarations to utf-8 (serialized output is always UTF-8).

Test plan

  • Unit tests for sanitize active-content / charset rewrite
  • Unit tests for resolve-base selection (clone/resolve_test.go)
  • go test -short -count=1 ./sanitize/ ./clone/

Related: #16

@tamnd

tamnd commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Thanks for your contribution and interest in Kage! Feel free to ping me when you are ready for a review. : )

…edirects

# Conflicts:
#	CHANGELOG.md
#	docs/content/reference/release-notes.md
@SihanTeng
SihanTeng marked this pull request as ready for review August 3, 2026 05:15
@SihanTeng

Copy link
Copy Markdown
Author

Hi @tamnd — this is ready for a review when you have a moment. I've merged current main (v0.3.11) into the branch, so it's conflict-free and govulncheck is clean (x/text is v0.39.0 now). The workflow runs are waiting on your approval to start. Thanks!

@tamnd tamnd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are three separate changes in here and they share no code. I would like to take two of them almost as is and talk about the third.

  • The redirect and <base href> resolve base is a real bug fix and I want it. I checked the ordering and it is correct: pageResolveBase reads the base before CleanTree removes the element.
  • The charset rewriting is right too, and it is a genuine contribution to #16.
  • The active carrier removal is a product decision, not a bug fix, and as written it deletes every third party embed from every mirror we produce. That needs a flag and a maintainer decision before it ships.

Please split into three PRs. The first two can land this week.

One thing that is not in this PR and probably should be, since you are already in the charset code: saved pages still have no <!DOCTYPE html>, so they all render in quirks mode, which is where the charset meta you just corrected is least authoritative. Chrome's outerHTML of <html> never includes the doctype, html.Parse therefore builds no doctype node, and html.Render emits none. I verified it. Happy for that to be a fourth PR or for me to do it.

Comment thread clone/cloner.go
// links that pointed at /old still resolve. Cross-host redirects leave the
// resolve base as the final location for relative refs; scope checks still
// use that absolute URL.
resolveBase := pageResolveBase(j.u, res.FinalURL, root)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the good part of the PR and I want it. Resolving against the post redirect URL fixes a real bug: today a page fetched at /old that redirects to /new/ resolves href="next" as /next instead of /new/next, which breaks links and produces 404 asset fetches across the whole mirror.

I checked the ordering and it is correct. You read the <base href> here, RewriteHTML consumes it, and only then does CleanTree delete the element. Worth keeping that dependency in mind if anyone reorders processPage later.

One case to name in the CHANGELOG: on a cross host redirect (ex.com/old to other.com/new) the base becomes the other host, so every relative reference resolves out of scope, gets left absolute, and the page saved under ex.com/old ends up with nothing local in it at all. Your comment acknowledges the mechanism but not that outcome. Arguably the right answer is to enqueue the final URL as its own page when it is in scope, but I am happy to leave that for later as long as it is written down.

Comment thread clone/cloner.go
}

// documentBaseHref returns the first <base href> in document order, or "".
func documentBaseHref(root *html.Node) string {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reimplements a first match tree walk that findElement in the sanitize package already does. Not blocking, but two nearly identical walkers in two packages is the kind of thing that drifts. A shared helper would be fine.

Comment thread sanitize/sanitize.go
removeAttr(n, "src")
rep.ActiveFramesRemoved++
}
case strings.HasPrefix(low, "http://"), strings.HasPrefix(low, "https://"), strings.HasPrefix(low, "//"):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the change I cannot take as written.

Reading asset/html.go, atom.Iframe and atom.Frame go through rewriteAttr(n, "src", base, sink, pageOrAsset), and an out of scope iframe is deliberately left as an absolute URL to the live web. That is documented behaviour for off domain content. This branch then strips exactly those.

So on a typical page, every YouTube embed, Vimeo player, Google Map, CodePen, Twitter embed, Disqus thread and reCAPTCHA frame becomes an empty <iframe> with no src, which browsers render as a blank bordered box. An article that had a video in the middle of it now has a hole in it, and the reader has no idea anything was there.

I am not saying the policy is wrong. A live third party frame does phone home and run code when the saved page is opened online, and "inert snapshot" is the promise. But this is a change to what kage produces for nearly every real page on the web, and it arrives as an unannounced side effect of a PR titled "harden inert snapshots".

What I want before this lands:

  1. A flag. --keep-remote-frames or the inverse, whichever default you argue for. --keep-noscript and --all-asset-hosts already establish that policy like this is user selectable.
  2. A placeholder instead of a blank box, something like <a href="https://...">Embedded content: youtube.com/...</a>. That keeps the information at zero risk. Silently blanking destroys it.
  3. A line in the README and CHANGELOG in a user's words: third party embeds are removed from saved pages.

Comment thread sanitize/sanitize.go
if i := strings.IndexAny(path, "?#"); i >= 0 {
path = path[:i]
}
for _, ext := range []string{".html", ".htm", ".xhtml", ".svg", ".xml"} {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.svg in this list is too broad. <object data="diagram.svg"> and <embed src="logo.svg"> are ordinary, script free ways to place vector graphics, and they are common on documentation sites, which are our most likely target. Those illustrations now vanish entirely, and because the caller does RemoveChild rather than just dropping the reference, any fallback content nested inside the <object> goes with them.

The underlying concern is right: a raw .svg takes the asset path and is never sanitized, so an SVG with a <script> in it runs when framed. But the fix is at the wrong layer. SVG is XML and the existing tree walk applies almost unchanged, so sanitizing SVG assets and keeping the element is both safer and less destructive. Failing that, drop the reference and keep the element so the fallback survives.

Comment thread sanitize/sanitize.go
// carriers (HTML, SVG, or anything with a script-bearing data
// URL) so the inert-snapshot promise holds.
if neutralizePlugin(c, rep) {
n.RemoveChild(c)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note the inconsistency between the two carriers: an unsafe <iframe> is kept as an empty shell a few lines up, while an unsafe <object> is deleted outright here. Two policies for the same class of problem, with no stated reason for the difference. Whichever you pick, please pick one.

Comment thread sanitize/sanitize.go
case atom.Iframe, atom.Frame:
neutralizeActiveFrame(c, rep)
case atom.Object, atom.Embed:
// Same-domain HTML/SVG served through object/embed executes

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says the inert snapshot promise holds, and it only half does.

processPage runs asset.RewriteHTML(root, resolveBase, sink) before sanitize.CleanTree(root, ...). So by the time this code deletes an <object data="thing.svg">, the sink has already queued thing.svg for download. The script bearing file gets fetched, written into _kage/, and packed into the ZIM. It is merely unreferenced.

A reader who opens the archive and navigates to the file directly still gets a live script, and that is trivially possible given kage serve generates directory listings and Kiwix has search. Right now the change pays the cost of a strict policy without getting its benefit.

Either do not ship the payload, or reconsider whether deleting the visible content was justified. I would rather resolve that one way or the other than sit in between.

Comment thread sanitize/sanitize.go
low := strings.ToLower(ref)
if strings.HasPrefix(low, "javascript:") {
rep.JSURLsNeutralized++
rep.ActiveFramesRemoved++

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two counters double charge for a single action, so the report numbers no longer partition the work done.

ActiveFramesRemoved is also counting five different things by now: a dropped srcdoc, a data URL, a remote frame, a removed <object>, and a removed <embed>, under a name that says frames. Worth getting right while it is cheap.

Comment thread sanitize/sanitize.go
CharsetAdded bool
ActiveFramesRemoved int
BaseTagsRemoved int
CharsetFixed bool // true when a charset meta was inserted or rewritten to utf-8

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming an exported field is a breaking change for anyone importing github.com/tamnd/kage/sanitize. Nothing in this repo reads it so it costs us nothing internally, but please move it out of Fixed and into Changed in the CHANGELOG with the rename spelled out.

While we are here: I grepped, and no non test code reads CharsetAdded, CondCommentsRemoved, or any other counter. Strip and CleanTree return a Report that is always discarded, and this PR adds two more counters to it.

That is not your fault, but it is the moment to notice it. I would rather surface the report than keep growing it. A kage clone --verbose that printed "142 scripts stripped, 3 frames blanked, 1 charset rewritten" would have let the reporters of #16, #61 and #62 diagnose their own problems. If you would rather not take that on, say so and I will do it separately.

Comment thread sanitize/sanitize.go
// fixCharsetMetas rewrites existing charset declarations to utf-8. found is
// true when a declaration was already present (so the caller should not insert
// another); changed is true when a non-utf-8 value was rewritten in place.
func fixCharsetMetas(head *html.Node) (found, changed bool) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This walks only the direct children of <head>, same as the old hasCharsetMeta, so no regression. But since the input is Chrome's serialised DOM, a <meta charset> in a malformed document can end up outside <head> entirely, and it will then be neither found nor fixed, leaving two contradictory declarations in the file. A findElement style descendant walk would be more robust. Low priority.

Comment thread sanitize/sanitize.go
if !strings.HasPrefix(low, "data:") {
return false
}
// Only the mediatype token before the first comma decides; the payload

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice detail. Parsing only the mediatype token before the first comma, with a comment saying why, is exactly right. The payload really can contain text/html by chance in base64.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants