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
| Threat | How it works | Control |
|---|---|---|
| XML bomb | Nested entities expand to gigabytes | Disable DTD and entity resolution |
| External entity (XXE) | An entity that reads a local file and includes it | Disable external entity loading and network access |
| SSRF | Your server fetches a URL chosen by an attacker's feed | Allowlist schemes and hosts, block private address ranges |
| Script injection | Item HTML contains a script, event handler or javascript URL | Sanitise with an allowlist, then serve a restrictive CSP |
| Tracking pixels | Invisible images leak reader identity and behaviour | Strip images or proxy them, never load them from the reader's page |
| Huge payload | A feed that is gigabytes, or decompresses to that | Cap the download and the decompressed size |
| Slow-loris feed | A server that sends a byte a second forever | Connect and read timeouts on every request |
| Resource exhaustion | A feed with ten thousand items | Cap 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:anddata: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.
Related
Parsing and consuming feeds in code Aggregating feeds: OPML, merging and republishing
Last refreshed 2026-09-18.