Skip to content

Load the external DTD subset through the resource loader and requesters - #41

Merged
FlorianRappl merged 3 commits into
AngleSharp:develfrom
michaelthyregod:fix/external-dtd-loading
Nov 2, 2026
Merged

FlorianRappl merged 3 commits into
AngleSharp:develfrom
michaelthyregod:fix/external-dtd-loading

Conversation

@michaelthyregod

@michaelthyregod michaelthyregod commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Not benchmarked yet: I'll run the benchmarks once my pending AngleSharp core change (part of an AngleSharp.Css fix) is settled.

Reworked as discussed: the external DTD subset now loads through the AngleSharp ecosystem instead of the file system.

Problem

Parsing read the file a document's <!DOCTYPE r SYSTEM "..."> named with File.ReadAllText, from any path, with default options. That includes UNC paths: \\server\share\x.dtd makes Windows connect to server and authenticate as the current user while the document is parsed.

Change

  • Fetched like the document's other resources: through the browsing context's IResourceLoader, which only exists when resource loading is enabled, and its requesters, which decide the protocols.
    • Local files need a file: requester, such as AngleSharp.Io's.
    • Relative SYSTEM identifiers resolve against the document URL.
    • Without a loader, nothing is loaded, which is the default for new XmlParser() and WithXml() alone.
    • The ResourceRequest needs an element for its origin and referrer. The DOCTYPE comes before any element, so a detached element of the document stands in.
  • One loading path: XmlDomBuilder.LoadExternalSubsetAsync. The async parse awaits it (ConfigureAwait(false)), and the sync parse waits for it. The docs point callers on a synchronization context to the async parse.
  • The direct file access is removed: TryLoadExternalSubset and TryResolveSystemPath in the builder, and DtdParser's GetText / TryResolvePath.
    • External entities are no longer read; they get no replacement text.
    • DtdParser currently fails on the first token of every subset (swallowed by the builder), so that path didn't run, but it would have read any file a document named once DtdParser worked.
  • Docs: 04-Questions, 05-DTD-Validation (new section "Loading the external subset"), AGENTS.md and the CHANGELOG are updated.

Tests

  • Mock requester: Mocks/MockRequester serves mock: and http://*.test resources. Registered as the only requester, so WithDefaultLoader adds no HTTP requester and no test reaches the network.
  • XmlExternalDtdSupport covers:
    • validation and entities from the external subset, sync and async;
    • relative identifiers against the document URL;
    • nothing loaded without resource loading or without a loader;
    • a local file path is not read without a file: requester;
    • an unloadable subset is ignored.
  • The two other external DTD tests (XmlDeepNesting, XmlDtdIdentityValidation) serve their DTD through the mock instead of a temp file. Their documents and assertions are unchanged.
  • Benchmarks: the External rows load their DTD through a small file: requester, and GlobalSetup checks that the DTD is applied.
  • 627 tests pass.

🤖 Generated with Claude Code

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too many changes.

@michaelthyregod
michaelthyregod force-pushed the fix/external-dtd-loading branch from cb5e80c to 5121c7c Compare November 1, 2026 17:19
@michaelthyregod

Copy link
Copy Markdown
Contributor Author

#38 is merged now and this is rebased onto devel, so it only shows its own changes: 2 commits, 14 files (+281/-24). Most of it is docs and tests; happy to split the docs into a separate PR if that helps.

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not look like a good solution to me.

A good solution uses the AngleSharp ecosystem and it's requesters. So local files can only be read is there is a requested for file: protocols (eg as provided by AngleSharp.Io). Same is true for URLs etc

This way one can be really flexible up to the point of mocking input or determining the resolution on the fly.

@michaelthyregod

Copy link
Copy Markdown
Contributor Author

Makes sense, I'll rework it that way:

  • Resolve the SYSTEM identifier against the document URL and fetch it through the browsing context's resource loader and requesters. Without a loader (or without a requester for the protocol) nothing is loaded, so local files only load with a file: requester such as AngleSharp.Io's, and tests can use a mock requester.
  • This replaces IsResolvingExternalEntities and the direct file access (LocalFiles included); keeping UNC paths out becomes the requester's job.

