Skip to content

🐛 Fixed SVG favicons not rendering in bookmark cards - #29732

Merged
vershwal merged 2 commits into
mainfrom
fix-bookmark-svg-icon-content-type
Aug 4, 2026
Merged

🐛 Fixed SVG favicons not rendering in bookmark cards#29732
vershwal merged 2 commits into
mainfrom
fix-bookmark-svg-icon-content-type

Conversation

@vershwal

@vershwal vershwal commented Aug 4, 2026

Copy link
Copy Markdown
Member

ref https://linear.app/ghost/issue/ONC-1930/substack-favicon-broken

Bookmark card icons are stored without a content type, so GCS serves them as an
octet-stream. Browsers sniff raster formats and render them anyway, but they do
not sniff SVG, so SVG favicons in bookmark cards show as broken images.
Reported via escalation.

Note: this PR has been rebuilt twice and earlier review comments refer to
older versions. It previously sanitized SVG with DOMPurify and stored it as
image/svg+xml (~600 lines), then rasterized but also plumbed a content type
through the storage adapter. Both are gone.

Why this path and not image cards

Image cards go through save(), which has always passed file.type from the
upload middleware. Bookmark icons go through saveRaw() — they are fetched
server-side rather than uploaded — and saveRaw() never set a type. Same class
of bug as #26637, on the half of the storage interface that fix did not cover.

What this does

Converts bookmark SVGs to PNG before storing them, using the conversion
handle-image-sizes.js already applies when it converts an SVG. Nothing else
changes — no storage interface change, no content type, no changeset.

A PNG needs no content type to render: browsers sniff it, which is exactly why
every PNG and JPG favicon already renders fine from the same octet-stream that
breaks SVG. Converting also means we never store a document that can carry
script under our own origin, and these bytes come from whatever site an author
bookmarked.

Local storage types a file by its extension, so the URL's extension triggers
conversion in its own right. Everything that could be stored under a .svg
name is converted or not stored at all, which makes a missed content sniff
harmless rather than a stored script — on main an SVG favicon is already
served as image/svg+xml from the site's own origin, unsanitized.

Review confirmed no SSRF, no local file read and no XXE reach librsvg, that the
PNG output carries no metadata from the source, and that the naming invariant
survives case variants, percent-encoding, double extensions, query strings and
gzip.

Bounds

Rasterizing untrusted input needs bounding, and the obvious bounds do not hold.
Each of these was measured, and each was found by building the shape designed to
defeat the previous bound:

unbounded bounded
filter-heavy SVG, single render 106,102ms 811ms
4 concurrent, threadpool probe 226,772ms 1,480ms
266-byte extreme aspect ratio 30MB stored 219KB stored
.svgz, 10KB inflating to 1.1MB 47,685ms rejected in 1ms
  • timeout does not constrain librsvg — it renders during load, before libvips
    checks its deadline
  • input size alone does not either, because filter primitives cost output-area
    time rather than parse time
  • neither says anything about the output, so both dimensions are fixed at
    256x256, matching the icon entry in imageOptimization.internalImageSizes
  • gzip hides its size from a byte cap entirely, so compressed input is rejected
    rather than converted

Known limitations

  • Forward-only. Existing stored icons keep their octet-stream and stay
    broken. A header-only backfill is not safe for the SVGs, since those bytes
    were never converted; re-fetching is the right remediation, and it mints a new
    URL so the bookmark cards in post content need updating too.
  • Self-hosted SVG icons lose vector crispness — 256px rather than vector.
    Not visible at the 20px a bookmark icon renders at.
  • SVG thumbnails are cropped to 256x256. SVG og:images are rare, the card
    already crops with object-fit: cover, and a wider target is most of the DoS
    exposure above.
  • Gzipped SVGs, SVGs over 32KB, and installs without sharp fall back to
    DEFAULT_BOOKMARK_ICON.
  • .html/.xhtml from an attacker's URL are still stored verbatim and
    served executable from the site's own origin on self-hosted. Unchanged from
    main and out of scope here — needs its own fix.
  • external-media-inliner has the same missing content type and is untouched.

Testing

50 oembed unit tests. Every guard has a test that fails when the guard is
removed, including the extension match being case-insensitive, which is what
the naming invariant rests on.

@nx-cloud

nx-cloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 0558355

