Loading…
Loading…
Blocking script tags is the easy part. The hard part is iframes, and the javascript: scheme sitting in an href you forgot about.

Articles on this site are stored as HTML, produced by the editor in the CMS, then handed to dangerouslySetInnerHTML at render time. That prop name is long and uncomfortable exactly as React intends.
Between those two steps sits a sanitize pass. Here is its configuration, and why each line is there.
sanitizeHtml(content, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img", "iframe"]),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ["src", "alt", "width", "height"],
iframe: ["src", "width", "height", "allowfullscreen", "frameborder", "allow"],
},
allowedIframeHostnames: ["www.youtube.com", "www.youtube-nocookie.com", "player.vimeo.com"],
allowIframeRelativeUrls: false,
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: { img: ["http", "https", "data"] },
});
The most important thing here is the repeated word allowed. The opposite approach lists what to forbid, and it loses before it starts: you have to anticipate everything dangerous, while an attacker only needs one thing you did not think of. An allowlist denies by default, and the price is that you occasionally discover a legitimate tag being swallowed.
The library's default set holds 70 tags. pre, code, table, blockquote and figure are all in it, so a technical article built from those needs no extra declaration. img and iframe are not, which is precisely why those two have to be concatenated on explicitly.
The thing worth remembering about this style of filtering: when a tag is stripped, nothing throws. The content just is not there. So the first time you use a new tag in a post, open the real page and look, rather than trusting that it saved without complaint.
Allowing iframe sounds reckless, and it is reckless if you stop at adding it to allowedTags. An unrestricted iframe can embed a fake login page, a phishing form, or anything else a reader will assume is yours because it is on your domain.
allowedIframeHostnames narrows that to the three hosts serving video embeds. Every other src is stripped.
allowIframeRelativeUrls: false closes the remaining path around it. Without that line a relative src such as /admin slips past the hostname filter, because there is no hostname on it to check.
An attribute can be on the allowlist while the value inside it is still hostile. href is a perfectly ordinary attribute. href="javascript:..." is not.
allowedSchemes narrows things to http, https and mailto. That removes javascript: and data: everywhere.
Then allowedSchemesByTag hands data: back to img alone. An inline image is legitimate and the editor produces them, while data: inside an href is a link to a hand-written HTML page running on your own origin. Same scheme, two very different risk levels, and the configuration has to be able to tell them apart.
There is a genuine choice here, and I do not think one answer fits every project.
Sanitizing on write runs once per article. It costs less, and the database only ever holds clean data. What you give up is the ability to correct yourself: the day you find a hole in the old rules, everything already stored keeps its payload, and fixing it means a migration across all your content.
Sanitizing on read spends CPU on every render, but a new rule takes effect immediately for every old article. This site takes the second option, and the cost is close to nothing because article pages are prerendered: the sanitize pass runs at build time, not while somebody is waiting.
If your content renders per request the arithmetic changes. And if the content comes from readers rather than from an admin, do both, and stop worrying about the CPU.
No comments yet — be the first!