PR #408 — HTMLImageSourceRewriter escapes the re-emitted src/srcset value so an embedded quote can no longer close the attribute early. Two commits: the escape itself, and a follow-up that stops the escape from double-encoding character references an author already wrote into a data: URI.
rewriteTag now emits name="escapeAttributePreservingReferences(rewritten)". Only the data: passthrough and the srcset descriptor can carry "/'/& into the value — the prism-doc:// branch is percent-encoded by URLComponents, so the escape is a no-op there.4b91dc86 adds HTMLEscaping.escapeAttributePreservingReferences: identical to escapeAttribute except a & that begins a well-formed &name;/&#n;/&#xh; reference passes through. Needed because the scanner hands back attribute text as written (undecoded — T-1977) and the browser decodes once.HTMLSanitizer (SwiftSoup) runs after the rewriter and already stripped the broken-out remainder. The fix restores the rewriter's own well-formedness contract (defence in depth).HTMLImageSourceRewriterQuotingTests parse the emitted tag back the way a browser would and assert the value round-trips as one attribute; none goes through emitHTML + HTMLSanitizer.make lint 0 violations; make verify-test-isolation OK (83 checks). The eight targeted rewriter/emitter/security suites were run against an isolated git archive export of dd56e997 (the worktree itself was being edited and built by the concurrent fixer): 46/46 passed per the xcresult (Tools/check-test-results.sh); the JUnit file from xcbeautify lists each test twice (88 rows), a known double-count in its log parsing, not extra tests. The export predates 4b91dc86; that commit's fifth test is hand-traced only. Author's report: 28/28 targeted plus make build-macos.HTMLImageSourceRewriter.swift:119; the call now sits at line 131. No CLAUDE.md or agent-note text describes the re-emission, so nothing else is stale.Ready to push
The production change is a one-line escape at the rewriter's single re-emission point using the project's own HTMLEscaping helpers, and the follow-up commit closes the one real regression the first cut had (double-encoding of pre-existing character references on the verbatim data: passthrough). Four review agents found no blocking or major defect in the committed diff; lint and the WebKit test-isolation guard pass. Remaining items are a stale line number in the bugfix report, test-only nits, and an optional end-to-end test through HTMLSanitizer. The targeted suites pass 46/46 on an isolated export of the first commit; the follow-up commit's test is hand-traced, not run here. This review is strictly read-only, so nothing was fixed in place — the list below is for the author.
dd56e997 Fix T-1942: HTMLImageSourceRewriter quote breakout in re-emitted attributes 4b91dc86 Fix T-1942: leave existing character references alone when re-escaping Prism shows markdown files. A markdown file may contain a bit of raw HTML, such as an <img src="cat.png"> tag. Before that HTML reaches the screen, a small component called HTMLImageSourceRewriter rewrites the image address so the app can load it safely, and then writes the tag back out. When it wrote the tag back out it always wrapped the address in double quotes — src="..." — on the assumption that the address itself could never contain a double quote.
That assumption was wrong in two cases. An inline data: image (the picture bytes written straight into the page) is handed back exactly as the author wrote it, quotes included, and a srcset entry's size label (the 1x or 480w part) is copied across untouched. A " in either place closed the attribute early and turned the rest of the tag into new attributes.
The fix is one line: the value is now run through the project's existing HTMLEscaping.escapeAttribute helper, which turns " into " (and ', &, <, > into their entities) before the quotes go around it. A browser turns those entities back into the original characters when it reads the attribute.
Malformed output from this step is handed to the HTML sanitiser, which is the real security boundary and already stripped the broken-out junk. So this was not an exploit, but it was an invariant the rewriter claimed and did not keep. Fixing it means the sanitiser is no longer the only thing standing between a crafted image tag and a live attribute.
prism/Services/WebRendering/HTMLImageSourceRewriter.swift — rewriteTag now emits name="\(HTMLEscaping.escapeAttribute(rewritten))"; the comment that asserted the value was quote-free is replaced by one naming the two carriers.prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift — new HTMLImageSourceRewriterQuotingTests (4 tests) that parse the emitted tag back the way a browser would and assert the value round-trips as one attribute.CHANGELOG.md [Unreleased]/Fixed entry and specs/bugfixes/image-rewriter-quote-preservation/report.md.The rewriter scans a raw HTML fragment for <img>/<source> start tags, locates each attribute (double-quoted, single-quoted, unquoted), and routes src/srcset values through a closure (BlockHTMLEmitter.rewriteImageSrc). That closure returns a prism-doc://img/?src=… URL (percent-encoded, so never quote-bearing) — except for admitted data: URIs, which pass through verbatim. rewriteSrcset splits each candidate on its first space and re-joins the descriptor without touching it. The fix escapes at the single re-emission point rather than at the two producers, so the invariant "the rewriter emits well-formed attributes" holds regardless of what the closure returns.
Character pass per rewritten value on the emit path (off-main, per parse revision). The markdown-image path in BlockHTMLEmitter already pays exactly this at its own src= emission, so this brings raw-HTML images to parity.Known regression handled separately: the scanner returns the attribute's source text without decoding entities, so a well-formed & inside a non-base64 data: URI now comes out as &amp;. The comment's "nothing observable changes for well-formed input" overclaims for that shape; a concurrent fix is in progress.
Only the data: passthrough and the srcset descriptor can carry reserved characters into rewritten: the prism-doc:// branch is built with URLComponents/URLQueryItem, whose query encoding leaves no ", ', <, > or &, so escapeAttribute is provably a no-op there. </> in a value are moot in practice — tagRanges ends a tag at the first > and the scanner terminates unquoted values on <. Base64 payloads (the common data: form) contain none of the five characters. The observable delta is therefore confined to non-base64 data: URIs (JSON, unencoded SVG) and to descriptor text that contains a quote — i.e. exactly the shapes the ticket names, plus the &-double-encode residual noted above.
Downstream, HTMLSanitizer.sanitize parses the mediated fragment with SwiftSoup, which decodes "/' on read and re-serialises attributes with its own escaping, so the round trip holds for quotes. srcset is stripped entirely by the sanitiser (it cannot protocol-check the multi-URL syntax), so the descriptor test proves markup shape, not a value that survives to WebKit.
None structural. The rewriter's file header already says it is not a security boundary; this change makes its output contract ("well-formed mediated markup") actually hold. The emit path's existing growth test (RawHTMLImageScanGrowthTests G7) already runs through rewriteTag, so the added pass is covered by the linearity assertion, albeit on a fixture with nothing to escape.
src="a?x=1&y=2" reaches prism-doc:// as %26amp;) but the new escape makes it visible on the data: path.decodeAttributeEntities duplicates the private FootnoteRenderProbe.unescape; a third copy would justify a prismTests/Support helper.emitHTML + HTMLSanitizer that a quote-bearing data: src survives sanitisation intact; existing tests stop at the rewriter's output.prism/Services/WebRendering/HTMLImageSourceRewriter.swift
Why it matters. This is the whole fix. A raw " inside the rewritten value (verbatim data: URI or a srcset descriptor that never goes through the rewrite closure) used to close the hard double-quoted attribute early and splice the remainder into attribute position. Escaping here covers both carriers and any future one.
What to look at. HTMLImageSourceRewriter.swift:115-131 (rewriteTag re-emission)
prism/Services/WebRendering/HTMLEscaping.swift
Why it matters. The scanner returns attribute text as written, references undecoded, and the browser decodes the re-emitted attribute exactly once. Plain escapeAttribute turned an author's correct &amp; (one layer for the attribute, one for the SVG text) into &amp;amp;, so an SVG rendered 'Save & Export'. This helper keeps every other escape and leaves a well-formed reference alone.
What to look at. HTMLEscaping.swift:47-127 (escapeAttributePreservingReferences, characterReferenceEnd)
prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift
Why it matters. The five tests recover the attribute value the way a browser would (up to the first unescaped "), decode the handful of entities the escaper can emit, and compare to the original — so a regression that lands any unexpected byte in the attribute fails, not only the one shape the ticket named.
What to look at. BlockHTMLEmitterMediaTests.swift:369-518
One change point in rewriteTag covers the data: passthrough and the srcset descriptor re-join, and makes the rewriter's stated contract (well-formed mediated markup) hold whatever the closure returns. Recorded in the bugfix report's Alternatives Considered.
A value can carry both quote characters (an unencoded data: SVG routinely does), so no delimiter choice is safe without escaping anyway. Recorded in the report.
The scanner does not decode entities (T-1977 owns that question). Decoding here would need the full named-reference table (SwiftSoup, behind the sanitizer mutex) and would pre-empt T-1977. Passing a well-formed &name;/&#n;/&#xh; through is observably identical for a browser and cannot affect attribute boundaries. Recorded in the report and the commit body of 4b91dc86.
The fix does not change mediation (Req 8.3), data: passthrough (Req 3.3) or the sanitizer boundary (Req 8.1); it only changes how an already-mediated value is written out. The larger T-1655 change to the same file is not in that log either. Bugfix report is the record.
The report states the machine was running several worktrees' suites concurrently and lists the targeted suites (28/28) plus lint and the macOS build instead. Consistent with the documented contention behaviour of this project.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | HTMLImageSourceRewriter.swift (commit dd56e997 alone) | Plain escapeAttribute double-encoded character references an author already wrote into a non-base64 data: URI (&amp; -> &amp;amp;), and the re-emit comment's 'nothing observable changes for well-formed input' overclaimed for that shape. | Fixed on the branch by the concurrent commit 4b91dc86 (escapeAttributePreservingReferences + a fifth test + corrected comment, CHANGELOG and report). Traced by hand: &amp;, &, & pass through, a bare & still escapes, the prism-doc:// branch is unaffected because URLComponents percent-encodes & first. |
| minor | specs/bugfixes/image-rewriter-quote-preservation/report.md:84 | 'Changes made' cites HTMLImageSourceRewriter.swift:119 — that was the pre-fix line; the call now sits at line 131 after two comment expansions. | Update to :131 or reference rewriteTag's re-emission line without a number. Read-only review: left for the author. |
| minor | BlockHTMLEmitterMediaTests.swift — HTMLImageSourceRewriterQuotingTests | None of the five tests goes through BlockHTMLEmitter.emit/emitHTML + HTMLSanitizer.sanitize; the report's defence-in-depth claim (sanitizer receives well-formed markup, broken-out attributes never survive) is asserted in prose only. RawHTMLDeliberateChangeTests shows the pattern for the full pipeline. | Optional follow-up: one end-to-end test running the srcset breakout payload (or the &-bearing data: SVG) through BlockHTMLEmitter.emit and asserting no onerror= survives and the data: src is intact. Not blocking. |
| minor | BlockHTMLEmitterMediaTests.swift:391-410 decodeAttributeEntities | Duplicates the private FootnoteRenderProbe.unescape in FootnoteBadgeSubstitutionTests.swift:131-137 (same five entities, equivalent behaviour). | Acceptable as test-only code; a third copy would justify promoting one into prismTests/Support/. Left as is. |
| nit | BlockHTMLEmitterMediaTests.swift:388 extractDoubleQuotedAttributeValue | Regex name="([^"]*)" is not boundary-anchored; it cannot misfire on the current fixtures (srcset= never contains src=") but would on a hypothetical data-src=. | Anchor with (?:^|\s) if the helper grows. Left as is. |
| nit | prismTests/RawHTMLImageScanGrowthTests.swift G7 | The linear-growth test already runs through rewriteTag and therefore the new escape pass, but its fixture contains nothing to escape, so the every-character-expands path is unmeasured. String += is amortised O(1), so no regression is expected. | Optional: a fixture seeded with quotes/ampersands. Left as is. |
| nit | HTMLEscaping.escapeAttributePreservingReferences | A legacy reference without a terminating ';' (e.g. '& ' as some browsers accept in attributes) is treated as a bare & and escaped, so it now decodes to the literal text '&' rather than '&'. Correct per the fail-closed intent and vanishingly rare in a data: URI. | Informational; no change. |
| nit | Code reuse / efficiency | escapeAttribute is the codebase's standard for double-quoted attribute contexts (~15 call sites); the added pass is one linear Character walk per rewritten value, the same cost BlockHTMLEmitter already pays for markdown images at line 776. No unnecessary work worth a fast path. | No action. |
Source: local run at 2026-09-06T01:52:00+10:00 · snapshot dd56e9972c1c70553f09d9bd88f18ef95bb13c5f
Baseline: none
Execution: passed · JUnit: 1 file · Coverage: none · Baseline: absent
Coverage scope: as the project configures it
Totals: 44 passed · 0 failed · 0 skipped · 0 errored · 0 flaky
Derived by declaration name, from the diff (no baseline run).
Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot 4b91dc865434a5b6a25e5110844900049a381d26 against base 2b10c6dc4944d8e50c99e6b60e962591a230b39f.
prism/Resources/mermaid.min.js — blob over 1 MBClick to expand.
diff --git a/prism/Services/WebRendering/HTMLImageSourceRewriter.swift b/prism/Services/WebRendering/HTMLImageSourceRewriter.swiftindex 2276c36e..7da1e7db 100644--- a/prism/Services/WebRendering/HTMLImageSourceRewriter.swift+++ b/prism/Services/WebRendering/HTMLImageSourceRewriter.swift@@ -114,9 +114,21 @@ nonisolated enum HTMLImageSourceRewriter { let rewritten = attribute.name == "srcset" ? rewriteSrcset(attribute.value, using: rewrite) : rewrite(attribute.value)- // Re-emit double-quoted regardless of the source quote style: the rewritten- // value is a mediated prism-doc:/data: URL (no embedded double quote).- output += "\(attribute.name)=\"\(rewritten)\""+ // Re-emit double-quoted regardless of the source quote style. `rewritten` is+ // NOT guaranteed free of quote characters: a `data:` URI is passed through+ // verbatim by the `rewrite` closure's admit path (T-1942), and `rewriteSrcset`+ // re-joins the attacker-controlled descriptor half of each candidate unchanged.+ // Escaping (rather than switching delimiter to match the source) keeps this+ // correct regardless of which quote character — or both — the value contains.+ //+ // The escape must leave a character reference the value already carries+ // alone. What reaches here is the attribute's text as WRITTEN, references+ // undecoded (T-1977), and the browser decodes the re-emitted attribute exactly+ // once — so a `&amp;` an author wrote into an SVG `data:` URI must come out+ // as `&amp;`, not `&amp;amp;`, or the SVG parser sees the reference in+ // place of the character. The `prism-doc://` branch is indifferent: its URL is+ // percent-encoded and never contains a `&`.+ output += "\(attribute.name)=\"\(HTMLEscaping.escapeAttributePreservingReferences(rewritten))\"" cursor = attribute.range.location + attribute.range.length } output += nsTag.substring(from: cursor)
diff --git a/prism/Services/WebRendering/HTMLEscaping.swift b/prism/Services/WebRendering/HTMLEscaping.swiftindex ca5ee06d..06d3556b 100644--- a/prism/Services/WebRendering/HTMLEscaping.swift+++ b/prism/Services/WebRendering/HTMLEscaping.swift@@ -43,4 +43,85 @@ nonisolated enum HTMLEscaping { } return result }++ /// Escapes a value for a double-quoted attribute context WITHOUT re-escaping a+ /// character reference the value already carries.+ ///+ /// `escapeAttribute` is for a value that is plain text. This one is for a value that+ /// is HTML *attribute text* lifted from a document before any character reference in+ /// it was decoded — `HTMLImageSourceRewriter` re-emits a `data:` URI exactly as it was+ /// written in the raw fragment (T-1977 is the ticket for decoding it first). The+ /// browser decodes the re-emitted attribute once, so a reference that was already+ /// there must be passed through untouched: escaping its `&` to `&` turns the+ /// author's `&amp;` into `&amp;amp;`, which decodes back to the reference+ /// rather than to the character (T-1942 review).+ ///+ /// A `&` is left alone only when it begins a well-formed reference — `&name;`,+ /// `&#digits;` or `&#xhex;`, terminated by `;`. Whether the name is one HTML knows+ /// makes no difference: the browser renders an unknown `&foo;` as literal text whether+ /// it is escaped or not, so no entity table is needed. Every other character escapes+ /// exactly as `escapeAttribute` does, so the value still cannot end its attribute+ /// early, which is the property this exists to keep.+ static func escapeAttributePreservingReferences(_ value: String) -> String {+ let scalars = Array(value.unicodeScalars)+ var result = ""+ result.reserveCapacity(value.count)+ var index = 0+ while index < scalars.count {+ let scalar = scalars[index]+ switch scalar {+ case "&":+ if let end = characterReferenceEnd(in: scalars, from: index) {+ result.unicodeScalars.append(contentsOf: scalars[index..<end])+ index = end+ continue+ }+ result += "&"+ case "<": result += "<"+ case ">": result += ">"+ case "\"": result += """+ case "'": result += "'"+ default: result.unicodeScalars.append(scalar)+ }+ index += 1+ }+ return result+ }++ /// The index just past the `;` of the well-formed character reference starting at+ /// `start` (which must be a `&`), or nil when no such reference starts there.+ private static func characterReferenceEnd(in scalars: [Unicode.Scalar], from start: Int) -> Int? {+ var index = start + 1+ guard index < scalars.count else { return nil }+ let isNumeric = scalars[index] == "#"+ if isNumeric {+ index += 1+ guard index < scalars.count else { return nil }+ let isHex = scalars[index] == "x" || scalars[index] == "X"+ if isHex { index += 1 }+ let isDigit = isHex ? isASCIIHexDigit : isASCIIDigit+ let digitsStart = index+ while index < scalars.count, isDigit(scalars[index]) { index += 1 }+ guard index > digitsStart else { return nil }+ } else {+ guard isASCIILetter(scalars[index]) else { return nil }+ while index < scalars.count, isASCIILetter(scalars[index]) || isASCIIDigit(scalars[index]) {+ index += 1+ }+ }+ guard index < scalars.count, scalars[index] == ";" else { return nil }+ return index + 1+ }++ private static func isASCIIDigit(_ scalar: Unicode.Scalar) -> Bool {+ ("0"..."9").contains(scalar)+ }++ private static func isASCIILetter(_ scalar: Unicode.Scalar) -> Bool {+ ("a"..."z").contains(scalar) || ("A"..."Z").contains(scalar)+ }++ private static func isASCIIHexDigit(_ scalar: Unicode.Scalar) -> Bool {+ isASCIIDigit(scalar) || ("a"..."f").contains(scalar) || ("A"..."F").contains(scalar)+ } }
diff --git a/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift b/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swiftindex 5aade1b4..cb0a015a 100644--- a/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift+++ b/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift@@ -366,6 +366,157 @@ struct RawHTMLImageRewriteTests { } } +// MARK: - Attribute quoting safety (T-1942)++/// The rewriter always re-emits `src`/`srcset` double-quoted (`rewriteTag`), regardless of+/// how the source was quoted. Two carriers can put a raw `"` into the value the rewriter+/// re-emits: the `rewrite` closure's `data:` passthrough hands back the original bytes+/// unchanged (an unencoded SVG `data:` URI routinely contains `"`), and `rewriteSrcset`+/// re-joins each candidate's descriptor half — never passed through `rewrite` — verbatim.+/// Either one used to terminate the re-emitted attribute early, splicing the remainder into+/// attribute position. These tests parse the emitted tag back and assert the value survives+/// whole, rather than only checking for the escaped substring — the whole point of the bug+/// is that byte content the rewriter did not expect can still land in the output.+struct HTMLImageSourceRewriterQuotingTests {++ /// Extracts the value of a double-quoted `name="..."` attribute the way a browser would:+ /// up to the first `"`. If the rewriter left a raw `"` embedded in the value, this+ /// recovers only the truncated prefix — which is exactly the failure this suite guards.+ private func extractDoubleQuotedAttributeValue(named name: String, in tag: String) -> String? {+ guard let regex = try? NSRegularExpression(pattern: "\(name)=\"([^\"]*)\"") else { return nil }+ let nsTag = tag as NSString+ guard let match = regex.firstMatch(in: tag, range: NSRange(location: 0, length: nsTag.length)),+ match.numberOfRanges > 1 else { return nil }+ return nsTag.substring(with: match.range(at: 1))+ }++ /// Decodes exactly the entities `HTMLEscaping.escapeAttributePreservingReferences` can+ /// produce, matching at+ /// each position the way an HTML parser resolves character references — not sequential+ /// substring replacement, which would be ambiguous (a literal `&` is itself escaped to+ /// `&`, so no other entity's output can be mistaken for one).+ private func decodeAttributeEntities(_ value: String) -> String {+ let chars = Array(value)+ var result = ""+ result.reserveCapacity(chars.count)+ var index = 0+ while index < chars.count {+ if chars[index] == "&" {+ let remaining = String(chars[index...])+ if remaining.hasPrefix("&") { result.append("&"); index += 5; continue }+ if remaining.hasPrefix(""") { result.append("\""); index += 6; continue }+ if remaining.hasPrefix("'") { result.append("'"); index += 5; continue }+ if remaining.hasPrefix("<") { result.append("<"); index += 4; continue }+ if remaining.hasPrefix(">") { result.append(">"); index += 4; continue }+ }+ result.append(chars[index])+ index += 1+ }+ return result+ }++ @Test("Single-quoted src value containing a double quote round-trips intact (T-1942)")+ func singleQuotedSrcWithEmbeddedDoubleQuote() {+ // The ticket's example shape: an unencoded data: URI can legitimately contain raw+ // `"` characters. The `rewrite` closure's data: passthrough hands this back+ // unchanged, so the rewriter — not the closure — must make it safe. (No `<`/`>` in+ // the payload: the rewriter finds a tag's extent by scanning to the first `>`, which+ // is a separate, pre-existing limitation this ticket is not about.)+ let value = "data:application/json,{\"a\":1}"+ let raw = "<img src='\(value)'>"+ let rewritten = HTMLImageSourceRewriter.rewrite(raw) { $0 }+ guard let extracted = extractDoubleQuotedAttributeValue(named: "src", in: rewritten) else {+ Issue.record("src attribute did not parse back as one double-quoted attribute: \(rewritten)")+ return+ }+ #expect(decodeAttributeEntities(extracted) == value, "value must survive intact: \(rewritten)")+ #expect(rewritten.components(separatedBy: "src=\"").count == 2,+ "must parse back as exactly one src attribute: \(rewritten)")+ }++ @Test("Double-quoted src value containing a single quote round-trips intact (T-1942)")+ func doubleQuotedSrcWithEmbeddedSingleQuote() {+ let value = "data:application/json,{'a':1}"+ let raw = "<img src=\"\(value)\">"+ let rewritten = HTMLImageSourceRewriter.rewrite(raw) { $0 }+ guard let extracted = extractDoubleQuotedAttributeValue(named: "src", in: rewritten) else {+ Issue.record("src attribute did not parse back as one double-quoted attribute: \(rewritten)")+ return+ }+ #expect(decodeAttributeEntities(extracted) == value, "value must survive intact: \(rewritten)")+ #expect(rewritten.components(separatedBy: "src=\"").count == 2,+ "must parse back as exactly one src attribute: \(rewritten)")+ }++ @Test("A rewritten value containing both quote characters round-trips intact (T-1942)")+ func valueContainingBothQuoteCharactersEscaped() {+ // Independent of what the source tag looks like: the `rewrite` closure's return+ // value is what actually lands in the re-emitted attribute, and it must be made+ // safe whatever it contains.+ let raw = "<img src=\"placeholder\">"+ let value = "x\"y'z"+ let rewritten = HTMLImageSourceRewriter.rewrite(raw) { _ in value }+ guard let extracted = extractDoubleQuotedAttributeValue(named: "src", in: rewritten) else {+ Issue.record("src attribute did not parse back as one double-quoted attribute: \(rewritten)")+ return+ }+ #expect(decodeAttributeEntities(extracted) == value, "value must survive intact: \(rewritten)")+ #expect(rewritten.components(separatedBy: "src=\"").count == 2,+ "must parse back as exactly one src attribute: \(rewritten)")+ }++ @Test("A srcset descriptor containing a double quote does not break out of the attribute (T-1942)")+ func srcsetDescriptorWithEmbeddedDoubleQuoteEscaped() {+ // The carrier from the ticket's PR #319 comment: the descriptor half of a srcset+ // candidate is never passed through `rewrite`, only re-joined verbatim by+ // `rewriteSrcset`. A single-quoted srcset lets the descriptor carry a raw `"` —+ // `srcset='a.png 1x" onerror="evil()'` — which used to close the re-emitted+ // double-quoted attribute early and splice in a live `onerror` handler.+ let raw = "<img srcset='cat.png 1x\" onerror=\"evil()'>"+ let rewritten = HTMLImageSourceRewriter.rewrite(raw, using: BlockHTMLEmitter.rewriteImageSrc)+ guard let extracted = extractDoubleQuotedAttributeValue(named: "srcset", in: rewritten) else {+ Issue.record("srcset attribute did not parse back as one double-quoted attribute: \(rewritten)")+ return+ }+ let decoded = decodeAttributeEntities(extracted)+ #expect(decoded.hasSuffix("1x\" onerror=\"evil()"), "descriptor text must survive intact: \(decoded)")+ #expect(!rewritten.contains("onerror=\"evil()\""),+ "must not break out into attribute position: \(rewritten)")+ #expect(rewritten.components(separatedBy: "srcset=\"").count == 2,+ "must parse back as exactly one srcset attribute: \(rewritten)")+ }++ @Test("A data: value's existing character references are not escaped again (T-1942 review)")+ func dataURIWithExistingCharacterReferencesNotDoubleEscaped() {+ // The value the rewriter re-emits is the attribute's text as WRITTEN — its character+ // references undecoded (T-1977) — and the browser decodes the re-emitted attribute+ // once. An author embedding an SVG with a literal `&` in its text writes `&amp;`:+ // one layer for the attribute, one for the XML text underneath. Escaping that `&`+ // again hands the browser `&amp;amp;`, which decodes to `&amp;` — the SVG+ // parser then sees a reference where the author put a character, and the diagram+ // renders "Save & Export" instead of "Save & Export". A bare `&` (one starting+ // no well-formed reference) must still escape, or `"` handling would be the only+ // thing standing between the value and the attribute boundary.+ let value = "data:image/svg+xml,%3Csvg%3E%3Ctext%3ESave &amp; Export & more & a & b%3C/text%3E%3C/svg%3E"+ let raw = "<img src=\"\(value)\">"+ let rewritten = HTMLImageSourceRewriter.rewrite(raw, using: BlockHTMLEmitter.rewriteImageSrc)+ guard let extracted = extractDoubleQuotedAttributeValue(named: "src", in: rewritten) else {+ Issue.record("src attribute did not parse back as one double-quoted attribute: \(rewritten)")+ return+ }+ // Byte-for-byte: the references the author wrote survive, and only the bare `&`+ // gained an escape.+ #expect(extracted == "data:image/svg+xml,%3Csvg%3E%3Ctext%3ESave &amp; Export & more & a & b%3C/text%3E%3C/svg%3E",+ "existing references must pass through untouched: \(rewritten)")+ // After the browser's single decode the SVG text carries exactly the XML the author+ // intended — one `&` for the literal ampersand.+ #expect(decodeAttributeEntities(extracted).contains("Save & Export"),+ "single decode must yield the XML-escaped ampersand: \(rewritten)")+ #expect(rewritten.components(separatedBy: "src=\"").count == 2,+ "must parse back as exactly one src attribute: \(rewritten)")+ }+}+ // MARK: - Sanitized raw HTML embedding — DELIBERATE CHANGE group (Req 1.8) struct RawHTMLDeliberateChangeTests {
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9f8806be..fdb5c8de 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `HTMLImageSourceRewriter` no longer re-emits a mediated `src`/`srcset` value with an embedded quote character left unescaped (T-1942). The rewriter always re-emits these attributes double-quoted, but a value could still contain a raw `"`: the `rewrite` closure's `data:` passthrough hands unencoded `data:` URIs back unchanged, and `rewriteSrcset` re-joins a candidate's descriptor half — never passed through `rewrite` — verbatim. Either one could terminate the re-emitted attribute early, splicing the remainder into attribute position (e.g. `srcset='a.png 1x" onerror=… z='`). The value is now escaped for its double-quoted context before being written, so any quote characters it carries round-trip intact instead of breaking out. The escape leaves a character reference the value already carries alone (the `data:` passthrough re-emits the attribute's text as written, references undecoded, and the browser decodes it once), so an SVG `data:` URI whose author correctly wrote `&amp;` for a literal ampersand still renders that ampersand rather than the reference. This is defence in depth rather than a live exploit: the subsequent `HTMLSanitizer` (SwiftSoup) pass is the actual security boundary and already reduced the broken-out remainder to non-allowlisted junk. - The shared unit-test host no longer aborts part-way through a run and reports every still-queued test as a failure it never ran (T-2219, third pass). PR #380 fixed one cause of this — a synchronous `@MainActor` test body reaching WebKit off the main thread — and the cascade kept coming back with `make verify-test-isolation` passing, because there was a second, unrelated cause on a lifetime path no constructor check can see. A crash report captured during this investigation named it: WebKit raises an Objective-C exception on a Swift async job on the main thread, and the exception unwinds into a Swift frame, where `_swift_exceptionPersonality` calls `swift::fatalError` and aborts **inside the throw**. That last detail is why the failure had been so expensive to chase: the process dies before `NSSetUncaughtExceptionHandler` or any `catch` can see it, so nothing is recorded, the exception's own text is destroyed with the process, and the test the bundle blames is simply whichever job was resumed at that instant — twice it blamed `URLEncodingCorpusTests`, which does not touch WebKit at all. The path in this repository that can reach that stack is the `prism-doc://` scheme handler: it produced responses from an unstructured task into an unbounded stream buffer, so WebKit stopping a task (navigating away, superseding a load, releasing a page — all routine) raced every response not yet delivered, and a `WKURLSchemeTask` given anything after it has been stopped raises exactly that exception. Three of its four production points had no cancellation check at all. Every one of them now goes through a single sink that stops producing as soon as its consumer is gone, failures included, and a source-contract test fails the build if a new one bypasses it — a behavioural test is impossible here, since reproducing the race aborts the process running it. That sink is hardening rather than a closed door, and the code says so: its cancellation signal arrives only once the stream is already torn down, so it narrows the window instead of removing it. A bounded stream buffer is not the missing piece — `AsyncStream` has no back-pressure at any policy, so bounding it would drop response and body elements rather than slow the producer down. - Live-WebKit tests no longer hold hundreds of WebKit processes open at once (T-2219). Measured on the run that reproduced the abort: 230 WebKit helper processes started by one test host, 226 of them alive simultaneously in the instant it died, only 4 ever reclaimed — about 206 concurrent live pages. `-parallel-testing-worker-count 1` does not bound this; it bounds test host processes, while swift-testing runs tests concurrently inside one host with no cap, and the live-WebKit tests are the slowest in the target, so they are precisely the ones that accumulate. Every clean run peaked in the same place, so the pile-up is not itself the crash — it is the condition the crash needs, and it is why a run only fails under load and never reproduces a suite in isolation. Suites that can hold a live page are now charged against a shared budget, which took the peak from 226 to 59 and restored reclamation during the run. `make verify-test-isolation` fails when a suite that can reach WebKit is not covered, sharing one reachability model with the existing synchronous-construction rule so a suite cannot be visible to one check and invisible to the other; it found an uncovered suite on `main` the first time it ran, and eight more once `WebViewPool` and `SVGRenderer` were added to the list of production types it treats as building a page. That list is the boundary of both checks and is documented as such: a production type that builds a page but is not named there is invisible to them, and nothing can discover the omission automatically. Nothing is skipped, excluded or reordered, and the number of tests executed is unchanged. - `Tools/check-test-results.sh` now tells you what to look for when it detects the cascade (T-2219). It used to point at `~/Library/Logs/DiagnosticReports/prism-*.ips` and stop there. Those reports are frequently never written — three consecutive reproductions on the development machine produced none — and they rotate away within days, which is how this ticket twice lost the only evidence it had. The message now names the stack signature that identifies this abort, so a report that does exist can be read correctly on the first attempt, and states that there is no in-process alternative to it.
diff --git a/specs/bugfixes/image-rewriter-quote-preservation/report.md b/specs/bugfixes/image-rewriter-quote-preservation/report.mdnew file mode 100644index 00000000..c15a89f6--- /dev/null+++ b/specs/bugfixes/image-rewriter-quote-preservation/report.md@@ -0,0 +1,205 @@+# Bugfix Report: HTMLImageSourceRewriter Quote Preservation++**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++`HTMLImageSourceRewriter` (`prism/Services/WebRendering/`) mediates every `src`/`srcset`+attribute on raw-HTML `<img>`/`<source>` tags through the `prism-doc://` scheme handler+before the `HTMLSanitizer` pass runs. When it re-emits a rewritten attribute, it always+wraps the value in double quotes regardless of how the source attribute was quoted, and+regardless of what the rewritten value itself contains.++Two carriers let a raw `"` land in that re-emitted value:++1. The `rewrite` closure's `data:` passthrough (`BlockHTMLEmitter.rewriteImageSrc`) hands+ an admitted `data:` URI back byte-for-byte unchanged. An unencoded `data:` URI (e.g.+ inline SVG markup) can legitimately contain literal `"` characters.+2. `rewriteSrcset` splits each `srcset` candidate into a URL and a descriptor (`1x`,+ `480w`), passes only the URL through `rewrite`, and re-joins the descriptor verbatim.+ A single-quoted `srcset` attribute can carry a descriptor containing a raw `"`+ (`srcset='a.png 1x" onerror="evil()'`) — the scan grammar only terminates a+ single-quoted value at another `'`.++Either carrier, once re-emitted double-quoted with the embedded `"` left unescaped,+terminated the attribute early and turned the remainder of the tag into attribute text —+a quote-breakout in the markup this rewriter hands downstream.++**Reproduction steps:**+1. Call `HTMLImageSourceRewriter.rewrite("<img src='data:application/json,{\"a\":1}'>") { $0 }`.+2. Observe the output re-emits `src="data:application/json,{"a":1}"` — the embedded `"`+ closes the attribute after `{`, leaving `a":1}` as bogus attribute text.+3. Equivalently, rewrite `<img srcset='cat.png 1x" onerror="evil()'>` through+ `BlockHTMLEmitter.rewriteImageSrc` and observe a live `onerror="evil()"` attribute+ spliced into the tag.++**Impact:** Malformed markup handed to the subsequent `HTMLSanitizer` (SwiftSoup) pass.+`HTMLSanitizer` is the actual security boundary and already reduces the broken-out+remainder to non-allowlisted attribute junk, which it strips — so this was not a+demonstrated live exploit, but a weakened invariant in the rewriter whose whole job is to+hand the sanitizer well-formed mediated markup. It should not be relied on to stay benign+as the sanitizer's allowlist evolves. Found and scoped during the PR #319 (T-1655) review+and tracked separately as T-1942 since it pre-dates that change.++## Investigation Summary++- **Symptoms examined:** The re-emission line in `HTMLImageSourceRewriter.rewriteTag`+ (`output += "\(attribute.name)=\"\(rewritten)\""`) wraps `rewritten` in double quotes+ unconditionally, with a comment asserting the value could never contain one — an+ assumption that does not hold for the `data:` passthrough or for `rewriteSrcset`'s+ descriptor re-join.+- **Code inspected:** `HTMLImageSourceRewriter.swift` (the whole file — the attribute+ scanner, `rewriteTag`, `rewriteSrcset`), `BlockHTMLEmitter.rewriteImageSrc` and+ `admitsInlinePayload` (the `data:` passthrough policy), and `HTMLEscaping.swift` (the+ existing attribute-escaping helper already used elsewhere in the emitter).+- **Hypotheses tested:** Preserving the original quote delimiter instead of escaping was+ considered (the ticket's "smaller change" option) and rejected — see Alternatives+ Considered.++## Discovered Root Cause++`rewriteTag` re-emits every mediated `src`/`srcset` value hard double-quoted without+escaping it, on the false assumption that a mediated value (a `prism-doc://` URL or an+admitted `data:` URI) can never contain a `"`. Two paths falsify that assumption: the+`data:` passthrough returns attacker-controlled bytes unchanged, and `rewriteSrcset`+re-joins a descriptor that was never passed through `rewrite` at all.++**Defect type:** Missing output escaping / unwarranted invariant.++**Why it occurred:** The rewriter was designed around the common case — a rewritten value+is a `prism-doc://img/?src=...` URL, which is always percent-encoded and therefore quote-free.+The `data:` passthrough and the srcset descriptor are both exceptions to that common case+that the re-emission code did not account for.++**Contributing factors:** The scan's own value grammar (`attributeValue(in:from:)`) is+deliberately permissive about what a *source* attribute value can contain (a+single-quoted value may contain a raw `"`, and vice versa) so that mediation is total+across every HTML5-legal quoting style (T-1655). That same permissiveness on the input+side is what makes the fixed double-quote re-emission on the output side unsafe.++## Resolution for the Issue++**Changes made:**+- `prism/Services/WebRendering/HTMLImageSourceRewriter.swift:119` — re-emit the value+ through `HTMLEscaping.escapeAttributePreservingReferences` before wrapping it in double+ quotes, so any `"`, `'`, `<`, `>`, or bare `&` character it carries is entity-escaped+ rather than written raw.+- `prism/Services/WebRendering/HTMLEscaping.swift` — added+ `escapeAttributePreservingReferences(_:)`: identical to `escapeAttribute` except that a+ `&` beginning a well-formed character reference (`&name;`, `&#digits;`, `&#xhex;`) is+ passed through rather than escaped to `&`.++**Approach rationale:** Escaping (rather than switching the re-emitted delimiter to match+the source) is the more robust fix, as the ticket noted: it is correct regardless of+whether the value contains one quote character, the other, or both. Because escaping is+applied to the final value `rewriteTag` writes — after `rewriteSrcset` has already joined+URL and descriptor — one change point covers both carriers described above.++The first cut used the existing `HTMLEscaping.escapeAttribute` and the PR review caught a+regression in it: the value on the `data:` passthrough branch is the attribute's text as+*written*, character references undecoded (that decoding is T-1977, out of scope here),+and the browser decodes the re-emitted attribute exactly once. An author embedding an SVG+with a literal `&` in its text writes `&amp;` — one layer for the attribute, one for+the XML underneath. `escapeAttribute` turned that into `&amp;amp;`, which the browser+decodes to `&amp;`, so the SVG parser saw a reference where the author put a+character and rendered "Save & Export". The `prism-doc://` branch was never affected:+`URLComponents` percent-encodes `&` to `%26` before the escape sees it.++The fix escapes a `&` only when it does NOT begin a well-formed reference. That needs no+entity table: a browser renders an unknown `&foo;` as literal text whether its `&` is+escaped or not, so the output is observably identical to the pre-fix verbatim re-emission+for every input except one carrying a raw `"`, `'`, `<`, `>`, or bare `&` — exactly the+characters the fix exists to neutralise.++**Alternatives considered:**+- **Preserve the original quote delimiter:** Re-emit with whichever quote character the+ source used. Rejected because a value can contain *both* quote characters (e.g. a+ `data:` URI with both `"` and `'` in its payload), which no single delimiter choice can+ make safe without escaping anyway — escaping the delimiter character is required either+ way, so preserving the delimiter buys nothing.+- **Only escape at the two known call sites (`rewrite` closure's data: passthrough,+ `rewriteSrcset`):** Rejected because it duplicates the escaping decision across+ producers instead of making the sole re-emission point in `rewriteTag` responsible for+ producing well-formed output regardless of what it is handed — which is also the+ invariant the rewriter's own doc comment claims to guarantee.+- **Decode the value's character references, then `escapeAttribute` it:** Rejected+ because a faithful decode needs HTML's full named-reference table (SwiftSoup has one,+ but its static pools are unsynchronised and every use has to queue behind the+ sanitizer's mutex), and decoding the value changes what T-1977 is scoped to decide.+ Leaving well-formed references alone reaches the same rendered result without either.++## Regression Test++**Test file:** `prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift`+**Test name:** `HTMLImageSourceRewriterQuotingTests` (5 tests):+ - `singleQuotedSrcWithEmbeddedDoubleQuote()`+ - `doubleQuotedSrcWithEmbeddedSingleQuote()`+ - `valueContainingBothQuoteCharactersEscaped()`+ - `srcsetDescriptorWithEmbeddedDoubleQuoteEscaped()`+ - `dataURIWithExistingCharacterReferencesNotDoubleEscaped()` — an SVG `data:` value+ carrying `&amp;`, `&`, `&` and a bare `&`, run through the production+ `rewriteImageSrc` closure; asserts the references survive byte-for-byte, only the+ bare `&` gains an escape, and the browser's single decode yields `Save & Export`++**What it verifies:** Each test parses the emitted tag back the way a browser would (up+to the first unescaped `"`), decodes the handful of entities the escape can+produce, and asserts the recovered value equals the original input exactly — proving the+value round-trips as ONE attribute rather than only checking for an escaped substring.+The srcset test additionally asserts no `onerror="evil()"` attribute was spliced into the+tag (the concrete injection shape from the ticket).++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+ -destination 'platform=macOS' -testPlan prism -only-test-configuration "en (base)" \+ -only-testing:prismTests/HTMLImageSourceRewriterQuotingTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/WebRendering/HTMLImageSourceRewriter.swift` | Escape the re-emitted `src`/`srcset` value via `HTMLEscaping.escapeAttributePreservingReferences` before wrapping it in double quotes; updated the stale doc comment asserting the value could not contain a quote |+| `prism/Services/WebRendering/HTMLEscaping.swift` | Added `escapeAttributePreservingReferences(_:)` for attribute text whose references are not yet decoded |+| `prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift` | Added `HTMLImageSourceRewriterQuotingTests` (5 regression tests) |+| `CHANGELOG.md` | Added `[Unreleased]/Fixed` entry |++## Verification++**Automated:**+- [x] Regression tests pass (`HTMLImageSourceRewriterQuotingTests`, 5/5)+- [x] Targeted suite passes with no regressions: `RawHTMLImageRewriteTests`,+ `BlockHTMLEmitterImageRewriteTests`, `HTMLImageSourceRewriterQuotingTests` — 28/28+ passed, confirmed via `Tools/check-test-results.sh`+- [x] `make lint` passes+- [x] `make build-macos` passes+- Full `make test-quick` was not run — this machine was running several other worktrees'+ test suites concurrently, and `make test-quick` is documented as unreliable under that+ contention. The targeted run above covers every test that exercises+ `HTMLImageSourceRewriter`.++**Manual verification:** N/A — covered by the automated regression tests above, which+assert on the exact injection shapes described in the ticket.++## Prevention++**Recommendations to avoid similar bugs:**+- When re-emitting a value into a fixed-delimiter context (always double-quoted, in this+ case), escape for that delimiter at the point of re-emission rather than assuming+ upstream producers cannot supply the delimiter character. The producer's contract can+ change (or, as here, never actually excluded the character to begin with).+- `HTMLEscaping.escapeAttribute` is the project's existing helper for this; prefer it over+ ad hoc quoting assumptions anywhere HTML is hand-assembled — but only for a value that+ is plain text. A value lifted from a document's own markup with its references still+ undecoded needs `escapeAttributePreservingReferences`, or the browser's single decode+ hands the page the reference instead of the character.++## Related++- Transit T-1942 (this ticket)+- T-1655 (PR #319) — widened the rewriter's attribute scan to unquoted values; confirmed+ in T-1942's ticket comments to have introduced no new carrier for this defect.+- T-1976, T-1977 — other open tickets touching `HTMLImageSourceRewriter.swift` in later+ batches; this fix is scoped to quoting only and does not touch the areas those tickets+ cover.
escapeAttribute (plain-text values) and escapeAttributePreservingReferences (attribute text lifted undecoded from a document) now coexist. Both are doc-commented on when to use which; the risk is a future call site picking the wrong one. The rewriter is the only legitimate caller of the preserving variant until T-1977 decides whether to decode first.
The working tree was being edited (and built) by the concurrent fixer throughout, so the targeted suites ran against a git archive export of dd56e997 in /tmp with its own DerivedData — 46/46. 4b91dc86 landed after that export was taken, so its escapeAttributePreservingReferences path and fifth test have been traced by hand but not executed by this review.
HTMLSanitizer strips srcset entirely (it cannot protocol-check the multi-URL syntax), so the descriptor test proves the rewriter's markup shape, not a value that survives to the page. That is fine for this ticket's invariant, but it is why an end-to-end test would want the data: src shape rather than the srcset one.