Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.19.4</version>
<version>4.5.2</version>
</dependency>
<dependency>
<groupId>net.jodah</groupId>
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/atomgraph/linkeddatahub/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -1519,6 +1519,7 @@ public static Client getClient(KeyStore keyStore, String keyStorePassword, KeySt
ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);

Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>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();
Expand Down Expand Up @@ -1626,6 +1627,7 @@ public static Client getNoCertClient(KeyStore trustStore, Integer maxConnPerRout
ctx.init(null, tmf.getTrustManagers(), null);

Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Copyright 2026 Martynas Jusevičius <martynas@atomgraph.com>
*
* 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 <martynas@atomgraph.com>}
* @see <a href="https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html">OWASP XXE Prevention</a>
*/
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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
Loading
Loading