Skip to content

CWE-611: Improper Restriction of XML External Entity Reference - Go

Overview

XXE vulnerabilities in Go applications occur when XML parsers process external entity references in untrusted XML input. Go's standard encoding/xml package does not provide DTD validation or arbitrary external entity resolution; it recognizes the predefined XML entities and any extra replacements supplied through Decoder.Entity. The main XXE risk in Go comes from third-party XML libraries, native parser bindings, custom entity expansion, or forwarding accepted DTD-bearing XML into another component that resolves entities.

The xml.Decoder behaviour is stronger than "does not fetch": a reference to an entity the decoder does not know is a parse error (XML syntax error: invalid character entity &xxe;), so the classic file-disclosure and SSRF payload is rejected rather than silently expanded to an empty string. Developers still need body-size limits, strict structure validation, and a review of any custom entity map or third-party parser configuration.

The Go ecosystem favors JSON over XML for APIs and configuration, which keeps XXE exposure low. Where XML is necessary (SOAP services, legacy integrations, XML configurations), the work is to understand the security characteristics of encoding/xml and to avoid third-party parsers with unsafe defaults. The standard library parser has few configuration options, so there are fewer knobs to misconfigure than in the feature-rich parsers of other languages.

Primary Defence: Use Go's standard encoding/xml package for untrusted XML, reject DOCTYPE/ENTITY declarations at the boundary when they are not required, and avoid third-party XML libraries unless they can explicitly disable external entity processing, external DTD loading, and DTD validation.

Common Vulnerable Patterns

Unsafe Assumption About DTDs in encoding/xml

// VULNERABLE - Accepts DTD-bearing XML and forwards it downstream
package main

import (
    "encoding/xml"
    "fmt"
    "io"
    "net/http"
)

type Result struct {
    Data string `xml:"data"`
}

func parseXMLHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    var result Result
    err := xml.Unmarshal(body, &result)
    if err != nil {
        http.Error(w, err.Error(), 400)
        return
    }

    // DANGEROUS: Storing or forwarding the original XML can expose
    // downstream processors that do resolve external entities.
    forwardToLegacyXmlProcessor(body)
    fmt.Fprintf(w, "Data: %s", result.Data)
}

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

Why this is vulnerable: encoding/xml does not fetch the external entity, but this handler accepts XML containing DTD/entity declarations and forwards the original document to another XML processor. Boundary rejection avoids parser-specific behavior differences and prevents dangerous XML from reaching components with less restrictive defaults.

Third-Party XML Libraries

// VULNERABLE - Using CGo-based libxml2 binding
import (
    "io"
    "net/http"

    "github.com/lestrrat-go/libxml2"
)

func parseWithLibxml2(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    // DANGEROUS: libxml2 may have unsafe defaults
    doc, err := libxml2.Parse(body)
    if err != nil {
        http.Error(w, err.Error(), 400)
        return
    }
    defer doc.Free()

    // Process document...
}

// Attack: Same XXE payload can trigger file disclosure
// if libxml2 has entity resolution enabled

Why this is vulnerable: Third-party libraries, especially those wrapping native XML parsers through CGo (libxml2, expat), may have different security features and defaults than Go's standard library. They can become vulnerable when external entity processing or DTD loading is enabled or not explicitly restricted. CGo dependencies also complicate deployment and security reviews.

DTD validation is the feature to look for specifically. encoding/xml has none, so a schema-validating dependency is by definition not the standard library, and validating a document means processing the DTD that declares the entities. A parser that resolves external entities while validating gives the classic file-read and SSRF outcomes back, on a codebase whose developers reasonably believe Go is not exposed to them.

Custom Entity Expansion or Preprocessing

// VULNERABLE - Application-level entity expansion
import (
    "os"
    "regexp"
)

var externalEntity = regexp.MustCompile(`<!ENTITY\s+(\w+)\s+SYSTEM\s+"file://([^"]+)"`)

func expandEntities(xmlData []byte) []byte {
    // DANGEROUS: Reimplements external entity expansion in application code.
    return externalEntity.ReplaceAllFunc(xmlData, func(match []byte) []byte {
        parts := externalEntity.FindSubmatch(match)
        if len(parts) != 3 {
            return match
        }
        data, _ := os.ReadFile(string(parts[2]))
        return data
    })
}

func parseUserXml(xmlData []byte) {
    expanded := expandEntities(xmlData)
    // expanded now contains attacker-selected local file contents
    _ = expanded
}

Why this is vulnerable: encoding/xml does not provide external entity resolution, but custom preprocessing can recreate the same vulnerability. Never implement SYSTEM/PUBLIC entity expansion for untrusted XML. If entity-like substitution is required for a trusted internal format, use a fixed allowlist of names and values that cannot read files or make network requests.

Unsafe Decoder Entity Map

// VULNERABLE - Entity map populated from attacker-controlled names
import (
    "encoding/xml"
    "io"
    "strings"
)

func parseWithCustomEntities(xmlData string, entities map[string]string) error {
    decoder := xml.NewDecoder(strings.NewReader(xmlData))
    decoder.Entity = entities // DANGEROUS if values are attacker-controlled

    for {
        _, err := decoder.Token()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
    }
}

Why this is vulnerable: Decoder.Entity maps non-standard entity names to replacement strings. It does not fetch external resources by itself, but filling it from user input can create injection, memory amplification, or data-confusion bugs. Keep custom entity maps static and small, or reject custom entities entirely.

Secure Patterns

Use encoding/xml for Untrusted XML

// SECURE - Standard library does not fetch external entities
package main

import (
    "encoding/json"
    "encoding/xml"
    "io"
    "log"
    "net/http"
)

type User struct {
    XMLName xml.Name `xml:"user"`
    Name    string   `xml:"name"`
    Email   string   `xml:"email"`
    Role    string   `xml:"role"`
}

func parseXMLHandler(w http.ResponseWriter, r *http.Request) {
    // Limit request body size to prevent DoS
    r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB limit

    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
        return
    }
    defer r.Body.Close()

    var user User
    // SECURE - encoding/xml does not fetch external entities
    err = xml.Unmarshal(body, &user)
    if err != nil {
        log.Printf("XML parse error: %v", err)
        http.Error(w, "Invalid XML", http.StatusBadRequest)
        return
    }

    // Validate parsed data
    if user.Name == "" || user.Email == "" {
        http.Error(w, "Missing required fields", http.StatusBadRequest)
        return
    }

    // Process user data safely. Build the response with the JSON encoder,
    // never with Fprintf - a name containing a quote would otherwise close
    // the string and inject sibling fields into the response object.
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status": "success",
        "name":   user.Name,
    })
}

Why this works: Go's encoding/xml parser does not resolve external SYSTEM/PUBLIC entities into file or network content. When DTD/entity declarations are not part of the expected input contract, reject them before parsing and use http.MaxBytesReader to limit upload size. Validating the parsed struct keeps XML that parses but does not match the contract out of the business logic.

XML Decoder with Streaming (Large Documents)

// SECURE - Streaming XML parsing for large documents
import (
    "encoding/xml"
    "io"
    "net/http"
)

type Item struct {
    ID    string `xml:"id,attr"`
    Name  string `xml:"name"`
    Value string `xml:"value"`
}

func streamXMLHandler(w http.ResponseWriter, r *http.Request) {
    // Create decoder from request body
    decoder := xml.NewDecoder(r.Body)
    defer r.Body.Close()

    // SECURE - Decoder also doesn't resolve external entities
    var items []Item

    for {
        token, err := decoder.Token()
        if err == io.EOF {
            break
        }
        if err != nil {
            http.Error(w, "Parse error", 400)
            return
        }

        // Process start elements
        if se, ok := token.(xml.StartElement); ok {
            if se.Name.Local == "item" {
                var item Item
                if err := decoder.DecodeElement(&item, &se); err != nil {
                    http.Error(w, "Decode error", 400)
                    return
                }
                items = append(items, item)
            }
        }
    }

    // Process items...
    w.WriteHeader(http.StatusOK)
}

Why this works: xml.NewDecoder() creates a streaming decoder that processes XML incrementally, reducing memory usage for large documents. The decoder inherits the same XXE protection as xml.Unmarshal() - external entities are not resolved. Streaming also provides better control over parsing, allowing rejection of documents with unexpected structure before full parsing. This pattern is ideal for SOAP services or XML feeds where documents may be large.

Input Sanitization (Defense in Depth)

// SECURE - Reject XML with DOCTYPE declarations
import (
    "bytes"
    "encoding/xml"
    "io"
    "net/http"
)

func secureXMLHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(io.LimitReader(r.Body, 1048576))
    if err != nil {
        http.Error(w, "Read error", 500)
        return
    }
    defer r.Body.Close()

    // SECURE - Reject XML with DOCTYPE (defense in depth)
    if containsDOCTYPE(body) {
        http.Error(w, "DOCTYPE declarations not allowed", 400)
        return
    }

    var data interface{}
    err = xml.Unmarshal(body, &data)
    if err != nil {
        http.Error(w, "Invalid XML", 400)
        return
    }

    w.WriteHeader(http.StatusOK)
}

func containsDOCTYPE(xmlData []byte) bool {
    upper := bytes.ToUpper(xmlData)
    return bytes.Contains(upper, []byte("<!DOCTYPE")) ||
        bytes.Contains(upper, []byte("<!ENTITY"))
}

Why this works: While encoding/xml doesn't fetch external entities, explicitly rejecting <!DOCTYPE> and <!ENTITY> declarations provides defense-in-depth and prevents dangerous XML from being stored, logged, forwarded, or later parsed by a different component. This check is an early boundary control; secure parser selection remains the primary control.