Command Status Duration Result
nx run ghost:test:ci:integration ✅ Succeeded 3m 17s View ↗
nx run ghost:test:integration ✅ Succeeded 3m 18s View ↗
nx run ghost:test:legacy ✅ Succeeded 2m 19s View ↗
nx run ghost:test:e2e ✅ Succeeded 2m 41s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded 16s View ↗
nx run-many -t test:unit -p ghost ✅ Succeeded 33s View ↗
nx run-many -t lint -p ghost,ghost-monorepo ✅ Succeeded 16s View ↗
nx run @tryghost/admin:build ✅ Succeeded 8s View ↗
nx run-many --target=build --projects=tag:publi... ✅ Succeeded 1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-08-04 07:02:54 UTC

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The oEmbed image service detects SVG images by extension or content. It rejects oversized and gzip-compressed SVGs. It rasterizes accepted SVGs to 256×256 PNGs with a 10-second timeout. Stored SVG results use the .png extension. Raster images retain their original bytes and extension. Tests cover detection, validation, conversion, malformed input, storage behavior, and raster-image preservation.

Possibly related PRs

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: fixing SVG favicon rendering in bookmark cards.
Description check ✅ Passed The description directly explains the SVG rasterization fix, its safeguards, scope, limitations, and tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-bookmark-svg-icon-content-type

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vershwal
vershwal force-pushed the fix-bookmark-svg-icon-content-type branch from 319b3ce to 34f6282 Compare August 4, 2026 03:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
ghost/core/core/server/lib/image/sanitize-svg.js (1)

43-46: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider reusing one JSDOM window across both helpers.

Each call to sanitizeSvgContent and isRenderableSvgDocument builds a new JSDOM window. The oEmbed path calls both for a single icon, so one bookmark request now builds two windows on the request thread. A lazily created module-level window and DOMPurify instance removes that cost. Keep the require calls lazy so boot time is unaffected.

This is optional. The per-call construction matches the previous behavior in upload.js, so it is not a regression.

♻️ Proposed lazy shared window
+let cachedWindow;
+
+const getWindow = () => {
+    if (!cachedWindow) {
+        const {JSDOM} = require('jsdom');
+        cachedWindow = new JSDOM('').window;
+    }
+    return cachedWindow;
+};

Also applies to: 76-77

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/lib/image/sanitize-svg.js` around lines 43 - 46,
Optionally update the SVG helper initialization so jsdom and DOMPurify are
loaded lazily once, then reuse a module-level JSDOM window and DOMPurify
instance across sanitizeSvgContent and isRenderableSvgDocument. Preserve lazy
require behavior so application boot time remains unaffected and both helpers
share the same cached instances.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@ghost/core/core/server/lib/image/sanitize-svg.js`:
- Around line 43-46: Optionally update the SVG helper initialization so jsdom
and DOMPurify are loaded lazily once, then reuse a module-level JSDOM window and
DOMPurify instance across sanitizeSvgContent and isRenderableSvgDocument.
Preserve lazy require behavior so application boot time remains unaffected and
both helpers share the same cached instances.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a0c1b121-d733-4925-90bf-06dd6590bcbb

📥 Commits

Reviewing files that changed from the base of the PR and between 2be5e46 and 319b3ce.

📒 Files selected for processing (8)
  • .changeset/smart-icons-render.md
  • ghost/core/core/server/adapters/storage/S3Storage.ts
  • ghost/core/core/server/lib/image/sanitize-svg.js
  • ghost/core/core/server/services/oembed/oembed-service.js
  • ghost/core/core/server/web/api/middleware/upload.js
  • ghost/core/test/unit/server/adapters/storage/s3-storage.test.ts
  • ghost/core/test/unit/server/services/oembed/oembed-service.test.js
  • packages/adapters/storage-base/src/base.ts

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 34.09091% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.45%. Comparing base (2be5e46) to head (16e5744).

Files with missing lines Patch % Lines
...core/core/server/services/oembed/oembed-service.js 34.09% 29 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #29732      +/-   ##
==========================================
+ Coverage   75.43%   75.45%   +0.02%     
==========================================
  Files        1612     1612              
  Lines      141929   141970      +41     
  Branches    17549    17565      +16     
