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")); + } }