One question: the requesters are async. Should external DTDs only load in ParseDocumentAsync and browsing-context loading, with the synchronous ParseDocument ignoring them (rather than blocking on the requester)? That's what I'd do.

Also worth noting: an app that configures WithDefaultLoader for pages would then also fetch DTDs over HTTP from URLs the document chooses. Should that follow LoaderOptions.IsResourceLoadingEnabled, or is the configured requester enough?

@FlorianRappl

Copy link
Copy Markdown
Contributor

Well from the inside we don't know/don't care of we have been called async - so I would not differentiate.

Regarding the loading: true I'd only call this if resource loading is enabled. Makes most sense to me.

@michaelthyregod

Copy link
Copy Markdown
Contributor Author

Thanks. Loading only when resource loading is enabled means going through the browsing context's IResourceLoader, which only exists then. That's what I'll do.

On sync vs. async: the requesters only offer Task-based APIs. To load the DTD in the synchronous ParseDocument, the parser would have to block on that task (GetAwaiter().GetResult()). That ties up the thread for the whole download, and it can deadlock when the caller runs under a synchronization context (UI threads, classic ASP.NET), if the requester resumes on that context.

Would you accept blocking there, or rather that the synchronous parse skips external DTDs (or only uses them when the parse runs through the async path, e.g. browsing-context loading)? I'll implement whichever you prefer.

@FlorianRappl

Copy link
Copy Markdown
Contributor

