What we learned shipping <newsflash-feed>

We’ve just open-sourced Newsflash — a web component that renders an RSS or Atom feed in one of five layouts, plus a WordPress plugin that drives it from a shortcode. MIT, ~13 kB gzipped with Lit bundled, no runtime dependencies:

<script src="https://cdn.jsdelivr.net/npm/@qlibr/newsflash-feed/dist/newsflash-feed.standalone.js" defer></script>
<newsflash-feed src="/feed.xml" layout="magazine"></newsflash-feed>

That’s the marketing paragraph. It’s also the least interesting thing about the project.

Rendering a feed is a solved problem — it’s a list with a title, a date, and maybe a thumbnail. What isn’t solved, and what ate most of the development time, is everything around it: a server-side fetcher that an untrusted URL can point anywhere, a public REST route that must not become an open proxy, and a custom element lifecycle that fires in an order nobody documents until it bites you.

This post is about those parts.


Part 1: “Fetch this URL” is a loaded gun

The WordPress plugin’s job sounds trivial. An editor writes:

[newsflash feed="https://example.com/feed.xml" layout="cards" columns="3"]

…and PHP fetches that URL server-side, normalizes it, and embeds the result in the page. One round-trip, content present in the HTML for crawlers, no client-side fetch at all.

But look at what just happened. A string chosen by whoever can place a shortcode becomes an outbound HTTP request from inside your infrastructure. That’s Server-Side Request Forgery with a friendly UI. On a shared WordPress install where “Author” is not the same trust level as “root on the box”, it’s a real boundary.

WordPress ships wp_http_validate_url() for exactly this, and it is almost enough. It rejects non-http(s) schemes, loopback, and the RFC1918 private ranges. What it permits is 169.254.0.0/16.

That’s the link-local range. It’s where cloud instance metadata lives:

http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.170.2/v2/credentials/           # ECS task role credentials

A blind SSRF into the metadata service is not a theoretical finding. So every address a feed host resolves to gets checked against PHP’s private and reserved ranges, which do cover link-local:

$public = filter_var(
    $address,
    FILTER_VALIDATE_IP,
    FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ( ! $public ) {
    return false;
}

Note the loop: we check every address the name resolves to, not the first one. A hostname with both a public A record and an internal one shouldn’t be a coin flip.

The check that doesn’t actually check anything

Here’s where it gets interesting, and where a lot of SSRF filters in the wild are quietly broken.

You resolve the hostname. It comes back 93.184.216.34. Public. You approve it. You hand the URL to cURL. cURL resolves the hostname again and connects to whatever it gets this time.

Between your check and cURL’s connect, an attacker who controls the DNS record — with a one-second TTL — can flip it to 169.254.169.254. Your validation passed. Your request went somewhere else. This is DNS rebinding, and against a naive validate-then-fetch it works reliably, because you’re not validating the connection, you’re validating a lookup that gets thrown away.

The fix is to make sure the second lookup never happens. cURL has an option for it — CURLOPT_RESOLVE, which pins a host:port to specific addresses in its internal resolver cache:

$pin = static function ( $handle, $args, $url ) use ( &$pins ) {
    $key = self::pin_key( $url );
    if ( isset( $pins[ $key ] ) ) {
        curl_setopt( $handle, CURLOPT_RESOLVE, array( $key . ':' . $pins[ $key ] ) );
    }
};

So validation resolves the host, stashes the addresses it approved, and the pin setter feeds exactly those addresses back to cURL. There is one lookup per request, and the address that was vetted is the address that gets connected to. Same lookup, same decision, no window.

Two details in there that took a while to get right:

The pin key has to match how cURL keys connections — host:port, with the port defaulted from the scheme (443/80). Get that wrong and the pin silently doesn’t apply; the fetch still works, so nothing tells you the protection evaporated. And an IP literal is skipped entirely: there’s no name to rebind, and an IPv6 literal would produce a malformed key.

Redirects are hops too

Validating the URL an editor typed is the easy half. A perfectly legitimate public feed can answer 302 Location: http://169.254.169.254/, and now your carefully-vetted request lands on the metadata service anyway.

WordPress helps here in a way that looks like an accident but is load-bearing: WP_Http_Curl disables cURL’s own redirect following and re-enters WP_Http::request() for each hop. Which means the pre_http_request filter fires once per hop — the initial request and every redirect alike. So the same gate that validates the first URL validates each redirect target, and re-pins it:

$gate = static function ( $preempt, $args, $url ) use ( &$pins ) {
    $addresses = self::validate( $url );
    if ( false === $addresses ) {
        return new WP_Error( 'newsflash_blocked_host', /* … */ );
    }
    // Latest wins if a redirect chain revisits a host: it was just
    // re-validated, so trust the fresh address over an earlier one.
    $pins = array_merge( $pins, self::pins_for( $url, $addresses ) );
    return $preempt;
};

Both hooks read from the same $pins stash, so the gate and the pin can’t disagree about what was approved — a single lookup feeds both. There are unit tests for the pin and the redirect gate, and a wp-env integration test that runs a real redirect chain end-to-end, because “this hook fires per hop” is exactly the kind of assumption that silently stops being true.

Failing closed

The pin needs cURL. WordPress’s streams fallback transport exposes no equivalent, so on an install without cURL, the connection would re-resolve and the rebinding window reopens.

We refuse to fetch:

if ( ! self::can_pin() && apply_filters( 'newsflash_require_pinned_transport', true ) ) {
    return new WP_Error(
        'newsflash_unpinnable_transport',
        __( 'Refusing to fetch: cURL is required to pin the connection against DNS rebinding.', 'newsflash-rss' )
    );
}

This is the uncomfortable choice. Failing closed means a small number of installs see an error instead of a feed, and the plugin looks broken. Failing open means everyone gets a feed and a subset of them get an SSRF. There’s a filter to opt back in for anyone who has actually thought about it — and the redirect gate still applies either way — but the default is “don’t”.

If you take one thing from this section: an SSRF allowlist that validates a URL and then hands the string to an HTTP client is not an allowlist. It’s a suggestion. The thing you connect to is the thing you have to validate.


Part 2: A public endpoint that isn’t an open proxy

Most of the time the shortcode renders server-side and there is no endpoint at all. But two features genuinely need the browser to fetch: refresh="60" polling, and lazy loading on scroll. Those need a REST route.

That route has to be public — feeds render for logged-out visitors, so there’s no authentication to hang it on. A public /wp-json/newsflash/v1/feed?url=… is, by default, an open proxy: anyone on the internet can make your server fetch anything, and every SSRF mitigation above is now the only thing standing between a random visitor and your internal network.

The answer is that the endpoint doesn’t accept URLs. It accepts URLs this site signed:

public static function sign( $url ) {
    return hash_hmac( 'sha256', $url, wp_salt( 'nonce' ) . '|newsflash-feed' );
}

The shortcode signs the feed URL when it renders the page; the route verifies before doing anything else:

if ( ! hash_equals( self::sign( $url ), $key ) ) {
    return new WP_Error( 'newsflash_invalid_signature', /* … */, array( 'status' => 403 ) );
}

hash_equals, not === — signature comparison is a timing-attack surface, and it costs nothing to do right.

The property this buys: a visitor can request any feed an editor already put on a page, and nothing else. The attack surface shrinks from “the entire internet” to “URLs someone with edit_posts chose”. The salt is per-site and already rotated by WordPress, so signatures don’t travel between installs.

And the route is deletable. One filter and it never registers:

add_filter( 'newsflash_enable_rest', '__return_false' );

The shortcode notices and falls back to server-side rendering rather than emitting a src that points at a 404. Features that can’t work without the route (lazy) get silently disabled rather than half-configured — including a small honesty check where a refresh interval with no endpoint to poll is reset to 0 instead of quietly spinning a timer that can never succeed.


Part 3: The JSON island, and how a feed can break out of it

In the default mode, PHP embeds the fetched items directly in the page and the component reads them instead of fetching:

<newsflash-feed layout="cards" limit="9">
  <script type="application/json" slot="data">{"title":"…","items":[]}</script>
</newsflash-feed>

One round-trip. No public endpoint. Content in the HTML for crawlers. Nice.

Also: a place where third-party content goes inside a <script> tag, which is one of the few contexts in HTML where the rules stop being intuitive. Inside <script>, the HTML parser is looking for the literal byte sequence </script — it doesn’t care that you’re in the middle of a JSON string. A feed item titled:

Why we dropped </script><img src=x onerror=alert(1)> from our stack

…closes your script block and injects markup. JSON escaping doesn’t save you, because / is a perfectly ordinary JSON character.

The flags matter:

$json = wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );

JSON_HEX_TAG encodes < and > as \u003C and \u003E. The parser never sees a closing tag, JSON.parse decodes the escapes transparently, and the title renders as the text it always was.

On the component side, feed content is never trusted as HTML at all. Titles and excerpts are run through a DOMParser and reduced to textContent, links are gated to http(s):

export function safeUrl(url) {
  if (!url) return '';
  try {
    const parsed = new URL(url, window.location.href);
    return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '';
  } catch {
    return '';
  }
}

DOMParser rather than a tag-stripping regex, for two reasons: regex HTML stripping is famously wrong on malformed input (and feeds are full of malformed input), and a parsed document is inert — no scripts execute, no images load, nothing fires. It’s a parser being used as a sandbox.


Part 4: The lifecycle bugs nobody warns you about

Three of these, all in the same 40 lines, all invisible until they aren’t.

The element upgrades before its own children exist

If the bundle loads synchronously in <head>, the custom element definition can register before the browser has finished parsing the element’s children. connectedCallback fires, we look for our <script type="application/json"> child — and it isn’t there yet. The component decides there’s no inline data and falls back to fetching, which in the inline mode means fetching from an endpoint that may not exist.

The symptom is a feed that works with defer and breaks without it, which is a great way to spend an afternoon.

_start() {
  // A synchronously-loaded bundle can upgrade this element before its own
  // children have been parsed, which would hide inline data from us.
  if (this.ownerDocument.readyState === 'loading') {
    this.ownerDocument.addEventListener('DOMContentLoaded', () => this._start(), { once: true });
    return;
  }
  if (!this.isConnected) return;
  if (this._readInlineData()) return;
  // …
}

Lit reports your constructor as a change

Lit’s updated(changed) gives you a map of what changed. On the first update, that map contains every property you set in the constructor — because from Lit’s perspective, undefined → 'grid' is a change.

Which means the obvious “re-fetch when the source changes” logic:

const sourceKeys = ['src', 'feed', 'endpoint', 'limit'];
if (sourceKeys.some((k) => changed.has(k))) this.load();

…fires on first render, every time. Every element does a redundant second fetch. Worse, in inline mode it overwrites data that was already there with a network request that shouldn’t have happened.

if (this._firstUpdate) {
  this._firstUpdate = false;
  return;
}

Four lines. Would have been very hard to notice from the outside — the feed renders correctly, it just costs you a request per element per page load.

Changing limit shouldn’t require the network

If the data came from an inline <script> and someone lowers limit from 9 to 5, there’s nothing to re-fetch from. The payload is kept around so the component can re-slice it locally instead of erroring:

} else if (!this._readInlineData() && this._payload) {
  // No endpoint to re-fetch from — an inline-data element whose `limit`
  // changed. Re-slice what we already have instead of erroring.
  this._apply(this._payload);
}

Part 5: WordPress speaks strings, web components speak booleans

Small, but it’s the kind of impedance mismatch that makes CMS integrations feel broken.

HTML boolean attributes are presence-based: <input disabled> is true, and disabled="false" is also true, because the value is ignored. That’s the spec, and every web component tutorial builds on it.

WordPress shortcodes cannot express that. shortcode_atts() returns strings. An editor writing [newsflash excerpt="false"] gets "false" — a non-empty string — and a spec-compliant boolean converter reads that as true. The excerpt they explicitly turned off stays on, and there’s no way for them to turn it off, and it looks like the plugin is ignoring them.

So the converter honours both conventions:

export const boolAttr = {
  fromAttribute(value) {
    if (value === null) return false;
    if (value === '') return true;
    return !['false', '0', 'no', 'off'].includes(value.trim().toLowerCase());
  },
  toAttribute(value) {
    return value ? '' : null;
  },
};

Bare excerpt is true. excerpt="false" is false. excerpt="0", "no", "off" are false. It’s a deliberate divergence from the platform default, and it exists because the platform default is wrong for anything a non-programmer types into a text field.


Part 6: Container queries, because the component doesn’t know where it is

Five layouts, one DOM tree. Each layout only redefines geometry, so switching layouts doesn’t blow away a theme’s colour and typography overrides.

The responsive rules are container queries, not media queries:

:host {
  container-type: inline-size;
}

@container (max-width: 900px) {
  .layout-grid .list,
  .layout-cards .list {
    grid-template-columns: repeat(min(var(--nf-columns), 2), minmax(0, 1fr));
  }
}

A component genuinely cannot know how much room it has. The same <newsflash-feed layout="grid" columns="3"> might be the full width of a landing page or crammed into a 280px sidebar, and the viewport width tells you nothing about which. Media queries would collapse the sidebar copy to three columns on a desktop and leave it unreadable.

The min(var(--nf-columns), 2) is doing something worth noting too: it’s a ceiling, not an assignment. Someone who asked for 2 columns still gets 2 at 900px, not an upgrade to 3. Responsive rules that override user intent instead of constraining it are a recurring bug class in component libraries.

Two more layout details I’m fond of:

The magazine lead spans a computed number of rows. A lead item that spans a hard-coded 3 rows leaves empty rows — and their gaps — dangling when there are only 3 items total. The component sets the span from the actual item count:

'--nf-lead-span': String(Math.max(1, this._items.length - 1)),

The ticker renders every item twice. A seamless CSS loop needs the animation to end where it started, so the list is duplicated and translated by exactly -50%. The clones are aria-hidden="true" and tabindex="-1" — a screen reader shouldn’t announce your headlines twice, and tabbing shouldn’t walk through a set of links that are only there to make an animation work. Under prefers-reduced-motion: reduce the animation stops and the ticker becomes a horizontally scrollable strip, so the content stays reachable rather than freezing mid-scroll.


Part 7: Feeds do not agree on anything

A closing note on the parsing, which is less clever but more tedious than any of the above.

Namespace prefixes are a suggestion. content:encoded, dc:date, media:thumbnail — publishers pick their own prefixes and querySelector('content\\:encoded') is a coin flip across parsers. Matching on localName sidesteps the prefix entirely:

if (child.localName === name || child.nodeName.toLowerCase() === name) { /* … */ }

Content types lie. Passthrough proxies serve XML as text/html, JSON as text/plain, and occasionally the reverse. So we sniff the body and use the header only as a hint:

const looksJson = /json/i.test(contentType || '') || /^\s*[[{]/.test(text);

Every feed library invents its own key names. link/url/guid. excerpt/contentSnippet/summary/description/content_text/content_html/content. date/published/isoDate/pubDate/date_published. The normalizer accepts the shapes emitted by the WordPress proxy, rss-parser, @extractus/feed-extractor and JSON Feed — with the fallback order chosen so a field the endpoint already trimmed wins over full content, otherwise every card carries an entire article.

Images hide in three places. An <enclosure>, a media:thumbnail, or just an <img> somewhere in the body HTML. All three are checked, in that order, and a dead image URL removes its own container so you get clean text instead of a grey rectangle:

_onImageError(event) {
  event.target.closest('.thumb')?.remove();
}

None of this is interesting. All of it is necessary, and it’s most of why “just render an RSS feed” is a week rather than an afternoon.


The Takeaway

  1. Validate the connection, not the string. Any fetcher that takes a user-influenced URL needs to pin the address it approved — CURLOPT_RESOLVE in cURL, a custom resolver or dialer elsewhere. Resolve-then-connect leaves a rebinding window that is trivially exploitable with a low TTL.

  2. Redirects are separate requests. Validating hop 1 and following hops 2–5 blindly is the same bug with extra steps.

  3. A public proxy route needs a capability, not a whitelist. An HMAC over the URL turns “fetch anything” into “fetch what an editor already published”, without needing to authenticate visitors.

  4. JSON_HEX_TAG when embedding JSON in <script>. The HTML parser is scanning for </script regardless of what the JSON says.

  5. Lit’s first updated() reports your constructor. If you react to property changes by fetching, guard the first update or every element fetches twice.

  6. Custom elements can upgrade before their children parse. Check readyState before reading light-DOM children.

  7. Container queries, not media queries, for anything reusable. A component doesn’t know the viewport and shouldn’t care.

The component is on npm as @qlibr/newsflash-feed, the WordPress plugin is in the same repo under wordpress/, and it’s all MIT. Issues and PRs welcome — particularly from anyone who wants to try to get past the pin.

github.com/Qlibr/newsflash


We build media monitoring at Qlibr, which means we read a truly unreasonable number of feeds. Newsflash started as an internal widget for a dashboard and turned into the thing you see here after the second time someone asked “can we put this on the marketing site?”