==========================================
+ Hits       107060   107127      +67     
- Misses      33797    33798       +1     
+ Partials     1072     1045      -27     
Flag Coverage Δ
e2e-tests 77.58% <34.09%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@vershwal
vershwal force-pushed the fix-bookmark-svg-icon-content-type branch from 34f6282 to 0558355 Compare August 4, 2026 06:33
ref https://linear.app/ghost/issue/HKG-1836/

Bookmark icons and thumbnails are stored without a content type, so GCS serves
them as an octet-stream. Browsers sniff raster formats and render them anyway,
but they do not sniff SVG, so SVG favicons show as broken images.

Converting them to PNG on the way in fixes that without any storage change,
since a PNG is sniffed like every other raster favicon already is. It also
means we never store a document that can carry script under our own origin —
these bytes come from whatever site the author bookmarked.

Local storage types a file by its extension, so the URL's extension triggers
conversion in its own right: everything that could be stored under a `.svg`
name is converted or not stored at all, which makes a missed content sniff
harmless rather than a stored script.

Rasterizing untrusted input needs bounding, and the obvious bounds do not hold.
`timeout` does not constrain librsvg, which renders during load before libvips
checks its deadline. Input size alone does not either, because filter
primitives cost output-area time. Neither says anything about the output, where
a few hundred bytes of extreme aspect ratio rendered to tens of megabytes. And
gzip hides its size from a byte cap entirely — 10KB of `.svgz` inflated to
1.1MB and took 47s — so compressed input is rejected rather than converted.
Input is capped at 32KB, output is fixed at 256x256, and the worst case inside
those is 0.8s.
@vershwal
vershwal force-pushed the fix-bookmark-svg-icon-content-type branch from 0558355 to f2830ac Compare August 4, 2026 06:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ghost/core/test/unit/server/services/oembed/oembed-service.test.js`:
- Around line 834-842: Update processImageFromUrl so the rasterized PNG call to
saveRaw passes image/png as its third argument, then extend the SVG test for
saveRaw to assert that content type while preserving the existing path and
PNG-content assertions.
- Around line 883-892: Decode the URL pathname before extracting the filename in
the image-processing path, ensuring percent-encoded extensions such as %2E are
recognized by path.basename/path.extname and routed through shouldRasterize.
Extend the oEmbed service tests around the existing case-insensitive SVG test to
cover favicon%2ESVG and assert the saved output ends with .png.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 50443947-e386-4880-a0fb-f59054bfb05e

📥 Commits

Reviewing files that changed from the base of the PR and between 0558355 and f2830ac.

📒 Files selected for processing (2)
  • ghost/core/core/server/services/oembed/oembed-service.js
  • ghost/core/test/unit/server/services/oembed/oembed-service.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/core/server/services/oembed/oembed-service.js

Comment thread ghost/core/test/unit/server/services/oembed/oembed-service.test.js
Comment thread ghost/core/test/unit/server/services/oembed/oembed-service.test.js
@vershwal
vershwal requested a lite review from Copilot August 4, 2026 06:55
@vershwal

vershwal commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses bookmark-card SVG favicons rendering as broken images when stored via saveRaw() without a content-type (e.g., on GCS defaulting to application/octet-stream). Instead of plumbing MIME type through storage, it rasterizes SVG bookmark images to 256×256 PNGs before storing, which browsers reliably render via sniffing and avoids storing executable SVG under the site origin.

Changes:

  • Detect SVGs by extension (.svg/.svgz, case-insensitive) and by lightweight content sniffing, then convert to PNG via @tryghost/image-transform before imageStore.saveRaw().
  • Add guardrails: reject oversized SVG inputs (>32KB) and gzipped inputs (SVGZ/gzip magic) with a specific validation error.
  • Add unit test coverage for conversion, bounds, extension/case handling, and rejection paths.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
ghost/core/core/server/services/oembed/oembed-service.js Adds SVG detection + guarded rasterization to PNG in processImageFromUrl() before storing bookmark icons/thumbnails.
ghost/core/test/unit/server/services/oembed/oembed-service.test.js Adds a focused test suite validating PNG conversion, 256×256 bounding, case/extension invariants, and rejection of gzip/oversized SVG inputs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@vershwal
vershwal merged commit 5cb9b02 into main Aug 4, 2026
50 checks passed
@vershwal
vershwal deleted the fix-bookmark-svg-icon-content-type branch August 4, 2026 07:30
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