Someone who uses the sync path (not the recommendation but it's fine) should not use requesters - and if should not put them into the same synchronization context. From my perspective this is on the user.

I'd not distinguish here and I'd use the same logic for both ways (there should not be a duplicate).

The external subset a DOCTYPE names was read with File.ReadAllText from
any path the document chose, with default options, including UNC paths
(\\server\share), which connect to the server while parsing.

It is now fetched like the document's other resources: through the
browsing context's IResourceLoader, which only exists when resource
loading is enabled, and its requesters, which decide the protocols
(file: through e.g. AngleSharp.Io's requester). Relative SYSTEM
identifiers resolve against the document URL. Without a loader nothing
is loaded. LoadExternalSubsetAsync is the one loading path: the
asynchronous parse awaits it, the synchronous parse waits for it.

DtdParser no longer reads external entities from disk; it fails on the
first token of every subset today, so this path did not run, but it would
have read any file a document named once it did.

Tests use a mock requester registered as the only requester, so no
request reaches the network. The benchmark's External rows load their
DTD through a file: requester and check that it is applied.
@michaelthyregod
michaelthyregod force-pushed the fix/external-dtd-loading branch from 5121c7c to 4e8f59c Compare November 2, 2026 08:08
@michaelthyregod michaelthyregod changed the title Load external DTD subsets and entities only when asked to, and only from local files Load the external DTD subset through the resource loader and requesters Nov 2, 2026
@michaelthyregod

Copy link
Copy Markdown
Contributor Author

Reworked as discussed: the external subset now loads through IResourceLoader (only with resource loading enabled) and its requesters, with one loading path that the sync parse waits for. Direct file access is gone, including in DtdParser.

@FlorianRappl FlorianRappl added this to the v1.3.0 milestone Nov 2, 2026
Comment thread CHANGELOG.md Resolved
The External rows name the plain path again, which older builds read
directly; as a URL its drive letter is the protocol, so the benchmark's
requester serves single-letter protocols from the disk, without Uri.
@michaelthyregod

Copy link
Copy Markdown
Contributor Author

Thanks. The benchmark's Documents.cs is back to the original path (no Uri), and the CHANGELOG entry is shortened and uses "Updated" (1c75fa7).

On interrupting the parse: it waits at the DOCTYPE because entities declared in the external subset (<!ENTITY greet ...>) are replaced while the body is tokenized. If the subset arrives later, &greet; is an unknown entity, and strict parsing throws. Validation alone could wait until the end. Two ways to avoid the interruption:

  • (b) Start the download at the DOCTYPE, keep parsing, and wait only at the end for validation. Nothing interrupts the parse, but entities from the external subset stop working (today they're supported and tested).
  • (c) Start the download at the DOCTYPE and wait only when it's needed: when the body references an entity that isn't declared internally, otherwise at the end for validation. Entities keep working, and most documents are never interrupted; it's a bit more code.

Which do you prefer? And should this still go into 1.3.0 (#43), or after it?

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@FlorianRappl

Copy link
Copy Markdown
Contributor

Good point regarding the entity validation. Can we aggregate the entities and defer their validation / resolution to the end of the parse? This way, we can keep on parsing and only deal with the validation as whole at the end.

@michaelthyregod

Copy link
Copy Markdown
Contributor Author

Thanks! Yes, that's doable:

  1. At the DOCTYPE the download starts and parsing continues.
  2. A reference to an entity that isn't declared internally doesn't throw while a download is pending. It's kept, and the parser records where it sits (text node or attribute, and offset).
  3. At the end, the parse waits for the external subset, registers its entities, and replaces the recorded references.
  4. Then validation runs as now; in strict mode an entity that is still unknown throws then, pointing at the recorded position.

The replacement uses the recorded positions rather than searching the text afterwards, which would also hit escaped text such as &amp;name;.

Since this PR is approved as it is, I'd suggest merging it and doing the deferral as a follow-up PR. Or would you rather have it in this one?

@FlorianRappl

Copy link
Copy Markdown
Contributor

I'd see it in this one. Also note for (2): To not overcomplicate things I'd always do the entity resolution at the end. This way, it's consistent and we don't need to check if a download is active or finished. We always do the same.

Loading the external subset no longer interrupts the parse. The download
starts at the DOCTYPE and the parse goes on; the tokenizer keeps every
reference to a non-built-in entity as written and records its offset
(XmlEntityReference), and the builder records where it lands (text node
or attribute value). When the tree is built, CompleteAsync waits for the
download (the synchronous parse blocks there), builds the DTD from the
external and internal subsets, replaces the recorded references by their
offsets, and validates. Both parse paths complete the same way.

The replacement uses the recorded offsets, never a search of the text.
That fixes escaped references: &amp;e; read as &e; and was expanded as
well when e was declared. An empty entity now resolves to nothing; strict
parsing threw on it before. An unknown entity still throws the same error
at the same position, only at the end of the parse. OnCreated sees
attribute values with their references as written.

A 60,000-result differential fuzz against the previous AngleSharp#41 code differs
only for escaped references to declared entities and for empty entities.
@michaelthyregod

Copy link
Copy Markdown
Contributor Author

Done in 07d4409: entity references are now always resolved at the end of the parse.

  • At the DOCTYPE the external subset starts loading and the parse goes on. The tokenizer keeps every reference to a non-built-in entity as written and records its offset; the builder records the text node or attribute it lands in.
  • At the end, both paths complete the same way: wait for the download, build the DTD, replace the recorded references by offset, then validate.
  • Side effects, both fixes:
    • escaped &amp;e; stays &e; (it was expanded as well when e was declared);
    • an empty entity now resolves to nothing (strict parsing threw on it).
  • Unknown entities throw the same error at the same position, just at the end. OnCreated sees attribute values with their references as written; that's in the docs.

Tests: 635 pass, 8 new. One test holds the DTD back and checks that the whole body is parsed before it arrives. A 60,000-result differential fuzz against the previous commit differs only for escaped references and empty entities. Still not benchmarked.

@FlorianRappl
FlorianRappl merged commit 68d437c into AngleSharp:devel Nov 2, 2026
5 checks passed
@michaelthyregod michaelthyregod mentioned this pull request Nov 2, 2026
8 of 9 tasks
@michaelthyregod
michaelthyregod deleted the fix/external-dtd-loading branch November 3, 2026 06:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants