Cross-site scripting
Published
A sanitizer config is the rare object where every value you wrote is there to make the library stricter than it would be on its own. That makes the direction of a lost option the entire question. We protected a file that hands a tight allowlist to a sanitizer, renamed the option names one group at a time, and read the rendered HTML back out of a sanitizer that was never rebuilt.
What the sample actually does
The file builds a policy object with six keys and hands it to a sanitizer on every piece of user-supplied comment HTML. The policy allows six tags, allows only href and rel on an anchor, restricts URL schemes to https, refuses protocol-relative URLs, forces rel="noopener noreferrer" onto every link through a transform, and extends the list of tags whose text content is discarded to include noscript and template.
The sanitizer itself is copied into the measurement directory unprotected, which is the point of the design. An installed sanitizer is a dependency; it is not rebuilt when you protect your own bundle, so it keeps reading the property names it has always read. Its defaults are the ones documented by the library it models: a broad tag list, an anchor attribute list that includes target, four permitted schemes rather than one, and protocol-relative URLs permitted.
Protection alone was applied first, on five presets covering both output targets and the compressed profile. All five produced output identical to the unprotected run. Nothing here is a defect in the engine. It is what member renaming does when the keys in an options object are read by code that is not in your build.
Every value you wrote was tighter than the default, so every loss loosens
Renaming allowedTags alone did not empty the allowlist. It restored the library's own list, which is longer than yours. Our table-survived and img-survived lines both moved from false to true, and the dropped-elements log shrank because the sanitizer stopped removing things.
Renaming allowedAttributes restored an anchor attribute list that includes target. Our target-blank-survived line moved from false to true, so a comment could once again open a new browsing context - and because the transform is a separate option, it was still adding rel at that point. Rename both and you have target="_blank" with no rel, which is the classic reverse-tabnabbing shape.
Renaming allowedSchemes restored four schemes where you had asked for one. plain-http-survived and mailto-survived both went from false to true. None of this raised a warning, changed an exit code, or produced a single line on stderr. The sanitizer was working perfectly; it was simply enforcing its policy rather than yours.
The sharpest one is the option whose default is permissive
Five of the six keys in the policy tighten something the library was already restricting. One does not. allowProtocolRelative defaults to true, which means a URL beginning with two slashes is accepted unless you say otherwise - and saying otherwise is exactly what the pinned false was for.
Renaming that single name moved protocol-relative-survived from false to true, and the rendered HTML gained an anchor whose target began with two slashes and somebody else's host name. The scheme allowlist did not catch it, because a protocol-relative URL has no scheme to check; it inherits the page's. A link that passes an https-only policy while pointing at another origin is a genuinely awkward thing to have in user-generated content.
This is worth stating as a rule rather than a fact about one library. Losing a name that adds an exception is harmless. Losing a name that removes one is not. Walk your sanitizer config and mark each key by whether its default is more or less permissive than the value you supplied; the permissive-default keys are the ones that matter.
Two options that fail in the other direction
Renaming transformTags removed the function that adds rel="noopener noreferrer". Our noopener-added line moved from true to false and every anchor in the output lost its rel. Nothing else changed and nothing complained.
Renaming nonTextTags restored the library's shorter list, so noscript and template stopped having their contents discarded. Both noscript-text-survived and template-text-survived moved from false to true, and the text inside those elements was re-emitted into the page. The wrapper is still stripped; it is the body that comes back.
Neither of these is dramatic on its own, and that is the problem. A missing rel and some re-emitted text inside a stripped tag are exactly the kind of change that survives a visual review of a rendered comment thread.
The whole policy at once, which is what a scoped pattern actually does
A regular expression written to cover an options block matches every key in it, so we measured that too. With all six names renamed, the dropped-elements log collapsed from thirteen entries to four, and every one of our seven verdict lines flipped: protocol-relative URLs, plain http, mailto, target, tables and images all survived, rel was never added, and the text inside noscript and template was preserved.
The rendered output was still well-formed HTML. Nothing threw. The sanitizer still ran on every comment and still removed the things its own defaults remove, so a smoke test that posts <script>alert(1)</script> and checks that it disappears would have passed on every arm in this article.
That is the practical warning. The test most teams have for a sanitizer proves the sanitizer is installed, not that it is enforcing your policy.
The return shape is a contract too
Two control arms are worth reporting because they fail in the opposite way. Renaming the names on the object the sanitizer hands back - the sanitized string and the list of dropped elements - produced an immediate crash rather than a quiet degradation, because the caller reads properties that the unprotected library never wrote under those names.
The same is true of the module's exported function name. Renaming it turned the call into a type error at the first comment. Both are loud, both stop a build that runs anything at all, and both are far better outcomes than any of the silent arms above.
The distinction is the useful part. Names your code writes and your code reads are safe, because both sides move together. Names that cross into a library, or come back from one, are a contract with something that did not move.
What to do about it
Member renaming is optional and scoped by a regular expression, so the fix is a pattern that does not match your sanitizer configuration. For this area exclude allowedTags, allowedAttributes, allowedSchemes, allowProtocolRelative, transformTags, nonTextTags, and whatever equivalents your library uses - a config-driven allowlist is easier to keep correct than a hand-maintained list of names.
Then write the one test that would have caught every arm here. Do not assert that a script tag disappears; assert on the specific decisions you configured. Feed the sanitizer a protocol-relative link, an http link, a link with target, and a tag you deliberately removed, and require each to be handled the way your policy says. Run those assertions against the protected build, not the source.
If your policy is loaded from JSON or a config file rather than written as an object literal, you are already safe from this particular failure, because the keys are string data rather than member names. That is a legitimate reason to move a security policy into configuration.
Frequently asked questions
Does obfuscation change what a sanitizer removes?
Not by itself. Protection alone, on all five presets we tested including both output targets and the compressed profile, produced output identical to the unprotected file. The behaviour changed only when member renaming was pointed at the option names the sanitizer reads.
Why does losing an option make the sanitizer more permissive rather than less?
Because a renamed key is indistinguishable from a key you never supplied, and the library then applies its own default. Almost everything in a sanitizer config exists to be tighter than that default, so losing it moves in one direction.
Which sanitizer option is the most dangerous to rename?
In our measurement it was the one whose default is permissive rather than restrictive. Allowing protocol-relative URLs is the default, so pinning it off is the only thing stopping a two-slash URL, and renaming that key silently re-admitted a link pointing at another host.
Would our existing XSS test catch this?
Probably not. Every arm we measured still removed a script tag, because that removal comes from the library's own defaults rather than from your config. Test the specific decisions you configured instead: schemes, attributes, protocol-relative URLs and the tags you added to the discard list.
Is a config file safer than an object literal?
For this failure, yes. Keys loaded from JSON are string data, and member renaming does not touch string literals. That is a real argument for keeping a sanitizer policy in configuration rather than in code.
What should I exclude from member renaming?
The option names your sanitizer reads, and the property names on anything it hands back. In our sample that was allowedTags, allowedAttributes, allowedSchemes, allowProtocolRelative, transformTags and nonTextTags, plus the fields on the result object.
Does this mean obfuscation is unsafe for code that renders user content?
No. It means one optional transform needs a scoped pattern. Renaming is off unless you enable it and only touches names your expression matches, so this is a configuration decision rather than a decision about whether to protect the file.
Related reading