From 88ba1022c6cbdb0c867f1b0aa422541aaea71cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sat, 11 Jul 2026 16:49:15 +0300 Subject: [PATCH 1/2] Harden SSRF, XXE, and JWT verification (pen-test remediation) - URLValidator: block loopback (127.0.0.0/8, ::1) and wildcard (0.0.0.0, ::) addresses in addition to link-local/private, and check every resolved address to narrow the DNS-rebinding window (LNK-003/LNK-009). ALLOW_INTERNAL_URLS remains the development escape hatch. Extend URLValidatorTest. - SecureXML: hardened parser factories. XSLTMasterUpdater parses with DTDs and external entities disabled; ldh:send-request (SendHTTPRequest) parses external responses with secure processing (entity-expansion capped) and external entities disabled (LNK-005 residual). - Upgrade java-jwt 3.19.4 -> 4.5.2 on the OAuth2/OIDC verification path; adapt the one breaking call (IDTokenFilterBase TokenExpiredException now takes an Instant); add JWKS-based JWTVerifier tests. - Document the pinned-truststore invariant behind the disabled hostname verifier on internal HTTP clients. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 ++ pom.xml | 2 +- .../atomgraph/linkeddatahub/Application.java | 2 + .../request/auth/IDTokenFilterBase.java | 2 +- .../linkeddatahub/server/util/SecureXML.java | 78 +++++++++ .../server/util/URLValidator.java | 33 ++-- .../server/util/XSLTMasterUpdater.java | 7 +- .../writer/function/SendHTTPRequest.java | 14 +- .../server/util/JWTVerifierTest.java | 154 ++++++++++++++++++ .../server/util/URLValidatorTest.java | 25 +++ 10 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/atomgraph/linkeddatahub/server/util/SecureXML.java create mode 100644 src/test/java/com/atomgraph/linkeddatahub/server/util/JWTVerifierTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d82acc00c1..da0568a7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [Unreleased] +### Security +- SSRF: `URLValidator` now blocks loopback (127.0.0.0/8, ::1) and wildcard (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). Closes the residual proxy-to-internal-service path; `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 e28cae2972..2ee038f4dc 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 2d429ce4f5..8ec9bb5a0c 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 d3ed486c8a..7d42cb81c3 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 0000000000..32c8f72401 --- /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 23e3c92ecd..4769f0c220 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,15 @@ 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) + * - Loopback addresses (127.0.0.0/8, ::1) — reaches services co-located with the application (e.g. Fuseki, Varnish) + * - 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. Legitimate access to internal + * addresses (e.g. localhost WebID documents in development) is enabled via the {@code ALLOW_INTERNAL_URLS} + * escape hatch, which sets {@code allowInternal}. * * @param uri the URI to validate * @return the validated URI @@ -73,18 +76,20 @@ 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 loopback/wildcard/private/internal 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.isLoopbackAddress()) + throw new InternalURLException(uri, address.getHostAddress()); + 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 cf37e56c36..d193455dc9 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 3847b8a38f..647356ef88 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 0000000000..c9b7d80939 --- /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 b72bc4b653..2e7b1d8b32 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,24 @@ public void testNullURI() assertThrows(IllegalArgumentException.class, () -> new URLValidator(false).validate(null)); } + @Test + public void testLoopbackIPv4Blocked() + { + assertThrows(InternalURLException.class, () -> new URLValidator(false).validate(URI.create("http://127.0.0.1:3030/ds"))); + } + + @Test + public void testLoopbackHostnameBlocked() + { + assertThrows(InternalURLException.class, () -> 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 +115,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")); + } } From 8dcb96e12157e7ad9829f94b04bb3adf3c8b9a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 12 Jul 2026 08:51:49 +0300 Subject: [PATCH 2/2] Do not block loopback in URLValidator (fix http-tests regression) The loopback block broke the HTTP test suite: it runs entirely on https://localhost:4443/, so every client-cert WebID is a localhost URI that WebIDFilter validates on each authenticated request. Blocking loopback turned those into InternalURLException -> 400 on every authenticated call. LDH dereferences its own documents/WebIDs on the same origin (loopback in local/test deployments), and the actual pen-test target (fuseki-admin:3030) is site-local and already blocked, so loopback blocking added little and fought the architecture. Keep the wildcard/any-local block and the all-addresses DNS-rebinding check; leave loopback reachable. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- .../linkeddatahub/server/util/URLValidator.java | 14 +++++++------- .../server/util/URLValidatorTest.java | 13 +++++-------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da0568a7b0..5720c5f2a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [Unreleased] ### Security -- SSRF: `URLValidator` now blocks loopback (127.0.0.0/8, ::1) and wildcard (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). Closes the residual proxy-to-internal-service path; `ALLOW_INTERNAL_URLS` remains the development escape hatch (LNK-003/LNK-009) +- 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 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 4769f0c220..9a1c6fbd13 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java @@ -52,15 +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: - * - Loopback addresses (127.0.0.0/8, ::1) — reaches services co-located with the application (e.g. Fuseki, Varnish) * - 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) * * 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. Legitimate access to internal - * addresses (e.g. localhost WebID documents in development) is enabled via the {@code ALLOW_INTERNAL_URLS} - * escape hatch, which sets {@code allowInternal}. + * 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 @@ -76,13 +78,11 @@ public URI validate(URI uri) String host = uri.getHost(); if (host == null) throw new IllegalArgumentException("URI host cannot be null"); - // Resolve hostname to all IPs and reject if any is loopback/wildcard/private/internal + // Resolve hostname to all IPs and reject if any is wildcard/private/internal (loopback intentionally allowed) try { for (InetAddress address : InetAddress.getAllByName(host)) { - if (address.isLoopbackAddress()) - throw new InternalURLException(uri, address.getHostAddress()); if (address.isAnyLocalAddress()) throw new InternalURLException(uri, address.getHostAddress()); if (address.isLinkLocalAddress()) 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 2e7b1d8b32..735e23c096 100644 --- a/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java +++ b/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java @@ -40,15 +40,12 @@ public void testNullURI() } @Test - public void testLoopbackIPv4Blocked() + public void testLoopbackAllowedForSelfDereferencing() { - assertThrows(InternalURLException.class, () -> new URLValidator(false).validate(URI.create("http://127.0.0.1:3030/ds"))); - } - - @Test - public void testLoopbackHostnameBlocked() - { - assertThrows(InternalURLException.class, () -> new URLValidator(false).validate(URI.create("http://localhost:3030/ds"))); + // 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