Safeguard
Industry Analysis

XXE Prevention in Ruby with Nokogiri NONET/NOENT

Nokogiri wraps libxml2, and one misconfigured parse call can leak local files or trigger SSRF. Here's how NONET and NOENT actually work, and how to lock them down.

Aman Khan
AppSec Engineer
8 min read

A Rails app accepts an uploaded XML invoice, an SVG avatar, or a DOCX file, and hands the raw bytes to Nokogiri::XML(file.read). Nothing else changes. Six months later a bug bounty report shows up with the contents of /etc/passwd pasted into the XML response, or a hit against http://169.254.169.254/latest/meta-data/iam/security-credentials/ from inside your VPC. Nothing was "hacked" in the traditional sense — the parser just did exactly what XML lets it do: fetch and inline whatever an <!ENTITY> declaration points it at.

Nokogiri is the de facto XML/HTML library for Ruby, wrapping libxml2 and libxslt, and it ships in the dependency tree of Rails, Loofah, and dozens of gems touching XML, SVG, RSS, or SOAP. Its behavior around external entities has changed repeatedly over the gem's history, and the two flags at the center of it — NONET and NOENT — are named in a way that trips up even experienced Ruby developers. This post covers what XXE does in Nokogiri, why the flag names mislead, and how to configure parsing so untrusted XML can't read your filesystem or pivot into your internal network.

What Is XXE and Why Does Nokogiri Need Special Handling?

XXE (XML External Entity injection) happens because the XML 1.0 spec lets a document define custom "entities" — placeholders that get replaced with content the parser fetches from somewhere else, including the local filesystem or a network URL. A minimal malicious payload looks like this:

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

If the parser resolves &xxe; and substitutes it into the document tree, whatever your app does next with that node — render it, log it, echo it back in an API response — leaks the file contents. Swap file:///etc/passwd for an internal HTTP endpoint and the same mechanism becomes server-side request forgery (SSRF). Nokogiri needs special handling because it doesn't implement XML parsing itself; it's a thin Ruby binding over libxml2, and libxml2's C-level defaults around DTD loading and entity resolution have historically leaned toward "spec-compliant" rather than "safe by default" when handed attacker-controlled input.

What Do NONET and NOENT Actually Do, and Why Are They Confusingly Named?

NONET blocks network access during parsing, and NOENT — despite the name — turns entity substitution on, not off. This is the single most common source of misconfigured Nokogiri code. In libxml2's option naming, NOENT stands for "no entities left unexpanded," meaning when the flag is set, the parser walks the tree and replaces every entity reference with its resolved value. Set NOENT on untrusted input and you've just asked libxml2 to fetch and inline whatever SYSTEM or PUBLIC identifiers the document declares. Leave it off, and entity references are typically left as unexpanded nodes rather than resolved content — which is what you want for anything you didn't generate yourself.

NONET, by contrast, does what its name suggests: it forbids the parser from making network requests (HTTP, FTP) while resolving external DTDs or entities, which closes off remote-fetch SSRF even if something else in your parse options is misconfigured. It does not, on its own, stop file:// reads — that's governed by whether entities and external DTD loading are enabled at all. In Nokogiri's Ruby DSL:

doc = Nokogiri::XML(untrusted_xml) do |config|
  config.nonet   # block network fetches during parsing
  # do NOT call config.noent on untrusted input
  # do NOT call config.dtdload on untrusted input
end

The safest posture for anything that isn't a document you authored yourself is to leave NOENT and DTDLOAD off entirely and add NONET explicitly, rather than relying on whatever Nokogiri::XML::ParseOptions::DEFAULT_XML happens to resolve to in the gem version pinned in your Gemfile.lock.

Which Real Nokogiri and libxml2 CVEs Show This Risk in Practice?

CVE-2015-1819 is the reference case: libxml2 before 2.9.3 failed to prevent external parameter entity access during internal entity substitution, an XXE class bug that affected every language binding built on libxml2, Nokogiri included, and required the vendored library inside the gem to be rebuilt against the patched libxml2 before Ruby apps were protected. It's a useful example because it wasn't a Ruby-level logic bug — the vulnerable code lived in C, two layers below anything a Rails developer would ever read, which is exactly why "just don't call .noent" isn't a complete mitigation strategy on its own; you also need the underlying libxml2 build to be current.

That pattern has repeated on a roughly annual cadence. Nokogiri has shipped dozens of point releases whose changelog entry is essentially "update vendored libxml2/libxslt to pick up CVE-NNNN-NNNNN," including a February 2024 release (1.16.2) that bundled a libxml2 fix for a use-after-free issue tracked as CVE-2024-25062. None of these require a single line of application code to change — they require the gem itself to be current, which is a dependency-hygiene problem, not an application-logic one. Separately, the classic "billion laughs" entity-expansion denial-of-service — nesting entity definitions so each substitution multiplies exponentially — is blocked in modern libxml2/Nokogiri by expansion limits, but only if entity substitution (NOENT) is even reachable in the first place, which is one more reason to keep it off by default.

How Do You Write Safe Nokogiri Parsing Code Today?

You write safe Nokogiri code by never enabling NOENT or DTDLOAD on input you don't fully control, and by setting NONET explicitly rather than trusting the default. A concrete safe pattern for parsing an uploaded XML file:

def parse_untrusted_xml(raw)
  Nokogiri::XML(raw) do |config|
    config.strict.nonet
  end
end

For HTML fragments (comments, bios, rich-text fields), use Nokogiri::HTML5 or Loofah rather than Nokogiri::XML::DocumentFragment, since the HTML parser path doesn't process DTDs the way the XML path does. For SVG uploads specifically — a common blind spot because SVG is XML and image-upload code paths rarely get the same scrutiny as API endpoints — route the file through the same nonet-only configuration before any image-processing library (ImageMagick, rsvg, or Nokogiri-based sanitizers like Loofah's Loofah::Scrubber) touches it. And if your app parses SOAP or XML-RPC payloads from third parties, apply the same rule to every Nokogiri::XML.parse call site, not just the first one you audit — XXE fixes that get applied to one controller and missed in a background job or a Sidekiq worker are a routine finding in penetration test reports.

How Do You Verify Your Codebase Isn't Already Vulnerable?

You verify it by grepping for every Nokogiri parse call site and checking each one's options, not by trusting that "we fixed XXE once." A fast first pass:

grep -rn "Nokogiri::XML\|Nokogiri::HTML\|Nokogiri::XML::Document\|Nokogiri::XML::Reader" --include="*.rb" .

For each hit, confirm the call either uses a block with config.nonet and no config.noent/config.dtdload, or explicitly passes Nokogiri::XML::ParseOptions::DEFAULT_XML | Nokogiri::XML::ParseOptions::NONET without also adding NOENT. Also check Gemfile.lock for the pinned Nokogiri version and confirm it's within a handful of releases of current — bundle outdated nokogiri will tell you in one line whether you're carrying known CVEs in the vendored libxml2/libxslt build. Static analyzers like Brakeman catch some unsafe deserialization and injection patterns in Rails apps but don't reliably flag every misconfigured Nokogiri::XML block, so a manual or SAST-assisted review of parse-option usage across the codebase, plus a dependency check against the NVD/GHSA feeds for Nokogiri and libxml2, is the only combination that reliably closes both the code-level and library-level halves of this bug class.

How Safeguard Helps

Safeguard's software supply chain security platform is built to catch exactly this two-layer problem — the code-level misconfiguration and the outdated-dependency exposure — before either reaches production. On the dependency side, Safeguard continuously tracks the Nokogiri gem and its vendored libxml2/libxslt builds against the NVD and GHSA advisory feeds, so when a CVE like the 2024 libxml2 use-after-free lands, you get flagged automatically instead of finding out from bundle audit weeks later or from a customer's security questionnaire. On the code side, Safeguard's SAST scanning is tuned to recognize risky XML-parsing patterns in Ruby — Nokogiri::XML calls missing an explicit nonet configuration, or blocks that enable noent/dtdload on request-derived input — and surfaces them as findings tied to the specific file and line, not a generic "XXE possible" warning that requires a security engineer to go hunting.

Because Safeguard maps findings to your actual SBOM and build provenance, you also get visibility into every service that pulls in Nokogiri transitively through Loofah, Rails-Html-Sanitizer, or a SOAP client gem, not just the services where you know to look. For a vulnerability class like XXE, where the fix is a one-line parse-option change but the exposure is scattered across every controller, background job, and internal tool that ever touches XML or SVG, that combination of dependency tracking and code-pattern detection is what turns "we think we're covered" into an auditable, continuously verified answer.

Never miss an update

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