This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/file-resolvers in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git
commit faa4e1c8800998090e2b80c6d57ac466362b0fc4 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Thu Sep 24 10:04:17 2026 +0100 Make sure file references are to actual files --- THREAT-MODEL.md | 12 +++++ .../schema/resolver/DefaultURIResolver.java | 54 +++++++++++++++++++++- .../test/java/tests/DefaultURIResolverTest.java | 41 ++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index a058cb30..7a155f9b 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -766,6 +766,18 @@ Revise this document when any of the following lands: rule as first written: it tested only the URI authority, so `file:////host/share/x.xsd`, which parses with no authority and carries the host in its path instead, was not caught. +- **2026-09-24** — `DefaultURIResolver` now refuses a local location that + exists but is not a regular file. A remote fetch is bounded in time and + bytes by the properties in §5a; a local read is handed to the parser as a + system id and is bounded by nothing, so a named pipe held the parsing + thread for as long as nothing wrote to it (confirmed: an indefinite hang, + now a refusal). Directories, character devices and sockets go out of + reach with it. A location that does not exist is untouched, so a missing + schema still reports as it always has and the §9 existence oracle is + unchanged. A revision trigger under the first bullet above. Note that + this is not a size or time bound on local reads: §9 still records that a + single schema document has no size limit, and a large local one can still + exhaust the heap. - **2026-09-24** — `DefaultURIResolver` now refuses, before a remote fetch and again on each redirect hop, an address in a class that can never serve a schema document: link-local, multicast, wildcard, IPv6 diff --git a/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java b/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java index 87260084..4329fc8b 100644 --- a/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java +++ b/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java @@ -547,9 +547,59 @@ public class DefaultURIResolver implements CollectionURIResolver { + "\" would be read from the filesystem, which " + ALLOW_FILE_SYSTEM_PROPERTY + " has turned off."); } - if ("file".equals(scheme) && !isLocalFileUri(archive)) { + if ("file".equals(scheme)) { + if (!isLocalFileUri(archive)) { + throw new XmlSchemaException("The schema location \"" + schemaLocation + + "\" resolves to a file URL with a non-local" + + " authority."); + } + verifyRegularFile(archive, schemaLocation); + } + } + + /** + * Refuse a local location that exists but is not a regular file. A remote fetch is bounded in + * time and bytes; a local read is handed to the parser as a system id and is bounded by + * nothing, so a named pipe holds the parsing thread for as long as nothing writes to it. A + * schema document is a regular file, so requiring one costs nothing and also puts directories, + * character devices and sockets out of reach. + * <p> + * A location that does not exist is left alone: that is an ordinary missing-schema error and + * the parser reports it as it always has. + * </p> + * + * @param uri the resolved location, or for a <code>jar:</code> URL the archive it names. + * @param schemaLocation the original schema location, for the error message. + */ + private static void verifyRegularFile(String uri, String schemaLocation) { + final File file = toLocalFile(uri); + if (file != null && file.exists() && !file.isFile()) { throw new XmlSchemaException("The schema location \"" + schemaLocation - + "\" resolves to a file URL with a non-local authority."); + + "\" is not a regular file. A schema document cannot be" + + " a directory, a device or a pipe, and reading one can" + + " block the parse for as long as nothing writes to it."); + } + } + + /** + * The local file a <code>file:</code> URL names, or <code>null</code> if it cannot be mapped to + * one. Null means the check above does not apply rather than that the location is safe; the + * scheme and authority rules have already run. + */ + private static File toLocalFile(String uri) { + final URI parsed; + try { + parsed = new URI(uri.trim()); + } catch (URISyntaxException e) { + return null; + } + try { + return new File(parsed); + } catch (IllegalArgumentException e) { + // File(URI) refuses an authority, even "localhost", which isLocalFileUri allows. The + // path is the part that names the file. + final String path = parsed.getPath(); + return path == null || path.length() == 0 ? null : new File(path); } } diff --git a/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java b/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java index c9d8f5a1..ee150987 100644 --- a/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java +++ b/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java @@ -436,4 +436,45 @@ public class DefaultURIResolverTest extends Assert { expected.getMessage().contains(expectedMessageFragment)); } } + + /** + * A local read is handed to the parser as a system id and bounded by nothing, so a named pipe + * holds the parsing thread indefinitely. A directory exercises the same predicate portably -- + * Java cannot create a FIFO -- and is refused for the same reason. + */ + @Test + public void testALocalLocationThatIsNotARegularFileIsRefused() throws Exception { + File directory = existingDirectory(); + assertTrue(directory.isDirectory()); + String asFileUrl = directory.toURI().toString(); + + assertSchemeRefused(asFileUrl, null, "not a regular file"); + assertSchemeRefused(asFileUrl, localBase(), "not a regular file"); + // The archive of a jar: URL must be a regular file too. + assertSchemeRefused("jar:" + asFileUrl + "!/x.xsd", null, "not a regular file"); + } + + @Test + public void testARegularFileStillResolves() throws Exception { + File file = File.createTempFile("schema", ".xsd"); + try { + assertEquals(file.toURI().toString(), + new DefaultURIResolver() + .resolveEntity("urn:x", file.toURI().toString(), null).getSystemId()); + } finally { + assertTrue(file.delete() || !file.exists()); + } + } + + /** + * A location that does not exist is not this check's business: it is an ordinary missing-schema + * error, and refusing it here would change what the parser reports for a typo. + */ + @Test + public void testAMissingLocalFileIsStillResolved() { + String missing = new File(existingDirectory(), "no-such-schema.xsd").toURI().toString(); + + assertEquals(missing, + new DefaultURIResolver().resolveEntity("urn:x", missing, null).getSystemId()); + } }
