Skip to content

fix(blocks): keep feeds same-origin and sanitize inserted HTML - #197

Merged
miguelpeixe merged 8 commits into
trunkfrom
fix/view-rest-url-origin
Oct 8, 2026
Merged

miguelpeixe merged 8 commits into
trunkfrom
fix/view-rest-url-origin

Conversation

@miguelpeixe

@miguelpeixe miguelpeixe commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

The Rolling Coverage feed refreshes itself in the browser, fetching new entries from the site and inserting them into the page while readers watch. This change tightens where a feed may fetch from and what it may insert, so a feed only loads entries from the site's own API and nothing active can ride in with an entry.

What changes

The view script now ignores a feed whose data source points anywhere other than the current site, accepts only the kind of reply the site's API sends, and cleans every piece of markup it inserts before it reaches the page: entries, ad placeholders, and the swap to the latest posts alike.

With this change:

  • A feed loads its entries only from the same site that served the page.
  • Scripts, event handlers, embedded documents, and script links are removed from anything a feed inserts.
  • Ads, image alt text, captions, and other entry content render as before.

On a page served from a domain other than the site address, such as an alias domain that doesn't redirect, the feed shows its first page of entries without updating, and the browser console says why.

How to test

  1. Open a published page with a Rolling Coverage feed, with the browser console open. No Rolling Coverage warning appears.
  2. Add a new entry to that coverage. Within the poll interval it appears in the feed with no reload.
  3. Add an entry with an image whose alt text starts with "Data:". Once it appears in the feed, inspect the image. The alt text is intact.
  4. Scroll to the end of the feed, or press Load More. Older entries load as before.
  5. On an ad-enabled feed, check that ads still fill between entries.
  6. Open a shared single-entry view of an active coverage and use the "newer posts" control. The live feed swaps in without a full navigation.
Technical details

A feed's REST URL comes from the block's markup, which an author without unfiltered_html can still set, and with it the server whose HTML the feed inserts. So:

  • initBlock() honours data-rest-url only when it is same-origin with the page (isSameOrigin()), and logs a console warning when it refuses one.
  • fetchEntries() drops a reply that a redirect carried to another origin, or a successful one that isn't JSON, so a same-origin uploaded file can't stand in for the entries route. The jump to the live feed takes only a same-origin HTML page.
  • Every fragment the script inserts (poll entries, load more, off-page updates, ad markup, and the jump to the live feed) passes through sanitizeHtml() on top of the server's KSES. It removes scripts, object/embed, base, meta[http-equiv], SVG animate/set, on* handlers, and srcdoc; javascript: and vbscript: URLs in any attribute; and data: URLs in href, src, xlink:href, action, and formaction. It reads a URL's scheme the way the browser's URL parser does, so an alt text that opens with "Data:" survives. A provider's ad placeholder survives too; the ad's own script loads the creative.

Because of the same-origin gate, a page served from a host other than rest_url()'s shows a static first page, even though WordPress's REST CORS headers would let it poll cross-origin. Core redirects between www and non-www, so this is limited to alias domains and proxies that nothing redirects.

Verified in a headless Chromium harness against the built view.js, before and after each change: an off-origin feed URL, a redirect to another origin, a non-JSON reply, and active content in entries and ad markup are all refused or stripped, while a legitimate same-origin entry inserts with its text, links, alt text, and ad placeholder intact. tsc --noEmit and lint-js are clean; CI runs the PHP and JS suites on push.

Self-review: five rounds (Newspack WP expert, deep (Opus 5.5), deep (opus)), no blockers fixed.

🤖 Generated with Claude Code

The feed's REST URL and entry HTML come from block markup, which an author without unfiltered_html can still set. initBlock() now honours data-rest-url only when it is same-origin with the page, and sanitizeHtml() also drops srcdoc, object/embed and javascript: URLs on top of scripts and on* handlers. Load more runs its entries through the same sanitizer as polls. Ad markup is left as served, since it is same-origin and needs the markup an ad ships with.
Same-origin isn't the same as trusted: the planted root also chooses the full data-rest-url, so the reply need not be the plugin's own KSES'd output. Route adHtml and the jump-to-latest feed (its control and entries) through sanitizeHtml() too, so every client-inserted fragment is cleaned uniformly. A provider's ad placeholder survives sanitizing; the ad's own script loads the creative. Updates the DEVELOPMENT.md note and the sanitizeHtml docblock to match.
@miguelpeixe

