diff --git a/CHANGELOG.md b/CHANGELOG.md
index d82acc00c..5720c5f2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,13 @@
+## [Unreleased]
+### Security
+- SSRF: `URLValidator` now blocks wildcard/any-local (0.0.0.0, ::) addresses in addition to link-local and private ranges, and checks every address the host resolves to (narrowing the DNS-rebinding window). Loopback stays reachable for same-origin document/WebID dereferencing; the backend triplestore is site-local (already blocked). `ALLOW_INTERNAL_URLS` remains the development escape hatch (LNK-003/LNK-009)
+- XXE: added `SecureXML` hardened parser factories — `XSLTMasterUpdater` parses with DTDs and external entities disabled, and the external responses parsed by `ldh:send-request` use secure processing (entity-expansion capped) with external entities disabled (LNK-005 residual)
+- Upgraded `java-jwt` 3.19.4 → 4.5.2 on the OAuth2/OIDC verification path
+- Documented the pinned-truststore invariant behind the disabled hostname verification on internal HTTP clients
+
+### Added
+- Loopback/wildcard `URLValidator` tests; JWKS-based `JWTVerifier` tests (valid, wrong issuer, wrong audience, expired, missing `kid`, bad signature)
+
## [5.6.0] - 2026-07-08
### Added
- `OntologyRepository` (renamed from `OntologyModelGetter`): a bounded, evicting ontology cache that serves bundled vocabularies without querying SPARQL; per-app creation is thread-safe and each ontology is materialized once under a lock (`owl:imports` closure flattened manually, then RDFS-inferred and materialized). Seeded ad-hoc in `Namespace`
diff --git a/pom.xml b/pom.xml
index e28cae297..2ee038f4d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -181,7 +181,7 @@
com.auth0
java-jwt
- 3.19.4
+ 4.5.2
net.jodah
diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java
index 2d429ce4f..8ec9bb5a0 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/Application.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java
@@ -1519,6 +1519,7 @@ public static Client getClient(KeyStore keyStore, String keyStorePassword, KeySt
ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
Registry socketFactoryRegistry = RegistryBuilder.create().
+ // hostname verification is safely disabled because ctx trusts only the pinned truststore; revisit if that truststore ever widens to public CAs
register("https", new SSLConnectionSocketFactory(ctx, NoopHostnameVerifier.INSTANCE)).
register("http", new PlainConnectionSocketFactory()).
build();
@@ -1626,6 +1627,7 @@ public static Client getNoCertClient(KeyStore trustStore, Integer maxConnPerRout
ctx.init(null, tmf.getTrustManagers(), null);
Registry socketFactoryRegistry = RegistryBuilder.create().
+ // hostname verification is safely disabled because ctx trusts only the pinned truststore; revisit if that truststore ever widens to public CAs
register("https", new SSLConnectionSocketFactory(ctx, NoopHostnameVerifier.INSTANCE)).
register("http", new PlainConnectionSocketFactory()).
build();
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/IDTokenFilterBase.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/IDTokenFilterBase.java
index d3ed486c8..7d42cb81c 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/IDTokenFilterBase.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/IDTokenFilterBase.java
@@ -195,7 +195,7 @@ public SecurityContext authenticate(ContainerRequestContext request)
else
{
if (log.isDebugEnabled()) log.debug("ID token for subject '{}' has expired at {}, refresh token not found", jwt.getSubject(), jwt.getExpiresAt());
- throw new TokenExpiredException("ID token for subject '%s' has expired at %s".formatted(jwt.getSubject(), jwt.getExpiresAt()));
+ throw new TokenExpiredException("ID token for subject '%s' has expired at %s".formatted(jwt.getSubject(), jwt.getExpiresAt()), jwt.getExpiresAt().toInstant());
}
}
if (!verify(jwt)) return null;
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/SecureXML.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/SecureXML.java
new file mode 100644
index 000000000..32c8f7240
--- /dev/null
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/SecureXML.java
@@ -0,0 +1,78 @@
+/**
+ * Copyright 2026 Martynas Jusevičius
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.atomgraph.linkeddatahub.server.util;
+
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParserFactory;
+import org.xml.sax.SAXException;
+import org.xml.sax.XMLReader;
+
+/**
+ * Factory helpers for XML parsers hardened against XXE and entity-expansion (billion laughs) attacks.
+ *
+ * @author Martynas Jusevičius {@literal }
+ * @see OWASP XXE Prevention
+ */
+public final class SecureXML
+{
+
+ private SecureXML()
+ {
+ }
+
+ /**
+ * Returns a namespace-aware {@link DocumentBuilderFactory} with DTDs and external entities disabled.
+ * Suitable for parsing trusted internal XML (e.g. stylesheets) that never carries a DOCTYPE.
+ *
+ * @return hardened document builder factory
+ * @throws ParserConfigurationException if a feature cannot be set
+ */
+ public static DocumentBuilderFactory newDocumentBuilderFactory() throws ParserConfigurationException
+ {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ factory.setXIncludeAware(false);
+ factory.setExpandEntityReferences(false);
+ return factory;
+ }
+
+ /**
+ * Returns an {@link XMLReader} hardened for parsing untrusted external content.
+ * Secure processing caps entity expansion (billion laughs) and external entities are disabled,
+ * while a benign internal DOCTYPE (e.g. XHTML) is still tolerated.
+ *
+ * @return hardened XML reader
+ * @throws ParserConfigurationException if a feature cannot be set
+ * @throws SAXException if the reader cannot be created
+ */
+ public static XMLReader newXMLReader() throws ParserConfigurationException, SAXException
+ {
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ factory.setNamespaceAware(true);
+ factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
+ factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+ return factory.newSAXParser().getXMLReader();
+ }
+
+}
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java
index 23e3c92ec..9a1c6fbd1 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java
@@ -52,12 +52,17 @@ public URLValidator(boolean allowInternal)
/**
* Validates that the URI does not point to an internal/private network address.
* Prevents SSRF attacks by blocking access to:
- * - RFC 1918 private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
+ * - Wildcard/any-local addresses (0.0.0.0, ::)
+ * - RFC 1918 private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fec0::/10)
* - Link-local addresses (169.254.0.0/16, fe80::/10)
*
- * Note: Loopback addresses (127.0.0.1, localhost, ::1) are NOT blocked as the application
- * may legitimately need to access resources on the same server (e.g., transformation queries,
- * WebID documents during development, admin operations).
+ * All addresses the host resolves to are checked (not just the first), narrowing the DNS-rebinding
+ * window where a host publishes both a public and an internal address.
+ *
+ * Loopback addresses (127.0.0.0/8, ::1) are intentionally NOT blocked: LinkedDataHub dereferences
+ * its own documents and WebIDs on the same origin (which is loopback in local/test deployments), while
+ * the backend triplestore is reached over a private/site-local address (blocked above), not loopback.
+ * The {@code ALLOW_INTERNAL_URLS} escape hatch disables all checks for fully-internal deployments.
*
* @param uri the URI to validate
* @return the validated URI
@@ -73,18 +78,18 @@ public URI validate(URI uri)
String host = uri.getHost();
if (host == null) throw new IllegalArgumentException("URI host cannot be null");
- // Resolve hostname to IP and check if it's private/internal
+ // Resolve hostname to all IPs and reject if any is wildcard/private/internal (loopback intentionally allowed)
try
{
- InetAddress address = InetAddress.getByName(host);
-
- // Note: We don't block loopback addresses (127.0.0.1, localhost) because the application
- // legitimately accesses its own endpoints for various operations
-
- if (address.isLinkLocalAddress())
- throw new InternalURLException(uri, address.getHostAddress());
- if (address.isSiteLocalAddress())
- throw new InternalURLException(uri, address.getHostAddress());
+ for (InetAddress address : InetAddress.getAllByName(host))
+ {
+ if (address.isAnyLocalAddress())
+ throw new InternalURLException(uri, address.getHostAddress());
+ if (address.isLinkLocalAddress())
+ throw new InternalURLException(uri, address.getHostAddress());
+ if (address.isSiteLocalAddress())
+ throw new InternalURLException(uri, address.getHostAddress());
+ }
}
catch (UnknownHostException e)
{
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java
index cf37e56c3..d193455dc 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java
@@ -23,8 +23,8 @@
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import jakarta.servlet.ServletContext;
+import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
-import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
@@ -203,15 +203,14 @@ public void removePackageImport(Path masterFile, String packagePath) throws IOEx
private Document parseDocument(Path file) throws ParserConfigurationException, SAXException, IOException
{
- DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
- factory.setNamespaceAware(true);
- DocumentBuilder builder = factory.newDocumentBuilder();
+ DocumentBuilder builder = SecureXML.newDocumentBuilderFactory().newDocumentBuilder();
return builder.parse(file.toFile());
}
private void serializeDocument(Document doc, Path file) throws TransformerException
{
TransformerFactory transformerFactory = TransformerFactory.newInstance();
+ transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
Transformer transformer = transformerFactory.newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "no");
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
diff --git a/src/main/java/com/atomgraph/linkeddatahub/writer/function/SendHTTPRequest.java b/src/main/java/com/atomgraph/linkeddatahub/writer/function/SendHTTPRequest.java
index 3847b8a38..647356ef8 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/writer/function/SendHTTPRequest.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/writer/function/SendHTTPRequest.java
@@ -16,6 +16,7 @@
*/
package com.atomgraph.linkeddatahub.writer.function;
+import com.atomgraph.linkeddatahub.server.util.SecureXML;
import com.atomgraph.linkeddatahub.vocabulary.LDH;
import java.io.IOException;
import java.io.InputStream;
@@ -25,7 +26,8 @@
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
-import javax.xml.transform.stream.StreamSource;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.sax.SAXSource;
import net.sf.saxon.s9api.ExtensionFunction;
import net.sf.saxon.s9api.ItemType;
import net.sf.saxon.s9api.ItemTypeFactory;
@@ -38,6 +40,8 @@
import net.sf.saxon.s9api.XdmValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
/**
* Executes an HTTP request.
@@ -117,7 +121,7 @@ public XdmValue call(XdmValue[] arguments) throws SaxonApiException
if (cr.hasEntity())
try (InputStream is = cr.readEntity(InputStream.class))
{
- return getProcessor().newDocumentBuilder().build(new StreamSource(is));
+ return getProcessor().newDocumentBuilder().build(new SAXSource(SecureXML.newXMLReader(), new InputSource(is)));
}
}
else
@@ -135,14 +139,14 @@ public XdmValue call(XdmValue[] arguments) throws SaxonApiException
if (cr.hasEntity())
try (InputStream is = cr.readEntity(InputStream.class))
{
- return getProcessor().newDocumentBuilder().build(new StreamSource(is));
+ return getProcessor().newDocumentBuilder().build(new SAXSource(SecureXML.newXMLReader(), new InputSource(is)));
}
}
}
-
+
return XdmEmptySequence.getInstance();
}
- catch (IOException ex)
+ catch (IOException | ParserConfigurationException | SAXException ex)
{
throw new SaxonApiException(ex);
}
diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/util/JWTVerifierTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/util/JWTVerifierTest.java
new file mode 100644
index 000000000..c9b7d8093
--- /dev/null
+++ b/src/test/java/com/atomgraph/linkeddatahub/server/util/JWTVerifierTest.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2026 Martynas Jusevičius .
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.atomgraph.linkeddatahub.server.util;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTCreator;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import jakarta.json.Json;
+import jakarta.json.JsonObject;
+import java.math.BigInteger;
+import java.net.URI;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for JWKS-based JWT verification.
+ * The JWKS is supplied through the cache argument so verification runs offline (no HTTP client needed).
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public class JWTVerifierTest
+{
+
+ private static final String ISSUER = "https://accounts.example.com";
+ private static final String CLIENT_ID = "client-abc";
+ private static final String KID = "test-key-1";
+ private static final String SUBJECT = "user-123";
+ private static final URI JWKS_ENDPOINT = URI.create("https://accounts.example.com/jwks");
+
+ private static KeyPair keyPair; // published in the JWKS
+ private static KeyPair otherKeyPair; // used to forge a bad signature
+ private static List allowedIssuers;
+ private static Map jwksCache;
+
+ @BeforeAll
+ public static void init() throws NoSuchAlgorithmException
+ {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ keyPair = generator.generateKeyPair();
+ otherKeyPair = generator.generateKeyPair();
+
+ allowedIssuers = List.of(ISSUER);
+
+ JsonObject jwk = Json.createObjectBuilder().
+ add("kty", "RSA").
+ add("use", "sig").
+ add("alg", "RS256").
+ add("kid", KID).
+ add("n", base64Url(((RSAPublicKey) keyPair.getPublic()).getModulus())).
+ add("e", base64Url(((RSAPublicKey) keyPair.getPublic()).getPublicExponent())).
+ build();
+ JsonObject jwks = Json.createObjectBuilder().add("keys", Json.createArrayBuilder().add(jwk)).build();
+
+ jwksCache = new HashMap<>();
+ jwksCache.put(JWKS_ENDPOINT.toString(), jwks);
+ }
+
+ @Test
+ public void testValidTokenVerifies()
+ {
+ DecodedJWT jwt = JWT.decode(createToken(ISSUER, CLIENT_ID, KID, Instant.now().plusSeconds(300), keyPair));
+ assertTrue(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache));
+ }
+
+ @Test
+ public void testWrongIssuerRejected()
+ {
+ DecodedJWT jwt = JWT.decode(createToken("https://evil.example", CLIENT_ID, KID, Instant.now().plusSeconds(300), keyPair));
+ assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache));
+ }
+
+ @Test
+ public void testWrongAudienceRejected()
+ {
+ DecodedJWT jwt = JWT.decode(createToken(ISSUER, "some-other-client", KID, Instant.now().plusSeconds(300), keyPair));
+ assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache));
+ }
+
+ @Test
+ public void testExpiredTokenRejected()
+ {
+ DecodedJWT jwt = JWT.decode(createToken(ISSUER, CLIENT_ID, KID, Instant.now().minusSeconds(300), keyPair));
+ assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache));
+ }
+
+ @Test
+ public void testMissingKeyIdRejected()
+ {
+ DecodedJWT jwt = JWT.decode(createToken(ISSUER, CLIENT_ID, null, Instant.now().plusSeconds(300), keyPair));
+ assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache));
+ }
+
+ @Test
+ public void testBadSignatureRejected()
+ {
+ // token references the published kid but is signed with a different key
+ DecodedJWT jwt = JWT.decode(createToken(ISSUER, CLIENT_ID, KID, Instant.now().plusSeconds(300), otherKeyPair));
+ assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache));
+ }
+
+ private static String createToken(String issuer, String audience, String kid, Instant expiresAt, KeyPair signingKeyPair)
+ {
+ Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) signingKeyPair.getPublic(), (RSAPrivateKey) signingKeyPair.getPrivate());
+
+ JWTCreator.Builder builder = JWT.create().
+ withIssuer(issuer).
+ withAudience(audience).
+ withSubject(SUBJECT).
+ withExpiresAt(expiresAt);
+ if (kid != null) builder = builder.withKeyId(kid);
+
+ return builder.sign(algorithm);
+ }
+
+ private static String base64Url(BigInteger value)
+ {
+ byte[] bytes = value.toByteArray();
+ if (bytes.length > 1 && bytes[0] == 0) // drop the sign byte so the magnitude round-trips
+ {
+ byte[] trimmed = new byte[bytes.length - 1];
+ System.arraycopy(bytes, 1, trimmed, 0, trimmed.length);
+ bytes = trimmed;
+ }
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+}
diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java
index b72bc4b65..735e23c09 100644
--- a/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java
+++ b/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java
@@ -39,6 +39,21 @@ public void testNullURI()
assertThrows(IllegalArgumentException.class, () -> new URLValidator(false).validate(null));
}
+ @Test
+ public void testLoopbackAllowedForSelfDereferencing()
+ {
+ // loopback is intentionally allowed even with SSRF protection on: LDH dereferences its own
+ // documents/WebIDs on the same origin (loopback in local/test deployments); the backend is site-local (blocked)
+ new URLValidator(false).validate(URI.create("http://127.0.0.1:3030/ds"));
+ new URLValidator(false).validate(URI.create("http://localhost:3030/ds"));
+ }
+
+ @Test
+ public void testAnyLocalBlocked()
+ {
+ assertThrows(InternalURLException.class, () -> new URLValidator(false).validate(URI.create("http://0.0.0.0:8080/test")));
+ }
+
@Test
public void testLinkLocalIPv4Blocked()
{
@@ -97,4 +112,11 @@ public void testAllowInternalLinkLocalAllowed()
// When allowInternal=true, link-local addresses should pass through without exception
new URLValidator(true).validate(URI.create("http://169.254.1.1:8080/test"));
}
+
+ @Test
+ public void testAllowInternalLoopbackAllowed()
+ {
+ // When allowInternal=true, loopback addresses should pass through without exception (dev escape hatch)
+ new URLValidator(true).validate(URI.create("http://127.0.0.1:3030/ds"));
+ }
}