[{"data":1,"prerenderedAt":898},["ShallowReactive",2],{"blog-vulnerabilities/libxmljs2-dtdload":3},{"id":4,"title":5,"body":6,"category":868,"date":869,"dateModified":869,"description":870,"draft":871,"extension":872,"faq":873,"featured":871,"headerVariant":883,"image":884,"keywords":885,"meta":886,"navigation":466,"ogDescription":887,"ogTitle":884,"path":888,"readTime":889,"schemaOrg":890,"schemaType":891,"seo":892,"sitemap":893,"stem":894,"tags":895,"twitterCard":896,"__hash__":897},"blog/blog/vulnerabilities/libxmljs2-dtdload.md","libxmljs2 dtdload Default: The Option That Actually Causes XXE (2026)",{"type":7,"value":8,"toc":860},"minimark",[9,18,38,41,70,75,86,155,176,179,214,218,224,227,262,268,351,354,369,378,407,411,421,424,606,609,615,619,622,629,649,664,679,684,688,697,710,720,726,734,827,845,856],[10,11,12,13,17],"p",{},"If you have landed here you probably typed something close to ",[14,15,16],"code",{},"libxmljs2 dtdload default"," into a search box, because a security review flagged your XML parser and you need to know whether the box was already checked.",[10,19,20,21,24,25,29,30,33,34,37],{},"Short answer: ",[14,22,23],{},"dtdload"," defaults to ",[26,27,28],"strong",{},"false",". So does ",[14,31,32],{},"noent",". Every parser option in libxmljs2 is opt-in, and a bare ",[14,35,36],{},"parseXml(xml)"," call is not the vulnerable configuration.",[10,39,40],{},"The longer answer matters more, because the option most guides tell you to worry about is not the one that opens the hole.",[42,43,44],"tldr",{},[10,45,46,48,49,51,52,54,55,57,58,61,62,65,66,69],{},[14,47,23],{}," and ",[14,50,32],{}," are both off by default in libxmljs2. Turning on ",[14,53,32],{}," alone is enough to read local files through an external entity; turning on ",[14,56,23],{}," alone is not. Adding ",[14,59,60],{},"nonet: true"," does not close it, because ",[14,63,64],{},"nonet"," blocks the network and the payload uses ",[14,67,68],{},"file://",". The real risk in most projects is not the flags at all, it's that libxmljs2 stopped shipping releases in June 2025 and vendors libxml2 2.9.9.",[71,72,74],"h2",{"id":73},"where-the-default-actually-comes-from","Where the default actually comes from",[10,76,77,78,81,82,85],{},"libxmljs2 builds a libxml2 flag bitmask from the options object you hand ",[14,79,80],{},"parseXml",". The whole behaviour lives in one helper in ",[14,83,84],{},"src/xml_document.cc",":",[87,88,90],"code-block",{"label":89},"src/xml_document.cc, libxmljs2 0.37.0",[91,92,97],"pre",{"className":93,"code":94,"language":95,"meta":96,"style":96},"language-cpp shiki shiki-themes github-dark","int getParserOption(Local\u003CObject> props, const char *key, int value,\n                    bool defaultValue = true) {\n  Nan::HandleScope scope;\n  Local\u003CValue> prop =\n      Nan::Get(props, Nan::New\u003CString>(key).ToLocalChecked()).ToLocalChecked();\n  return !prop->IsUndefined() && Nan::To\u003Cbool>(prop).ToChecked() == defaultValue\n             ? value\n             : 0;\n}\n","cpp","",[14,98,99,107,113,119,125,131,137,143,149],{"__ignoreMap":96},[100,101,104],"span",{"class":102,"line":103},"line",1,[100,105,106],{},"int getParserOption(Local\u003CObject> props, const char *key, int value,\n",[100,108,110],{"class":102,"line":109},2,[100,111,112],{},"                    bool defaultValue = true) {\n",[100,114,116],{"class":102,"line":115},3,[100,117,118],{},"  Nan::HandleScope scope;\n",[100,120,122],{"class":102,"line":121},4,[100,123,124],{},"  Local\u003CValue> prop =\n",[100,126,128],{"class":102,"line":127},5,[100,129,130],{},"      Nan::Get(props, Nan::New\u003CString>(key).ToLocalChecked()).ToLocalChecked();\n",[100,132,134],{"class":102,"line":133},6,[100,135,136],{},"  return !prop->IsUndefined() && Nan::To\u003Cbool>(prop).ToChecked() == defaultValue\n",[100,138,140],{"class":102,"line":139},7,[100,141,142],{},"             ? value\n",[100,144,146],{"class":102,"line":145},8,[100,147,148],{},"             : 0;\n",[100,150,152],{"class":102,"line":151},9,[100,153,154],{},"}\n",[10,156,157,158,161,162,164,165,168,169,48,172,175],{},"Read the return expression. The flag is contributed only when the property is ",[26,159,160],{},"defined"," and its boolean value matches. Leave ",[14,163,23],{}," out and ",[14,166,167],{},"prop->IsUndefined()"," is true, so the function returns ",[14,170,171],{},"0",[14,173,174],{},"XML_PARSE_DTDLOAD"," never enters the mask.",[10,177,178],{},"That is the entire answer to \"what is the dtdload default\". There is no separate defaults object anywhere in the package, and no config file that flips it. Undefined means off.",[180,181,182],"info-box",{},[10,183,184,185,187,188,187,191,187,194,187,197,187,199,202,203,206,207,209,210,213],{},"The same helper handles ",[14,186,32],{},", ",[14,189,190],{},"dtdattr",[14,192,193],{},"dtdvalid",[14,195,196],{},"recover",[14,198,64],{},[14,200,201],{},"huge"," and the rest. Passing ",[14,204,205],{},"dtdload: false"," and omitting ",[14,208,23],{}," produce identical bitmasks, so ",[14,211,212],{},"{ noent: false, dtdload: false }"," is a comment to your future self rather than a hardening step.",[71,215,217],{"id":216},"what-we-tested-and-what-leaked","What we tested, and what leaked",[10,219,220,221,223],{},"Knowing the default is off is useful. Knowing which flag actually causes the damage is more useful, and most advice on this gets it backwards by focusing on ",[14,222,23],{},".",[10,225,226],{},"We installed libxmljs2 0.37.0, wrote a secret to a local file, and parsed the classic external entity payload once per option combination:",[87,228,230],{"label":229},"The payload",[91,231,235],{"className":232,"code":233,"language":234,"meta":96,"style":96},"language-xml shiki shiki-themes github-dark","\u003C?xml version=\"1.0\"?>\n\u003C!DOCTYPE root [\n  \u003C!ENTITY xxe SYSTEM \"file:///tmp/xxe-secret.txt\">\n]>\n\u003Croot>\u003Cdata>&xxe;\u003C/data>\u003C/root>\n","xml",[14,236,237,242,247,252,257],{"__ignoreMap":96},[100,238,239],{"class":102,"line":103},[100,240,241],{},"\u003C?xml version=\"1.0\"?>\n",[100,243,244],{"class":102,"line":109},[100,245,246],{},"\u003C!DOCTYPE root [\n",[100,248,249],{"class":102,"line":115},[100,250,251],{},"  \u003C!ENTITY xxe SYSTEM \"file:///tmp/xxe-secret.txt\">\n",[100,253,254],{"class":102,"line":121},[100,255,256],{},"]>\n",[100,258,259],{"class":102,"line":127},[100,260,261],{},"\u003Croot>\u003Cdata>&xxe;\u003C/data>\u003C/root>\n",[10,263,264,265,85],{},"Results against libxmljs2 0.37.0 on Node 22, reading ",[14,266,267],{},"//data",[269,270,271,286],"table",{},[272,273,274],"thead",{},[275,276,277,283],"tr",{},[278,279,280,281],"th",{},"Options passed to ",[14,282,80],{},[278,284,285],{},"Result",[287,288,289,300,308,317,329,340],"tbody",{},[275,290,291,297],{},[292,293,294],"td",{},[14,295,296],{},"{}",[292,298,299],{},"empty string",[275,301,302,306],{},[292,303,304],{},[14,305,212],{},[292,307,299],{},[275,309,310,315],{},[292,311,312],{},[14,313,314],{},"{ dtdload: true }",[292,316,299],{},[275,318,319,324],{},[292,320,321],{},[14,322,323],{},"{ noent: true }",[292,325,326],{},[26,327,328],{},"file contents returned",[275,330,331,336],{},[292,332,333],{},[14,334,335],{},"{ noent: true, dtdload: true }",[292,337,338],{},[26,339,328],{},[275,341,342,347],{},[292,343,344],{},[14,345,346],{},"{ noent: true, dtdload: true, nonet: true }",[292,348,349],{},[26,350,328],{},[10,352,353],{},"Three things fall out of that table.",[10,355,356,359,360,362,363,365,366,368],{},[14,357,358],{},"noent: true"," on its own is the vulnerability. You do not need ",[14,361,23],{},". If your codebase sets ",[14,364,32],{}," anywhere near untrusted XML, that line is the finding, and no amount of ",[14,367,205],{}," next to it helps.",[10,370,371,374,375,377],{},[14,372,373],{},"dtdload: true"," on its own did not leak. It loads the external subset; it doesn't substitute the entity into the text node. Useful to know when you're triaging a scanner alert that flagged ",[14,376,23],{}," and nothing else.",[379,380,381],"danger-box",{},[10,382,383,388,389,392,393,396,397,400,401,403,404,406],{},[26,384,385,387],{},[14,386,60],{}," does not save you."," It maps to ",[14,390,391],{},"XML_PARSE_NONET",", which forbids network access. Our payload never touched the network. A ",[14,394,395],{},"file:///etc/passwd"," or ",[14,398,399],{},"file:///proc/self/environ"," entity is a local read, and ",[14,402,64],{}," has no opinion about it. We have seen this exact combination pass an internal review because someone grepped for ",[14,405,64],{}," and found it present.",[71,408,410],{"id":409},"why-people-set-noent-in-the-first-place","Why people set noent in the first place",[10,412,413,414,417,418,420],{},"Nobody enables it to be reckless. They enable it because their XML has legitimate internal entities and the parsed output is full of unresolved ",[14,415,416],{},"&thing;"," references, and ",[14,419,358],{}," is the first Stack Overflow answer that makes the symptom go away.",[10,422,423],{},"If that is your situation, the fix is not a flag. It's rejecting the DOCTYPE before parsing:",[87,425,427],{"label":426},"Reject DTDs on untrusted input",[91,428,432],{"className":429,"code":430,"language":431,"meta":96,"style":96},"language-js shiki shiki-themes github-dark","const libxmljs = require('libxmljs2');\n\nfunction parseUntrusted(xml) {\n  // Cheap pre-filter. An external entity needs a DOCTYPE to declare it.\n  if (/\u003C!DOCTYPE/i.test(xml)) {\n    throw new Error('DTD not allowed');\n  }\n  return libxmljs.parseXml(xml, {\n    noent: false,\n    nonet: true,\n    dtdload: false,\n    dtdvalid: false,\n  });\n}\n","js",[14,433,434,462,468,484,490,518,536,541,554,564,575,585,595,601],{"__ignoreMap":96},[100,435,436,440,444,447,451,455,459],{"class":102,"line":103},[100,437,439],{"class":438},"snl16","const",[100,441,443],{"class":442},"sDLfK"," libxmljs",[100,445,446],{"class":438}," =",[100,448,450],{"class":449},"svObZ"," require",[100,452,454],{"class":453},"s95oV","(",[100,456,458],{"class":457},"sU2Wk","'libxmljs2'",[100,460,461],{"class":453},");\n",[100,463,464],{"class":102,"line":109},[100,465,467],{"emptyLinePlaceholder":466},true,"\n",[100,469,470,473,476,478,481],{"class":102,"line":115},[100,471,472],{"class":438},"function",[100,474,475],{"class":449}," parseUntrusted",[100,477,454],{"class":453},[100,479,234],{"class":480},"s9osk",[100,482,483],{"class":453},") {\n",[100,485,486],{"class":102,"line":121},[100,487,489],{"class":488},"sAwPA","  // Cheap pre-filter. An external entity needs a DOCTYPE to declare it.\n",[100,491,492,495,498,501,505,507,510,512,515],{"class":102,"line":127},[100,493,494],{"class":438},"  if",[100,496,497],{"class":453}," (",[100,499,500],{"class":457},"/",[100,502,504],{"class":503},"sns5M","\u003C!DOCTYPE",[100,506,500],{"class":457},[100,508,509],{"class":438},"i",[100,511,223],{"class":453},[100,513,514],{"class":449},"test",[100,516,517],{"class":453},"(xml)) {\n",[100,519,520,523,526,529,531,534],{"class":102,"line":133},[100,521,522],{"class":438},"    throw",[100,524,525],{"class":438}," new",[100,527,528],{"class":449}," Error",[100,530,454],{"class":453},[100,532,533],{"class":457},"'DTD not allowed'",[100,535,461],{"class":453},[100,537,538],{"class":102,"line":139},[100,539,540],{"class":453},"  }\n",[100,542,543,546,549,551],{"class":102,"line":145},[100,544,545],{"class":438},"  return",[100,547,548],{"class":453}," libxmljs.",[100,550,80],{"class":449},[100,552,553],{"class":453},"(xml, {\n",[100,555,556,559,561],{"class":102,"line":151},[100,557,558],{"class":453},"    noent: ",[100,560,28],{"class":442},[100,562,563],{"class":453},",\n",[100,565,567,570,573],{"class":102,"line":566},10,[100,568,569],{"class":453},"    nonet: ",[100,571,572],{"class":442},"true",[100,574,563],{"class":453},[100,576,578,581,583],{"class":102,"line":577},11,[100,579,580],{"class":453},"    dtdload: ",[100,582,28],{"class":442},[100,584,563],{"class":453},[100,586,588,591,593],{"class":102,"line":587},12,[100,589,590],{"class":453},"    dtdvalid: ",[100,592,28],{"class":442},[100,594,563],{"class":453},[100,596,598],{"class":102,"line":597},13,[100,599,600],{"class":453},"  });\n",[100,602,604],{"class":102,"line":603},14,[100,605,154],{"class":453},[10,607,608],{},"The regex is a guard, not the security boundary. The parser defaults are doing the real work; the check just gives you a clear error instead of a silently empty node, which is what your support inbox actually needs.",[610,611,612],"warning-box",{},[10,613,614],{},"If your XML genuinely requires entity expansion, resolve entities in your own code after parsing, against an allowlist you control. Never hand that decision to the parser on input you did not write.",[71,616,618],{"id":617},"the-bigger-problem-is-the-package-not-the-flag","The bigger problem is the package, not the flag",[10,620,621],{},"While confirming the defaults we pulled the published tarball, and the parser options turned out to be the less interesting finding.",[10,623,624,625,628],{},"The repository README opens with \"NO LONGER MAINTAINED.\" The last release on npm, 0.37.0, went out on ",[26,626,627],{},"1 June 2025",". And the package vendors its own copy of libxml2 rather than linking the system one:",[87,630,632],{"label":631},"vendor/libxml/include/libxml/xmlversion.h",[91,633,637],{"className":634,"code":635,"language":636,"meta":96,"style":96},"language-c shiki shiki-themes github-dark","#define LIBXML_DOTTED_VERSION \"2.9.9\"\n#define LIBXML_VERSION 20909\n","c",[14,638,639,644],{"__ignoreMap":96},[100,640,641],{"class":102,"line":103},[100,642,643],{},"#define LIBXML_DOTTED_VERSION \"2.9.9\"\n",[100,645,646],{"class":102,"line":109},[100,647,648],{},"#define LIBXML_VERSION 20909\n",[10,650,651,652,655,656,659,660,663],{},"libxml2 2.9.9 is a 2019 release. It predates the fix for CVE-2021-3518, a use-after-free in ",[14,653,654],{},"xmlXIncludeDoProcess()"," reachable when processing crafted files, which landed in 2.9.11. And ",[14,657,658],{},"binding.gyp"," in the same tarball compiles with ",[14,661,662],{},"LIBXML_XINCLUDE_ENABLED",", so XInclude is present in the build rather than compiled out.",[665,666,668],"finding-box",{"title":667},"What this means for your dependency tree",[10,669,670,671,674,675,678],{},"Your ",[14,672,673],{},"package.json"," may not mention libxmljs2 at all. It is a transitive dependency of several XML and SAML packages, and because it vendors libxml2, upgrading your operating system's libxml2 changes nothing. Run ",[14,676,677],{},"npm ls libxmljs2"," to see whether it's in there and what pulled it in.",[10,680,681,682,223],{},"Nothing here is a live exploit against your app. It's a maintenance status: an archived binding, wrapping a six-year-old C library, that no longer gets patches when the C library does. That's the thing worth putting on a roadmap, not another ",[14,683,205],{},[71,685,687],{"id":686},"the-check-to-run-today","The check to run today",[689,690,692],"step",{"number":691},"1",[10,693,694,696],{},[14,695,677],{}," in your project root. If nothing comes back, you're done.",[689,698,700],{"number":699},"2",[10,701,702,703,706,707,709],{},"If it's present, ",[14,704,705],{},"grep -rn \"noent\" --include=\"*.js\" --include=\"*.ts\" src/"," and read every hit. ",[14,708,358],{}," next to user-supplied XML is the finding.",[689,711,713],{"number":712},"3",[10,714,715,716,719],{},"Check the same for ",[14,717,718],{},".xml"," handling in serverless functions and webhook receivers, which is where XML parsing usually hides in a vibe-coded app. SAML callbacks and payment provider webhooks are the two common sources.",[689,721,723],{"number":722},"4",[10,724,725],{},"Decide whether you still need XML at all. Many projects carry this dependency for one legacy endpoint that could take JSON instead.",[10,727,728,729,223],{},"For the mechanism behind all of this, and the equivalent settings in Python, Java and PHP parsers, read our ",[730,731,733],"a",{"href":732},"/blog/vulnerabilities/xxe","XXE explainer",[735,736,737,763,778,799,821],"faq-section",{},[738,739,741],"faq-item",{"question":740},"What is the default value of dtdload in libxmljs2?",[10,742,743,744,746,747,749,750,752,753,755,756,187,758,187,760,762],{},"False. In ",[14,745,84],{}," the ",[14,748,174],{}," flag is only added when the ",[14,751,23],{}," property is defined and strictly true, so calling ",[14,754,80],{}," with no options leaves it off. The same helper governs ",[14,757,32],{},[14,759,193],{},[14,761,190],{}," and the rest.",[738,764,766],{"question":765},"Is noent false by default in libxmljs2?",[10,767,768,769,771,772,775,776,223],{},"Yes. Every parser option in libxmljs2 is opt-in. An undefined property contributes zero to the flag bitmask, so ",[14,770,36],{}," runs with no ",[14,773,774],{},"XML_PARSE_NOENT"," and no ",[14,777,174],{},[738,779,781],{"question":780},"Does dtdload true on its own create an XXE vulnerability?",[10,782,783,784,786,787,789,790,792,793,795,796,798],{},"Not in our test. Against libxmljs2 0.37.0, parsing a document with a ",[14,785,68],{}," external entity under ",[14,788,373],{}," returned an empty node. The same document under ",[14,791,358],{}," returned the file contents. ",[14,794,23],{}," loads the external subset; ",[14,797,32],{}," is what substitutes the entity into the text.",[738,800,802],{"question":801},"Does nonet true protect against XXE in libxmljs2?",[10,803,804,805,807,808,187,810,48,812,814,815,817,818,820],{},"Not against local file disclosure. We parsed a ",[14,806,68],{}," entity with ",[14,809,358],{},[14,811,373],{},[14,813,60],{},", and it still returned the file contents. ",[14,816,64],{}," forbids network access, which does nothing about a ",[14,819,68],{}," URI.",[738,822,824],{"question":823},"Is libxmljs2 still maintained?",[10,825,826],{},"No. The repository README states it is no longer maintained, and the last npm release, 0.37.0, was published on 1 June 2025. It vendors libxml2 2.9.9, a 2019 release that predates the fix for CVE-2021-3518.",[828,829,830,835,840],"related-articles",{},[831,832],"related-card",{"description":833,"href":732,"title":834},"How external entity attacks work, and the parser settings that stop them across languages","XXE Explained",[831,836],{"description":837,"href":838,"title":839},"The network half of XXE, and why an entity that reaches your metadata endpoint is worse than one that reads a file","/blog/vulnerabilities/ssrf","SSRF Explained",[831,841],{"description":842,"href":843,"title":844},"Finding the transitive packages you never chose and no longer get patches","/blog/how-to/check-npm-supply-chain-attack","Check Your npm Dependencies",[846,847,849,853],"cta-box",{"href":500,"label":848},"Start Free Scan",[71,850,852],{"id":851},"not-sure-what-your-app-parses","Not sure what your app parses?",[10,854,855],{},"Scan your deployed site for XML handling, exposed config, and dependencies nobody maintains.",[857,858,859],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}",{"title":96,"searchDepth":109,"depth":109,"links":861},[862,863,864,865,866,867],{"id":73,"depth":109,"text":74},{"id":216,"depth":109,"text":217},{"id":409,"depth":109,"text":410},{"id":617,"depth":109,"text":618},{"id":686,"depth":109,"text":687},{"id":851,"depth":109,"text":852},"vulnerabilities","2026-09-02","libxmljs2 leaves dtdload and noent off by default. Tested against 0.37.0, noent alone leaks local files, dtdload alone does not, and nonet does not help.",false,"md",[874,876,878,880,882],{"question":740,"answer":875},"False. In src/xml_document.cc the XML_PARSE_DTDLOAD flag is only added when the dtdload property is defined and strictly true, so calling parseXml with no options leaves it off. The same helper governs noent, dtdvalid, dtdattr and the rest.",{"question":765,"answer":877},"Yes. Every parser option in libxmljs2 is opt-in. An undefined property contributes zero to the flag bitmask, so parseXml(xml) runs with no XML_PARSE_NOENT and no XML_PARSE_DTDLOAD.",{"question":780,"answer":879},"Not in our test. Against libxmljs2 0.37.0, parsing a document with a file:// external entity under dtdload: true returned an empty node. The same document under noent: true returned the file contents. dtdload loads the external subset; noent is what substitutes the entity into the text.",{"question":801,"answer":881},"Not against local file disclosure. We parsed a file:// entity with noent: true, dtdload: true and nonet: true, and it still returned the file contents. nonet forbids network access, which does nothing about a file:// URI.",{"question":823,"answer":826},"red",null,"libxmljs2 dtdload default, libxmljs2 dtdload default false, libxmljs2 noent default false, libxmljs2 parsexml default noent, libxmljs2 xxe, libxmljs parseXml options",{},"The dtdload default in libxmljs2 is false. So is noent. Turning on noent by itself is what reads /etc/passwd, and nonet does not stop it.","/blog/vulnerabilities/libxmljs2-dtdload","8 min read","[object Object]","TechArticle",{"title":5,"description":870},{"loc":888},"blog/vulnerabilities/libxmljs2-dtdload",[],"summary_large_image","G35tQGxnJYGmzGPI4uaikbWpTLLbRW_mLcjZHVA3Vro",1789672868583]