Know what this byte scan does not cover. It matches UTF-8 bytes, so a document encoded as UTF-16 contains no <!DOCTYPE sequence and passes. In Go that is harmless on its own - encoding/xml rejects the same document with invalid UTF-8 unless a CharsetReader is configured - but the check exists to protect the downstream component that reparses the body, and that component is required to accept UTF-16: the XML specification states that "all XML processors MUST accept the UTF-8 and UTF-16 encodings of Unicode", and MUST use the byte order mark to tell them apart. So the filter's blind spot is not an implementation quirk to be enumerated - it is guaranteed present in every conforming parser downstream. The filter fails precisely where its stated purpose lies. If a downstream reparse is the threat you are defending against, hand that component the re-serialized output of a successful parse rather than the original bytes, so it receives a document you have already decoded.

Prefer JSON Over XML

// SECURE - Use JSON instead of XML (best practice)
import (
    "encoding/json"
    "io"
    "net/http"
)

type UserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Role  string `json:"role"`
}

func jsonHandler(w http.ResponseWriter, r *http.Request) {
    var user UserRequest

    // SECURE - JSON has no XML entity mechanism
    decoder := json.NewDecoder(io.LimitReader(r.Body, 1048576))
    decoder.DisallowUnknownFields() // Strict parsing

    err := decoder.Decode(&user)
    if err != nil {
        http.Error(w, "Invalid JSON", 400)
        return
    }
    defer r.Body.Close()

    // Validate
    if user.Name == "" || user.Email == "" {
        http.Error(w, "Missing fields", 400)
        return
    }

    // Process...
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status": "success",
        "name":   user.Name,
    })
}

Why this works: JSON parsers have no concept of external entities, DTDs, or entity references. Using JSON instead of XML removes XXE from that input path. JSON is also simpler to parse, has broad client support, and generates smaller payloads. This is the recommended approach for new systems unless XML is required for compatibility with SOAP, SAML, or legacy systems.

Framework-Specific Guidance

Gin with XML Binding

// SECURE - Gin XML binding uses encoding/xml
package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

type Config struct {
    AppName string `xml:"appname"`
    Version string `xml:"version"`
    Debug   bool   `xml:"debug"`
}

func main() {
    r := gin.Default()

    // Limit request size globally
    r.MaxMultipartMemory = 1 << 20 // 1MB

    r.POST("/config", configHandler)

    r.Run(":8080")
}

func configHandler(c *gin.Context) {
    var config Config

    // SECURE - Gin uses encoding/xml internally
    if err := c.ShouldBindXML(&config); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid XML"})
        return
    }

    // Validate
    if config.AppName == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "appname required"})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "status": "configured",
        "app":    config.AppName,
    })
}

Why this works: Gin's XML binding uses Go's standard encoding/xml package, so it does not fetch external entities. Keep request size limits, reject DTD/entity declarations if they are not expected, and validate the parsed struct before using it. Review separately if the application swaps in a custom XML binder or forwards raw XML to another service.

Echo XML Deserialization

// SECURE - Echo with XML binding
package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

type Message struct {
    To      string `xml:"to"`
    From    string `xml:"from"`
    Content string `xml:"content"`
}

func main() {
    e := echo.New()

    // Middleware
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.BodyLimit("1M")) // Limit body size

    e.POST("/message", messageHandler)

    e.Start(":8080")
}

func messageHandler(c echo.Context) error {
    var msg Message

    // SECURE - Echo uses encoding/xml
    if err := c.Bind(&msg); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "Invalid XML",
        })
    }

    // Validate
    if msg.To == "" || msg.From == "" {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "Missing required fields",
        })
    }

    return c.JSON(http.StatusOK, map[string]interface{}{
        "status":  "received",
        "to":      msg.To,
        "from":    msg.From,
        "preview": msg.Content[:min(len(msg.Content), 50)],
    })
}

Why this works: Echo's default XML binding uses encoding/xml, so it does not fetch external entities. The middleware.BodyLimit middleware enforces request size limits globally across all routes. Continue to reject DTD/entity declarations where they are not part of the API contract and validate all parsed fields.

Common Pitfalls

  • Trusting that encoding/xml's inability to resolve external entities protects the whole request pipeline, then forwarding the original, unparsed body to logging, a message queue, or another service that reparses it with a different, less restrictive XML library.
  • Populating xml.Decoder.Entity with values derived from the incoming document rather than a small, fixed, application-defined map - Entity is meant for a static, trusted substitution table, not one built at runtime from untrusted input.
  • Enforcing a body-size limit (http.MaxBytesReader) only on the xml.Unmarshal path while a separate streaming endpoint using Decoder.Token() has no equivalent limit, leaving entity-expansion-style resource exhaustion open on any parser or configuration that does expand entities.

Additional Resources