Feed security: sanitising third-party content

Fetching and rendering a third-party feed is running untrusted code against your own systems, and the risks are concrete and well documented.

What can go wrong

ThreatHow it worksControl
XML bombNested entities expand to gigabytesDisable DTD and entity resolution
External entity (XXE)An entity that reads a local file and includes itDisable external entity loading and network access
SSRFYour server fetches a URL chosen by an attacker's feedAllowlist schemes and hosts, block private address ranges
Script injectionItem HTML contains a script, event handler or javascript URLSanitise with an allowlist, then serve a restrictive CSP
Tracking pixelsInvisible images leak reader identity and behaviourStrip images or proxy them, never load them from the reader's page
Huge payloadA feed that is gigabytes, or decompresses to thatCap the download and the decompressed size
Slow-loris feedA server that sends a byte a second foreverConnect and read timeouts on every request
Resource exhaustionA feed with ten thousand itemsCap items processed per refresh
import ipaddress, socket
from urllib.parse import urlsplit

ALLOWED_SCHEMES = {"https"}
ALLOWED_PORTS = {443}
MAX_BYTES = 2 * 1024 * 1024
MAX_DECOMPRESSED = 8 * 1024 * 1024

def assert_safe_target(url: str) -> None:
    u = urlsplit(url)
    if u.scheme not in ALLOWED_SCHEMES:
        raise ValueError("scheme not allowed")
    port = u.port or 443
    if port not in ALLOWED_PORTS:
        raise ValueError("port not allowed")

    # Resolve and reject anything that is not a public address.
    # Re-check after every redirect, because DNS can change between hops.
    for _family, _type, _proto, _canon, sockaddr in socket.getaddrinfo(u.hostname, port):
        ip = ipaddress.ip_address(sockaddr[0])
        if (ip.is_private or ip.is_loopback or ip.is_link_local
                or ip.is_reserved or ip.is_multicast):
            raise ValueError("destination is not a public address")

def fetch_bounded(url: str) -> bytes:
    assert_safe_target(url)
    chunks, total = [], 0
    with http_client(timeout=(5, 10), allow_redirects=False) as c:
        r = c.get(url, stream=True)
        r.raise_for_status()
        for chunk in r.iter_content(64 * 1024):
            total += len(chunk)
            if total > MAX_BYTES:
                raise ValueError("feed too large")
            chunks.append(chunk)
    return b"".join(chunks)
  • Follow redirects manually and re-validate the target each time; an allowlisted host can redirect to a private address.
  • Decompress with a hard output limit, because a small gzip body can expand far beyond any input cap.
  • Sanitise item HTML with an allowlist of tags and attributes, and deny javascript: and data: URLs in href and src.
  • Serve aggregated content with a restrictive Content-Security-Policy so a sanitising miss is not automatically script execution.
  • Strip tracking images, or proxy them through your own domain so the reader's IP is not exposed to the feed's author.

Rendering safely

import bleach

ALLOWED_TAGS = ["p", "br", "a", "em", "strong", "ul", "ol", "li",
                "blockquote", "code", "pre", "h2", "h3", "h4"]
ALLOWED_ATTRS = {"a": ["href", "title"], "img": ["src", "alt"]}
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]

def sanitise(fragment: str) -> str:
    return bleach.clean(
        fragment,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRS,
        protocols=ALLOWED_PROTOCOLS,
        strip=True,            # remove disallowed tags, keep their text
        strip_comments=True,
    )
Content-Security-Policy: default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
⚠️
A reader application has a wider attack surface than a server: it runs on a user's machine, often with local file access and sometimes with credentials for many feeds. Apply the same input limits there — an XML bomb that crashes a server is a denial of service, the same bomb in a desktop reader is a crash on someone's laptop.

FAQ

Is a well-formed feed safe to parse?
No. Well-formedness says nothing about entity declarations, size or content. A well-formed document with nested entities can still exhaust memory during parsing.
Should I proxy images from feeds?
If you render aggregated content to other users, yes. Proxying keeps the reader's address away from the author and lets you cache, resize and drop tracking parameters.

Parsing and consuming feeds in code Aggregating feeds: OPML, merging and republishing

Last refreshed 2026-09-18.