. "$BST_LIB" # BST-TIMEOUT: 1200 # ONE python3 start, and that is the whole cost. Every # # assertion below either reads the filesystem/dpkg with # # shell builtins or greps the single probe run's output; the # # probe itself parses eight short documents and serialises # # two, which is nothing beside the interpreter load. It # # matches 99-python3-xmltodict's 1200 for the same reason -- # # the start dominates and the work after it is comparable. # # NOT MEASURED ON TARGET YET: this port's first on-target run # # is the batch verify, and the measured elapsed replaces this # # note in the test plan rather than in this comment, which # # would cost an image build to keep the shipped suite # # byte-identical. # # python3-defusedxml 0.7.1 (meta-openembedded # meta-python/recipes-devtools/python/python3-defusedxml_0.7.1.bb -- `inherit # pypi setuptools3', PyPI sdist, no patches) on the wasm32-hwjs glibc image -- # HARDENED DROP-IN REPLACEMENTS FOR THE STDLIB XML PARSERS. Same API; entity # expansion and external entity resolution RAISE instead of running, which is # what stops an XML bomb from taking the process down. # # ============================================================================ # WHY THIS PORTS AT ALL. # ============================================================================ # # This image's python3 is ONE STATIC BINARY with an EMPTY lib-dynload and no # dynamic loader (BRINTOS-1476; 99-python3 asserts both directly). A module # carrying a compiled extension could not be imported here -- not "would be # slow", could not be imported. defusedxml ships ELEVEN .py FILES AND NO # EXTENSION, which is the entire reason it ports. # # It does sit on a C extension: `defusedxml.common' imports `xml.parsers.expat' # at module scope and raises ImportError("pyexpat") if it lacks ParserCreate. # That works here only because pyexpat is BUILTIN in this interpreter rather than # a lib-dynload .so (99-python3/builtin-modules names it in the set it asserts). # `pyexpat-builtin' restates it from this suite's own side, so an interpreter # rebuilt without expat reds HERE with the cause named instead of presenting as a # broken defusedxml. # # `pkg-only-python' is the regression tripwire for the first half: it asserts # every regular file the package ships is Python or its metadata, so a release # that grew a compiled speedup reds at INSTALL time rather than at `import' on # somebody else's ticket three rounds later. It subsumes a bare .so case. # # ============================================================================ # THE PORTING PREREQUISITE THAT IS NOT OBVIOUS, AND THE ONE CASE THAT MATTERS. # ============================================================================ # # defusedxml.ElementTree does NOT subclass the XMLParser you get from `import # xml.etree.ElementTree'. `_get_py3_cls()' masks `_elementtree' in sys.modules # and RE-IMPORTS the module to obtain the PURE-PYTHON XMLParser, because only # that one exposes the expat handler slots (EntityDeclHandler, # ExternalEntityRefHandler, StartDoctypeDeclHandler) the guards are installed # into. Two things this image has to satisfy: # # - `xml/etree/ElementTree.py' must be reachable AS SOURCE. An image shipping # only frozen or byte-compiled stdlib, or a python3-xml trimmed to the C # accelerator, breaks defusedxml at IMPORT with an error naming neither # defusedxml nor the real cause. `stdlib-et-source' and `stdlib-et-file' # assert it. # # - `parser-not-c-xmlparser' is THE case on this file. If the base class were # the C XMLParser, the guards would be installed on a class that IGNORES # them: defusedxml would import, expose every name in `__all__', and REFUSE # NOTHING. Every other case here could pass while the package did exactly # nothing. That is the one failure shape a surface test cannot see, and it is # a live risk on this arm precisely because the interpreter is built # unusually. # # ============================================================================ # WHAT THE ATTACK FIXTURES ACTUALLY RAISE -- MEASURED, NOT ASSUMED. # ============================================================================ # # With defusedxml's DEFAULTS (`forbid_dtd=False, forbid_entities=True, # forbid_external=True') ALL THREE attack fixtures raise **EntitiesForbidden**, # including the external-entity one. The entity DECLARATION handler fires first, # so the reference is never reached and ExternalReferenceForbidden never runs. # defusedxml stops the XXE one step EARLIER than its own exception names suggest. # # This is worth stating plainly because the obvious expectation -- that the XXE # document raises ExternalReferenceForbidden on the default path -- is WRONG, and # a suite asserting it would red against a CORRECT package. `def-xxe-exc' asserts # what was measured. # # ExternalReferenceForbidden IS reached, on its own path: `extref-exc' drives # `DefusedXMLParser(forbid_entities=False)', which lets the declaration through # so the REFERENCE is what gets stopped. That is a real, documented API path and # the only way to state that guard as behaviour. `dtd-exc' does the same for # DTDForbidden, which is off by default because a DTD is not itself an attack. # # ============================================================================ # AND WHAT THE STDLIB CONTROL DOES -- ALSO MEASURED, AND ALSO NOT THE OBVIOUS # ANSWER. # ============================================================================ # # Each fixture is parsed a second time through the stdlib # `xml.etree.ElementTree'. Without that leg, a package that installed as a silent # no-op -- or that never installed, with `defusedxml' resolving to something else # on sys.path -- would pass every case above by refusing nothing that was ever # going to happen. # # For the two bombs the contrast is the expected one: the stdlib EXPANDS them # (`std-billion-textlen', `std-quad-textlen') where defusedxml refused. # # For the XXE fixture IT IS NOT. The stdlib does NOT read the file either -- # CPython's expat binding resolves no external entity by default, so it raises # `ParseError: undefined entity'. The difference defusedxml makes here is WHICH # guard stops it and WHEN: a typed, catchable DefusedXmlException naming the # entity and its system id, at declaration time, versus an untyped # well-formedness error at reference time. `std-xxe-exc' and # `std-xxe-names-secret' assert exactly that and nothing stronger. Asserting that # the stdlib RESOLVES the entity would red against a correct stdlib, which is a # false red about the platform rather than a finding about this package. # # ============================================================================ # THE BOMBS ARE BOUNDED, AND THAT IS A REQUIREMENT, NOT A COMPROMISE. # ============================================================================ # # Because the control leg really does expand these documents ON THE GUEST, a # true billion-laughs would take the machine down -- wedging not just this suite # but the shared batch image's whole verify, for every member. The fixtures are # the billion-laughs and quadratic-blowup SHAPES with their expansion factors cut # to nothing: nested entity indirection three levels deep expanding to 27 bytes, # and one 64-byte entity referenced 16 times expanding to 1024. What is asserted # is the MECHANISM -- nested expansion, repeated reference -- and defusedxml stops # both at the entity declaration, before expansion factor is a thing that exists. # A bomb sized to hurt would be testing the guest's memory limits, not this # library. # # ============================================================================ # THE SECURITY PROPERTY IS ASSERTED AGAINST THE BYTES. # ============================================================================ # # `secret-not-leaked' is independent of which exception came back: the probe # writes a marker file into the scratch tree, and greps for that marker in every # parse result and every exception string either parser produced. So "the file # was left unread" holds however the two parsers are wired, and it is the case # that would catch an expat actually configured to resolve external entities. # `secret-intact' pairs with it so that a `no' means "never read" rather than # "never written". # # ============================================================================ # THE lxml FACADE IS SHIPPED AND NOT IMPORTABLE, ON PURPOSE. # ============================================================================ # # defusedxml ships `lxml.py', which does `from lxml import etree' at module # scope. lxml is NOT ported to this image, so that one facade raises ImportError # while the other nine import fine. `lxml-facade-importerror' asserts the gap # rather than skipping it: it documents that `defusedxml.lxml' is unusable here, # and it will red the day lxml IS ported -- which is the right moment for # somebody to notice that this facade has just come alive. Upstream deprecated it # in 0.7.0 and it has no decompression-bomb protection anyway. # # ============================================================================ # EVERY VALUE BELOW WAS MEASURED, NOT PREDICTED. # ============================================================================ # # On x86-64 (CPython 3.10.12) TWICE, and the two transcripts agreed on all 50 # asserted keys: # # - against the 0.7.1 sdist the recipe's SRC_URI sha256 pins # (1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69, verified # against the recipe), unpacked and put on PYTHONPATH. Not a pip copy and not # upstream git. The recipe carries NO patches, so those bytes are the port. # - against the bytes THIS BUILD produced: the site-packages tree extracted # from python3-defusedxml_0.7.1-r0_wasm32.deb. # # Both runs were made with lxml masked out of sys.path, because the control host # HAS lxml and the target does not; that is the one condition simulated rather # than found, and it is simulated exactly (the module is absent). # # Recorded 2026-08-31; the ticket's design doc carries the transcript. # # What makes the control transferable is that all of it is pure-Python and # content-determined: which exception a guard raises, what the stdlib expands a # fixture to, and the serialisation of a parsed document depend on defusedxml's # code, on the stdlib's, and on the fixtures -- not on pointer width, byte order, # the platform string, the clock or hash randomisation. Nothing in the probe # sorts by hash, formats a float, or reads the environment. # # THE VALUES DELIBERATELY NOT ASSERTED are the ones that are facts about the HOST # rather than about this package: `expat_version' (owned by # 99-python3/expat-version), `package_file' and `stdlib_et_file' as literals, and # every raw exception STRING -- each interpolates the scratch path, and the # stdlib ParseError even carries a column number that moves with the length of # $TMPDIR. The types are asserted; the strings are emitted for a reader of a red. # # ============================================================================ # WHAT IS NOT COVERED, ON PURPOSE. # ============================================================================ # # - defusedxml's OWN test suite (tests.py). The recipe does not inherit ptest # and does not install it, and running it would need pytest. The port closes # on the API this file exercises. # - The minidom / pulldom / sax / expatreader / expatbuilder / xmlrpc facades # BEYOND importing them. Each wraps a different stdlib parser with the same # three guards; asserting all six would make this file a restatement of the # stdlib's parser zoo rather than of defusedxml's contribution. The # ElementTree facade the ticket names is driven end to end, and # `submodules-ok' says the rest are at least importable on this interpreter. # - `defuse_stdlib()', the EXPERIMENTAL monkey-patch entry point. Upstream # labels it experimental, it mutates global stdlib state for the rest of the # process, and nothing on this image calls it. # - DECOMPRESSION bombs. defusedxml does not defend against those and says so; # asserting anything here would be asserting a promise the package does not # make. # # THIS PORT CLOSES ON THIS SUITE: non-interactive, no manual browser pass, no # follow-on ticket under BRINTOS-780. P=python3-defusedxml # THE CANARY, which asserts nothing about python3-defusedxml. # # A suite's FIRST BST result line is lost between the guest's printf and the # host's read -- per-suite, deterministic, and NOT a defect in the host tool # (BRINTOS-1149 established that and filed it as linux-in-the-browser#177). The # harness computes its pass count as total-minus-failures, so THE DROPPED LINE # IS COUNTED AS A PASS. A nothing-case therefore stands in that position and the # eaten line costs nothing. It sits ahead of the `require' guards deliberately, # because a `require' that skips would emit its skip as the first line and be # eaten too. # # Remove this case when linux-in-the-browser#177 is fixed. bst_ok $P/suite-canary require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages PKGDIR=$SITE/defusedxml DI=$SITE/defusedxml-0.7.1.dist-info STDLIB_ET=/usr/lib/python3.12/xml/etree/ElementTree.py PKGLIST=/var/lib/dpkg/info/python3-defusedxml.list PROBE=$BST_DATA/python3-defusedxml-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-defusedxml.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # ------------------------------------------------------------- the payload --- # Where the package has to land. `import defusedxml' further down is the # functional statement; these are the ones that say WHICH copy answered, so a # package that imported out of some other path is distinguishable from one that # imported out of the package this image installed. check $P/site-packages-dir test -d "$SITE" check $P/package-dir test -d "$PKGDIR" check $P/package-init test -f "$PKGDIR/__init__.py" # IT IS A PACKAGE, NOT A MODULE -- a directory of eleven .py files. setup.py # declares `packages=["defusedxml"]'. Asserted by COUNT rather than by listing # every name: the eleven are enumerated in `submodules-ok' from the interpreter's # side, and a count here catches a release that added or dropped a facade without # turning this into a second list to keep in step with that one. check_eq $P/module-count "11" \ "$(ls "$PKGDIR"/*.py 2>/dev/null | wc -l | tr -d ' ')" # The four the ticket's path actually needs, named individually so a red says # which file did not ship rather than only that the count moved. check $P/module-common test -f "$PKGDIR/common.py" check $P/module-elementtree test -f "$PKGDIR/ElementTree.py" check $P/module-celementtree test -f "$PKGDIR/cElementTree.py" check $P/module-lxml test -f "$PKGDIR/lxml.py" # The list is copied to tmpfs first and never redirected from the rootfs: a # top-level stdin redirect on the virtiofs root makes dup2(2) return 513 and ash # EXITS, ending the suite silently (brintos/linux#73). cat "$PKGLIST" > "$W/pkgf" 2>/dev/null || : > "$W/pkgf" check $P/pkg-list-present test -s "$W/pkgf" # EVERY REGULAR FILE THE PACKAGE SHIPS IS PYTHON OR ITS METADATA. See the header: # on this image a .so could not be loaded at all, so this is the case that fails # a version bump which grew one -- loudly, here, instead of at `import' on a # later ticket. It also catches the shape nobody predicted -- a .pyd, a stray # README -- without having to enumerate what it might be. # # A dpkg .list names DIRECTORIES too, and without a trailing slash, so a # name-only filter would report every directory in the package as an unexpected # shape; each entry is therefore tested for being a regular file first. _bad="" while IFS= read -r _f; do [ -f "$_f" ] || continue case "$_f" in *.py|*.pyc) continue ;; */__pycache__/*) continue ;; */*.dist-info/*) continue ;; esac _bad="$_bad $_f" done < "$W/pkgf" check_eq $P/pkg-only-python "" "$_bad" # ----------------------------------------------------------- the dist-info --- # The installed metadata, which is the second, independent statement of the # version -- `$P/dist-version' below reads it through importlib.metadata, and the # directory name carries it a third time in $DI itself. A package whose modules # landed and whose metadata did not would import fine and serve every behavioural # case while reporting no installed version at all. check $P/dist-info-dir test -d "$DI" check $P/dist-info-metadata test -f "$DI/METADATA" check_out $P/dist-info-version "Version: 0.7.1" cat "$DI/METADATA" # top_level.txt names the import the package claims to provide. It is the one # metadata file that would disagree if the package were renamed upstream. check_out $P/dist-info-toplevel "defusedxml" cat "$DI/top_level.txt" # ------------------------------------------------- the stdlib prerequisite --- # See the header: defusedxml RE-IMPORTS xml.etree.ElementTree with the C # accelerator masked, so the pure-Python SOURCE has to be reachable. This is a # fact about python3-xml rather than about this package, and it is asserted here # because this package is the one that breaks when it stops being true. check $P/stdlib-et-source test -f "$STDLIB_ET" # --------------------------------------------------------------- the package -- # dpkg KNOWS the package. Every case above would look identical if some other # package had installed the same files, so this is the one that says # python3-defusedxml is a real package this image installed rather than an # IMAGE_INSTALL line that resolved to nothing. # # The stanza is read from a tmpfs copy for the same reason as the file list, and # the anchor is exact -- an unanchored match would also take the -dbg or -dev # stanza of the same family. sed -n '/^Package: python3-defusedxml$/,/^$/p' /var/lib/dpkg/status > "$W/st" 2>/dev/null check $P/pkg-known test -s "$W/st" check_out $P/pkg-installed "Status: install ok installed" cat "$W/st" # THE RUNTIME DEPENDENCIES, as dpkg recorded them. The recipe declares # `RDEPENDS:${PN} += "python3-xml"' and setuptools3 adds python3-core; the built # .deb's control file carries exactly `python3-core, python3-xml'. Both are # sub-packages of the ported python3 (BRINTOS-1476) and both are already on this # image, which is why this port waits on nothing. # # ONE CASE PER DEPENDENCY, against the extracted line, rather than one case # asserting the whole list as a string. The recipe's RDEPENDS order is not dpkg's # to preserve and the separator spacing is dpkg's own, so an exact-string case # here would be asserting a FORMAT this phase cannot measure. Split, each red also # names which dependency went missing instead of printing the whole line back. grep '^Depends:' "$W/st" > "$W/dep" 2>/dev/null || : > "$W/dep" check $P/pkg-depends-line test -s "$W/dep" check_out $P/pkg-depends-core "python3-core" cat "$W/dep" check_out $P/pkg-depends-xml "python3-xml" cat "$W/dep" # ---------------------------------------------------------- the interpreter --- # THE IMPORT, ON ITS OWN, BEFORE THE PROBE. If defusedxml cannot be imported then # every probe case below reds at once and each names a different symptom; this # case is what says the cause is the import. It is also the cheapest possible # statement of the deliverable. check $P/import python3 -c 'import defusedxml' # ...and the expat binding, separately and first in the failure order that # matters: defusedxml.common imports it at module scope and raises # ImportError("pyexpat") without it, so an interpreter without pyexpat presents # AS a broken defusedxml. This case is what tells the two apart. check $P/expat-import python3 -c 'from xml.parsers import expat' # ...and the facade the ticket names, which is the one that does the # mask-and-re-import dance. An interpreter whose pure-Python ElementTree source # is unreachable fails HERE and not at `import defusedxml'. check $P/import-elementtree python3 -c 'import defusedxml.ElementTree' # ---------------------------------------------------------------- the probe --- if [ ! -f "$PROBE" ]; then bst_notok $P/probe-present "$PROBE did not ship" rm -rf "$W" exit 0 fi bst_ok $P/probe-present # ONE interpreter start for everything that follows. bst_probe bounds it and # keeps BOTH streams: a probe that dies early otherwise leaves every case below # reporting got:'' and naming a symptom each, with the actual traceback nowhere. # The bound is generous because a python3 start on this arm is tens of seconds # before any work begins. # # $W is passed as the SCRATCH TREE: the external-entity fixtures name a file in # it by absolute path, so the probe writes the marker file itself rather than # shipping one, and the fixture is built around a path that exists. bst_probe $P/probe-run "$W/probe" 900 python3 "$PROBE" "$W" OUT="$W/probe" # Read a single key. The probe emits each key at most once, so head -n 1 is # defensive rather than load-bearing; the anchors keep `version' distinguishable # from `dist_version', and `def_xxe_exc' from `def_xxe_excstr'. pv() { grep "^$1=" "$OUT" 2>/dev/null | head -n 1 | cut -d= -f2-; } # THE EVIDENCE PLANE. If the probe emitted nothing, every case below is reading # an absent file: each would red with got:'' and the set would read exactly like # a broken library, when what actually happened is that no measurement was taken. # The word `unscorable' is PARSED -- brintos_local_verify re-measures a suite that # prints it on a fresh guest rather than trusting a shared boot's numbers # (BRINTOS-1644) -- so it goes in this structural check, not in a comment. # # The cases below still print their reds: withdraw, do not hide. This case is # what says what those reds measure. if [ -s "$OUT" ]; then bst_ok $P/probe-output else bst_notok $P/probe-output "the probe emitted nothing -- python3 did not start, or died before its first line; every CASE below is unscorable" fi # THE EXPAT BINDING, FROM THE INTERPRETER'S OWN ANSWER. `sys.builtin_module_names' # cannot be satisfied by a file that merely arrived on the path, so this is the # statement that the parser under defusedxml is the one linked into the static # interpreter. The version it reports is emitted but NOT asserted -- see header. check_eq $P/pyexpat-builtin "yes" "$(pv pyexpat_builtin)" # THE VERSION THE RECIPE PINS, twice and independently: the package's own # `__version__' and importlib.metadata over the installed dist-info. The two # disagreeing means a mixed install. check_eq $P/version "0.7.1" "$(pv version)" check_eq $P/dist-version "0.7.1" "$(pv dist_version)" # THE PUBLIC SURFACE, from the package's own `__all__' rather than from dir(): # defusedxml DECLARES one, and it is exactly the five exception types -- the # facades are submodules, not names on the package. A release that added or # renamed an exception changes what a caller can catch, which is this package's # entire interface. check_eq $P/api-all \ "DefusedXmlException,DTDForbidden,EntitiesForbidden,ExternalReferenceForbidden,NotSupportedError" \ "$(pv api_all)" # ...and the facade's own declaration, which is the surface a caller imports. # `XMLParse' is upstream's own typo, kept for backwards compatibility; it is part # of the shipped surface and so it is part of the assertion. check_eq $P/et-all \ "ParseError,XML,XMLParse,XMLParser,XMLTreeBuilder,fromstring,iterparse,parse,tostring" \ "$(pv et_all)" check_eq $P/et-origin "xml.etree.ElementTree" "$(pv et_origin)" # ------------------------------------------------ the pure-Python parser ------ # THE CASE THAT MATTERS ON THIS ARM. See the header at length: if the base class # were the C XMLParser then the guards would be installed on a class that ignores # them, and defusedxml would import, expose every name, and refuse NOTHING while # every surface case here still passed. check_eq $P/parser-not-c-xmlparser "no" "$(pv parser_is_stdlib_xmlparser)" check_eq $P/parser-base "XMLParser" "$(pv parser_base)" # ...and the source file that re-import needs, from the interpreter's own answer # rather than from the path literal asserted above. The two together say the # stdlib ElementTree this interpreter loaded is a .py and not a frozen module. check_eq $P/stdlib-et-is-source "yes" "$(pv stdlib_et_is_source)" # ------------------------------------------------------------ the facades ----- # The nine that must import on this image, as one sorted list. A red names which # facade broke because the list is printed back. check_eq $P/submodules-ok \ "cElementTree,common,ElementTree,expatbuilder,expatreader,minidom,pulldom,sax,xmlrpc" \ "$(pv submodules_ok)" check_eq $P/submodules-none-failed "" "$(pv submodules_failed)" # ...and the tenth, which must NOT. See the header: lxml is not ported, this # asserts the gap rather than skipping it, and it reds the day lxml arrives -- # which is when somebody should notice this facade has come alive. check_eq $P/lxml-facade-importerror "ImportError" "$(pv lxml_facade)" check_eq $P/lxml-facade-names-lxml "yes" "$(pv lxml_facade_names_lxml)" # ------------------------------------------------------------- the fixtures --- # The three documents the guards are driven with, pinned by digest so every case # below is known to be about the documents this file describes rather than about # an edited probe. The XXE fixture is NOT pinned: it interpolates the scratch # path, which is not a constant. `good-len' pins the well-formed one by length as # well, since it is the only fixture whose CONTENT is also compared. check_eq $P/billion-sha "c767cf7e146bfdd0" "$(pv billion_sha)" check_eq $P/quad-sha "2ab2b63a2da36580" "$(pv quad_sha)" check_eq $P/good-sha "a9f12e8f271082b5" "$(pv good_sha)" check_eq $P/good-len "92" "$(pv good_len)" # ------------------------------------------ the guards, on the default path --- # THE DELIVERABLE. All three attack fixtures are REFUSED by # defusedxml.ElementTree.fromstring with its shipped defaults. # # Each is asserted as outcome AND exception type. The outcome case alone would be # satisfied by any exception at all -- including a ParseError from a fixture this # file had accidentally broken -- so the type is what says the refusal was # defusedxml's and not the parser's. check_eq $P/def-billion-outcome "raised" "$(pv def_billion_outcome)" check_eq $P/def-billion-exc "EntitiesForbidden" "$(pv def_billion_exc)" check_eq $P/def-quad-outcome "raised" "$(pv def_quad_outcome)" check_eq $P/def-quad-exc "EntitiesForbidden" "$(pv def_quad_exc)" # AND THE XXE ONE, WHICH IS ALSO EntitiesForbidden AND NOT # ExternalReferenceForbidden -- see the header. The declaration handler fires # before the reference is reached. This is the case a plausible reading of the # ticket would have got wrong. check_eq $P/def-xxe-outcome "raised" "$(pv def_xxe_outcome)" check_eq $P/def-xxe-exc "EntitiesForbidden" "$(pv def_xxe_exc)" # The refusal NAMES the system id it refused, which is what makes it actionable # to a caller catching it. Asserted as a yes/no about the scratch path rather # than as the exception string, which is not portable. check_eq $P/def-xxe-names-secret "yes" "$(pv def_xxe_names_secret)" # ------------------------------------------------ the same three, undefused --- # THE CONTROL LEG. Without it every case above is satisfied by a package that # refuses nothing that was ever going to happen. The two bombs EXPAND, with the # expansion asserted exactly -- 3x3x3 characters and 16x64 -- so a stdlib that # had itself been hardened underneath us is distinguishable from a defusedxml # that works. check_eq $P/std-billion-outcome "parsed" "$(pv std_billion_outcome)" check_eq $P/std-billion-textlen "27" "$(pv std_billion_textlen)" check_eq $P/std-quad-outcome "parsed" "$(pv std_quad_outcome)" check_eq $P/std-quad-textlen "1024" "$(pv std_quad_textlen)" # THE XXE CONTROL, WHICH IS NOT A CONTRAST OF THE SAME KIND -- see the header. # The stdlib does not read the file either; it fails on well-formedness at # REFERENCE time with an untyped ParseError that does NOT name the file. That # pair of cases is the whole of the difference defusedxml makes here, stated # honestly: a typed, catchable exception naming the system id, versus a parse # error naming nothing. check_eq $P/std-xxe-outcome "raised" "$(pv std_xxe_outcome)" check_eq $P/std-xxe-exc "ParseError" "$(pv std_xxe_exc)" check_eq $P/std-xxe-names-secret "no" "$(pv std_xxe_names_secret)" # --------------------------------- the other two guards, on their own paths --- # ExternalReferenceForbidden, reached with the entity guard stood down so the # REFERENCE rather than the declaration is what expat stops on. This is the case # that says the third handler is wired at all -- nothing on the default path ever # reaches it. check_eq $P/extref-outcome "raised" "$(pv extref_outcome)" check_eq $P/extref-exc "ExternalReferenceForbidden" "$(pv extref_exc)" check_eq $P/extref-names-secret "yes" "$(pv extref_names_secret)" # DTDForbidden, off by default because a DTD is not itself an attack. Stated as # behaviour rather than left as an unexercised constructor argument. check_eq $P/dtd-outcome "raised" "$(pv dtd_outcome)" check_eq $P/dtd-exc "DTDForbidden" "$(pv dtd_exc)" # ------------------------------------------ the exception hierarchy, asserted -- # Every guard raises a DefusedXmlException, which IS a ValueError. That is the # contract a caller writes `except ValueError' against, and it is what makes the # refusals catchable as a class rather than one name at a time. A release that # reparented them would break every such caller while every case above still # passed. check_eq $P/exc-base-is-valueerror "yes" "$(pv exc_base_is_valueerror)" check_eq $P/exc-all-defused "yes" "$(pv exc_all_defused)" # -------------------------------------------------- the well-formed document -- # THE HARDENING DOES NOT BREAK ORDINARY INPUT. Compared as SERIALISED BYTES # through each parser's own tree, which is the strongest form available: same # elements, same attributes, same text. A package that refused everything would # pass every guard case above and red here. check_eq $P/good-defused "parsed" "$(pv good_defused)" check_eq $P/good-stdlib "parsed" "$(pv good_stdlib)" check_eq $P/good-identical "yes" "$(pv good_identical)" check_eq $P/good-out-sha "de1158019fbc9f07" "$(pv good_out_sha)" check_eq $P/good-out-len "55" "$(pv good_out_len)" # ------------------------------------------------------------ the leak check -- # THE SECURITY PROPERTY, ASSERTED AGAINST THE BYTES rather than inferred from # which exception came back. Nothing either parser returned, and nothing either # parser said, contained the marker the secret file holds -- so the file was # never read, however the parsers are wired. `secret-intact' pairs with it so a # `no' means "never read" rather than "never written". check_eq $P/secret-not-leaked "no" "$(pv secret_leaked)" check_eq $P/secret-intact "yes" "$(pv secret_intact)" # The probe ran to the end. Every case above greps a key, so a probe that died # halfway would leave the remaining keys absent and each case would red with # got:'' -- this case is the one that says the cause was a truncated run. check_eq $P/probe-complete "yes" "$(pv done)" rm -rf "$W"