miguelpeixe commented Oct 7, 2026 •

Copy link
Copy Markdown
Member Author

Self-review summary — 5 rounds on this branch before handoff.

Accepted:

  • 36d93a4: Ad markup and the jump to the live feed go through the same sanitizer as polled entries, since a same-origin URL alone doesn't prove a reply is the plugin's own output.
  • 155ebca: The sanitizer reads a URL's scheme the way the browser does and removes data: only from attributes that can open a document, so alt text and Lite captions that open with "Data:" survive. It also removes base, http-equiv meta, and SVG animate/set. Polls and load more accept only JSON replies and the jump only an HTML page, so a same-origin upload can't stand in for the feed. A feed refused for an off-origin REST URL logs a console warning.
  • 07022ae: The docblock and DEVELOPMENT.md name the five attributes data: URLs are removed from.
  • Round 5: no changes — confirmed the above holds.

Declined:

  • A relative data-rest-url passes the origin check, but the request builder rejects it and the feed stays inert. That fails safe, and honouring relative URLs would widen what a planted root can reach.
  • Checking against a REST root the server prints, which would keep feeds updating on an alias domain that doesn't redirect. That case is rare; the gate stays, with the console warning and the trade-off documented.

@miguelpeixe
miguelpeixe requested a balanced review from Copilot October 7, 2026 23:15

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@miguelpeixe
miguelpeixe requested a lite review from Copilot October 7, 2026 23:16
@miguelpeixe
miguelpeixe marked this pull request as ready for review October 7, 2026 23:16

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.

🟡 Changes recommended

Unresolved iframe sanitization, redirect validation, and relative-URL handling issues remain.

2 open findings

🧠 Review effort: Lite

Comment thread src/blocks/rolling-coverage/view.ts Outdated
Comment thread src/blocks/rolling-coverage/view.ts

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.

🟡 Changes recommended

Unresolved security gaps remain around redirects, executable URL attributes, and embedded iframes.

3 open findings

🧠 Review effort: Lite


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread src/blocks/rolling-coverage/view.ts Outdated
Addresses PR review. sanitizeHtml() now also drops data: and vbscript: URLs in href/src, so a data:text/html iframe (which survived as an opaque-origin frame) no longer renders; KSES omits data: from its protocols too. fetchEntries() drops a reply whose final URL is not same-origin, so a same-origin data-rest-url that redirects to another origin can't feed the page — matching the check fetchLiveBlock() already makes.
Addresses PR review. The scheme check was gated to href and src, so action, formaction and SVG xlink:href could still carry a javascript: URL that runs on submit or click. Test the scheme on every attribute's value instead, so any URL-bearing attribute is covered; a normal https URL is untouched.
Self-review round 3. sanitizeHtml() now reads a URL's scheme the way the browser's parser does and removes data: only from attributes that load or navigate, so an alt text, title or Lite placeholder caption that opens with "Data:" survives; it also removes base, http-equiv meta and SVG animate/set elements. fetchEntries() drops a successful reply that isn't JSON and the jump to the live feed takes only an HTML page, so a same-origin uploaded file can't stand in for the feed. initBlock() logs a warning when it refuses an off-origin REST URL.
Self-review round 4. The sanitizeHtml docblock and the DEVELOPMENT.md note described data: removal as covering every attribute the browser loads, but it covers href, src, xlink:href, action and formaction only.
@miguelpeixe
miguelpeixe merged commit 3cbfe35 into trunk Oct 8, 2026
5 checks passed
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Hey @miguelpeixe, good job getting this PR merged! 🎉

Now, the needs-changelog label has been added to it.

Please check if this PR needs to be included in the "Upcoming Changes" and "Release Notes" doc. If it doesn't, simply remove the label.

If it does, please add an entry to our shared document, with screenshots and testing instructions if applicable, then remove the label.

Thank you! ❤️

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants