Safeguard
Industry Analysis

XXE Prevention in Go with decoder.DisallowDTD

Go's standard XML parser resists classic XXE by design, but cgo bindings and SAML libraries can reopen it. Here's how the DisallowDTD pattern closes the gap.

Aman Khan
AppSec Engineer
7 min read

In February 2021, researchers at RIPS Technologies disclosed CVE-2021-29447, a CVSS 8.8 XML External Entity (XXE) vulnerability in WordPress that let an attacker with media-upload rights read arbitrary files off the server. The entry point wasn't an obvious API endpoint — it was the ID3 metadata inside an uploaded .wav file, which WordPress happened to parse as XML. Five years on, XXE hasn't gone away; OWASP folded it into "Security Misconfiguration" (A05:2021) precisely because it keeps resurfacing in SOAP integrations, SAML assertions, SVG uploads, and document parsers. Go's standard library is unusually resistant to the classic version of this bug, because encoding/xml never fetches external DTDs in the first place. But "unusually resistant" is not the same as "immune," and teams that reach for cgo bindings, shell out to xmllint, or vendor XML tooling from other ecosystems can reintroduce the exact risk Go was designed to avoid. That gap is where the DisallowDTD pattern earns its keep.

What Is XXE, and Why Is It Still a Top OWASP Risk?

XXE is a vulnerability class where a crafted XML document defines a custom entity — typically pointing at a local file (SYSTEM "file:///etc/passwd") or an internal network resource — and tricks the parser into resolving it during processing, leaking data or enabling server-side request forgery. It has been a known, named attack technique since at least the early 2000s (the related "Billion Laughs" DoS pattern dates to 2003), yet it's still common enough in 2026 that OWASP didn't retire it in the 2021 Top 10 revision — it merged XXE into the broader Security Misconfiguration category (A05:2021) alongside default credentials and verbose error pages, because the root cause is the same: a parser configured with more capability than the application actually needs. The WordPress case above is a useful reminder that XXE doesn't require a dedicated "upload XML here" feature; it just requires code somewhere that hands untrusted bytes to an XML parser with entity resolution switched on.

Does Go's Standard encoding/xml Package Actually Parse DTDs and External Entities?

No — Go's standard library does not fetch external DTDs or resolve external entities, full stop, and this is true regardless of the Strict setting on xml.Decoder. If you hand xml.Unmarshal a document with <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]> and reference &xxe; in an element, Go doesn't open the file. In the default strict mode, an entity that isn't one of the five built-in XML entities (lt, gt, amp, apos, quot) or explicitly supplied via the decoder's Entity map causes a parse error rather than a silent file read. That's a meaningfully different design decision than Java's DocumentBuilderFactory, Python's lxml, or PHP's libxml2 bindings, all of which historically shipped with external entity and DTD loading on by default and required an explicit opt-out (Java's well-known http://apache.org/xml/features/disallow-doctype-decl feature flag is the direct ancestor of the pattern this article is about). Go simply never implemented the feature that makes classic XXE possible, which is why a huge fraction of "Go is safe from XXE" folklore is directionally correct — for code that stays inside the standard library.

What Is the DisallowDTD Pattern, and How Do You Implement It in Go?

The DisallowDTD pattern is a defense-in-depth check that rejects any XML document containing a <!DOCTYPE declaration before your code processes its content at all, and in Go you build it yourself because the standard library doesn't expose a single boolean flag for it. The mechanism is straightforward: xml.Decoder.Token() surfaces every DOCTYPE declaration as an xml.Directive token, so you can walk the token stream and bail out the moment one appears:

func decodeStrict(data []byte, v interface{}) error {
    dec := xml.NewDecoder(bytes.NewReader(data))
    for {
        tok, err := dec.Token()
        if err == io.EOF {
            break
        }
        if err != nil {
            return fmt.Errorf("xml token error: %w", err)
        }
        if _, ok := tok.(xml.Directive); ok {
            return errors.New("xxe: DOCTYPE/DTD declarations are not permitted")
        }
    }
    return xml.Unmarshal(data, v)
}

This costs one extra pass over the document (or a single combined pass if you fold the unmarshal logic into the same loop), and it converts "parser silently tolerates something odd" into "request rejected with a clear error." The reason this pattern is worth writing down and naming — rather than treating Go's default safety as sufficient — is that it also protects you against internal-subset entity expansion tricks (like Billion Laughs-style recursive entities defined without a SYSTEM reference) and it travels with your code if you ever swap the underlying XML engine for a cgo binding that doesn't share Go's restraint.

Where Do XXE Vulnerabilities Actually Creep Into Go Codebases?

They creep in wherever a Go service stops using encoding/xml and starts using something else, and there are three patterns worth auditing for specifically. First, cgo bindings to libxml2 — packages like lestrrat-go/libxml2 or moovweb/gokogiri exist because teams need XPath or XSLT support that the standard library doesn't offer, and libxml2's C API has historically required explicit parser-option flags (equivalent to not setting XML_PARSE_NOENT or XML_PARSE_DTDLOAD) to stay safe; a binding that doesn't set those defensively reintroduces full libxml2 behavior into a Go binary. Second, os/exec calls out to xmllint, xsltproc, or similar CLI tools for document transformation in build pipelines or ETL jobs — the safety of Go's parser is irrelevant if the actual parsing happens in a subprocess. Third, SAML and WS-Security libraries: SAML assertions are XML by specification, and XML signature-wrapping plus XXE has been a recurring finding across nearly every language's SAML stack (Ruby's ruby-saml, for instance, disclosed a related XML signature bug as CVE-2017-11428), so any Go identity provider or service provider integration that does custom XML canonicalization before signature verification deserves the same DisallowDTD scrutiny as a public upload endpoint. A fourth, easy-to-miss surface: Office Open XML files. DOCX and XLSX are ZIP archives full of XML, so a Go service that parses uploaded spreadsheets or documents — via libraries such as qax-os/excelize or unidoc/unioffice — is handling untrusted XML even though the upload form says "attach a spreadsheet," not "attach XML."

What Did the WordPress XXE Bug Teach the Industry About Blind Spots?

It taught teams that XXE hides in metadata parsers, not just obvious API bodies — the WordPress vulnerability lived in ID3 tag extraction from audio uploads, a code path nobody was likely reviewing with "XML security" in mind. The lesson generalizes directly to Go shops: the risk isn't the /api/import-xml endpoint everyone already scrutinizes during code review, it's the webhook receiver parsing a legacy partner's XML payload, the log-ingestion service unmarshaling a third-party SDK's XML-formatted telemetry, or the file-processing worker that runs unzip on an uploaded document and hands the extracted XML to whatever library was fastest to go get at the time. CVE-2021-29447 carried a CVSS score of 8.8 specifically because it enabled both local file disclosure and SSRF from a single flaw, and that dual impact is characteristic of XXE generally — it's rarely "just" an information leak. An accurate inventory of every place your Go services touch XML, including transitively through dependencies, is the only reliable way to know where a DisallowDTD-style guard is actually needed versus where the standard library's defaults already cover you.

How Safeguard Helps

Safeguard approaches XXE the way we approach the rest of the software supply chain: by making the exposure visible before it ships, not after an audit finds it. Our SCA scanning flags dependencies — including cgo-wrapped XML and libxml2 bindings — that carry known entity-expansion or DTD-loading risk, and tracks their versions in your SBOM so a future libxml2 advisory maps instantly to the services that pull it in. Our SAST rules look specifically for the Go anti-patterns that erode encoding/xml's default safety: custom Entity maps that widen what a document can reference, Strict: false configurations adopted to "just make parsing work," and XML decoding paths with no DisallowDTD-equivalent guard anywhere upstream of xml.Unmarshal. Because these checks run in CI as merge gates rather than as a quarterly pentest finding, a PR that introduces an unguarded XML parsing path — whether in a new webhook handler, a SAML integration, or a document-upload feature — gets flagged with the specific line and library responsible before it reaches production. That turns "Go is generally safe from XXE" from a comforting assumption into a continuously verified fact about your actual codebase.

Never miss an update

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