Skip to content

Commit 4c44750

Browse files
committed
Merge branch 'develop' into near-term-wins
# Conflicts: # CHANGELOG.md
2 parents fdb817d + b5bd857 commit 4c44750

11 files changed

Lines changed: 297 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
## [Unreleased]
2+
### Security
3+
- 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)
4+
- 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)
5+
- Upgraded `java-jwt` 3.19.4 → 4.5.2 on the OAuth2/OIDC verification path
6+
- Documented the pinned-truststore invariant behind the disabled hostname verification on internal HTTP clients
7+
28
### Added
39
- Unit tests for `AuthorizationFilter`: the HTTP-method → ACL access-mode contract (`GET`/`HEAD`→Read, `POST`→Append, `PUT`/`DELETE`/`PATCH`→Write), mode lookup, and the owner Read/Write/Append grant
10+
- Loopback/wildcard `URLValidator` tests; JWKS-based `JWTVerifier` tests (valid, wrong issuer, wrong audience, expired, missing `kid`, bad signature)
411
- `AGENTS.md`: an agent-facing guide to driving a running instance's HTTP API — data model, WebID auth, read/write discipline (writes via `POST`/`PUT`/`PATCH` on document URLs; read-only SPARQL), content model, dataspaces, tooling
512
- Dependabot config (`.github/dependabot.yml`) for Maven, the Docker base image, and GitHub Actions updates; routine Maven minor/patch bumps grouped into one PR
613

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@
181181
<dependency>
182182
<groupId>com.auth0</groupId>
183183
<artifactId>java-jwt</artifactId>
184-
<version>3.19.4</version>
184+
<version>4.5.2</version>
185185
</dependency>
186186
<dependency>
187187
<groupId>net.jodah</groupId>

src/main/java/com/atomgraph/linkeddatahub/Application.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,7 @@ public static Client getClient(KeyStore keyStore, String keyStorePassword, KeySt
15191519
ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
15201520

15211521
Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create().
1522+
// hostname verification is safely disabled because ctx trusts only the pinned truststore; revisit if that truststore ever widens to public CAs
15221523
register("https", new SSLConnectionSocketFactory(ctx, NoopHostnameVerifier.INSTANCE)).
15231524
register("http", new PlainConnectionSocketFactory()).
15241525
build();
@@ -1626,6 +1627,7 @@ public static Client getNoCertClient(KeyStore trustStore, Integer maxConnPerRout
16261627
ctx.init(null, tmf.getTrustManagers(), null);
16271628

16281629
Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create().
1630+
// hostname verification is safely disabled because ctx trusts only the pinned truststore; revisit if that truststore ever widens to public CAs
16291631
register("https", new SSLConnectionSocketFactory(ctx, NoopHostnameVerifier.INSTANCE)).
16301632
register("http", new PlainConnectionSocketFactory()).
16311633
build();

src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/IDTokenFilterBase.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ public SecurityContext authenticate(ContainerRequestContext request)
195195
else
196196
{
197197
if (log.isDebugEnabled()) log.debug("ID token for subject '{}' has expired at {}, refresh token not found", jwt.getSubject(), jwt.getExpiresAt());
198-
throw new TokenExpiredException("ID token for subject '%s' has expired at %s".formatted(jwt.getSubject(), jwt.getExpiresAt()));
198+
throw new TokenExpiredException("ID token for subject '%s' has expired at %s".formatted(jwt.getSubject(), jwt.getExpiresAt()), jwt.getExpiresAt().toInstant());
199199
}
200200
}
201201
if (!verify(jwt)) return null;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Copyright 2026 Martynas Jusevičius <martynas@atomgraph.com>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
package com.atomgraph.linkeddatahub.server.util;
18+
19+
import javax.xml.XMLConstants;
20+
import javax.xml.parsers.DocumentBuilderFactory;
21+
import javax.xml.parsers.ParserConfigurationException;
22+
import javax.xml.parsers.SAXParserFactory;
23+
import org.xml.sax.SAXException;
24+
import org.xml.sax.XMLReader;
25+
26+
/**
27+
* Factory helpers for XML parsers hardened against XXE and entity-expansion (billion laughs) attacks.
28+
*
29+
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
30+
* @see <a href="https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html">OWASP XXE Prevention</a>
31+
*/
32+
public final class SecureXML
33+
{
34+
35+
private SecureXML()
36+
{
37+
}
38+
39+
/**
40+
* Returns a namespace-aware {@link DocumentBuilderFactory} with DTDs and external entities disabled.
41+
* Suitable for parsing trusted internal XML (e.g. stylesheets) that never carries a DOCTYPE.
42+
*
43+
* @return hardened document builder factory
44+
* @throws ParserConfigurationException if a feature cannot be set
45+
*/
46+
public static DocumentBuilderFactory newDocumentBuilderFactory() throws ParserConfigurationException
47+
{
48+
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
49+
factory.setNamespaceAware(true);
50+
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
51+
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
52+
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
53+
factory.setXIncludeAware(false);
54+
factory.setExpandEntityReferences(false);
55+
return factory;
56+
}
57+
58+
/**
59+
* Returns an {@link XMLReader} hardened for parsing untrusted external content.
60+
* Secure processing caps entity expansion (billion laughs) and external entities are disabled,
61+
* while a benign internal DOCTYPE (e.g. XHTML) is still tolerated.
62+
*
63+
* @return hardened XML reader
64+
* @throws ParserConfigurationException if a feature cannot be set
65+
* @throws SAXException if the reader cannot be created
66+
*/
67+
public static XMLReader newXMLReader() throws ParserConfigurationException, SAXException
68+
{
69+
SAXParserFactory factory = SAXParserFactory.newInstance();
70+
factory.setNamespaceAware(true);
71+
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
72+
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
73+
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
74+
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
75+
return factory.newSAXParser().getXMLReader();
76+
}
77+
78+
}

src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,17 @@ public URLValidator(boolean allowInternal)
5252
/**
5353
* Validates that the URI does not point to an internal/private network address.
5454
* Prevents SSRF attacks by blocking access to:
55-
* - RFC 1918 private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
55+
* - Wildcard/any-local addresses (0.0.0.0, ::)
56+
* - RFC 1918 private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fec0::/10)
5657
* - Link-local addresses (169.254.0.0/16, fe80::/10)
5758
*
58-
* Note: Loopback addresses (127.0.0.1, localhost, ::1) are NOT blocked as the application
59-
* may legitimately need to access resources on the same server (e.g., transformation queries,
60-
* WebID documents during development, admin operations).
59+
* All addresses the host resolves to are checked (not just the first), narrowing the DNS-rebinding
60+
* window where a host publishes both a public and an internal address.
61+
*
62+
* Loopback addresses (127.0.0.0/8, ::1) are intentionally NOT blocked: LinkedDataHub dereferences
63+
* its own documents and WebIDs on the same origin (which is loopback in local/test deployments), while
64+
* the backend triplestore is reached over a private/site-local address (blocked above), not loopback.
65+
* The {@code ALLOW_INTERNAL_URLS} escape hatch disables all checks for fully-internal deployments.
6166
*
6267
* @param uri the URI to validate
6368
* @return the validated URI
@@ -73,18 +78,18 @@ public URI validate(URI uri)
7378
String host = uri.getHost();
7479
if (host == null) throw new IllegalArgumentException("URI host cannot be null");
7580

76-
// Resolve hostname to IP and check if it's private/internal
81+
// Resolve hostname to all IPs and reject if any is wildcard/private/internal (loopback intentionally allowed)
7782
try
7883
{
79-
InetAddress address = InetAddress.getByName(host);
80-
81-
// Note: We don't block loopback addresses (127.0.0.1, localhost) because the application
82-
// legitimately accesses its own endpoints for various operations
83-
84-
if (address.isLinkLocalAddress())
85-
throw new InternalURLException(uri, address.getHostAddress());
86-
if (address.isSiteLocalAddress())
87-
throw new InternalURLException(uri, address.getHostAddress());
84+
for (InetAddress address : InetAddress.getAllByName(host))
85+
{
86+
if (address.isAnyLocalAddress())
87+
throw new InternalURLException(uri, address.getHostAddress());
88+
if (address.isLinkLocalAddress())
89+
throw new InternalURLException(uri, address.getHostAddress());
90+
if (address.isSiteLocalAddress())
91+
throw new InternalURLException(uri, address.getHostAddress());
92+
}
8893
}
8994
catch (UnknownHostException e)
9095
{

src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
import org.w3c.dom.Node;
2424
import org.w3c.dom.NodeList;
2525
import jakarta.servlet.ServletContext;
26+
import javax.xml.XMLConstants;
2627
import javax.xml.parsers.DocumentBuilder;
27-
import javax.xml.parsers.DocumentBuilderFactory;
2828
import javax.xml.transform.OutputKeys;
2929
import javax.xml.transform.Transformer;
3030
import javax.xml.transform.TransformerFactory;
@@ -203,15 +203,14 @@ public void removePackageImport(Path masterFile, String packagePath) throws IOEx
203203

204204
private Document parseDocument(Path file) throws ParserConfigurationException, SAXException, IOException
205205
{
206-
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
207-
factory.setNamespaceAware(true);
208-
DocumentBuilder builder = factory.newDocumentBuilder();
206+
DocumentBuilder builder = SecureXML.newDocumentBuilderFactory().newDocumentBuilder();
209207
return builder.parse(file.toFile());
210208
}
211209

212210
private void serializeDocument(Document doc, Path file) throws TransformerException
213211
{
214212
TransformerFactory transformerFactory = TransformerFactory.newInstance();
213+
transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
215214
Transformer transformer = transformerFactory.newTransformer();
216215
transformer.setOutputProperty(OutputKeys.INDENT, "no");
217216
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");

src/main/java/com/atomgraph/linkeddatahub/writer/function/SendHTTPRequest.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717
package com.atomgraph.linkeddatahub.writer.function;
1818

19+
import com.atomgraph.linkeddatahub.server.util.SecureXML;
1920
import com.atomgraph.linkeddatahub.vocabulary.LDH;
2021
import java.io.IOException;
2122
import java.io.InputStream;
@@ -25,7 +26,8 @@
2526
import jakarta.ws.rs.core.MultivaluedHashMap;
2627
import jakarta.ws.rs.core.MultivaluedMap;
2728
import jakarta.ws.rs.core.Response;
28-
import javax.xml.transform.stream.StreamSource;
29+
import javax.xml.parsers.ParserConfigurationException;
30+
import javax.xml.transform.sax.SAXSource;
2931
import net.sf.saxon.s9api.ExtensionFunction;
3032
import net.sf.saxon.s9api.ItemType;
3133
import net.sf.saxon.s9api.ItemTypeFactory;
@@ -38,6 +40,8 @@
3840
import net.sf.saxon.s9api.XdmValue;
3941
import org.slf4j.Logger;
4042
import org.slf4j.LoggerFactory;
43+
import org.xml.sax.InputSource;
44+
import org.xml.sax.SAXException;
4145

4246
/**
4347
* Executes an HTTP request.
@@ -117,7 +121,7 @@ public XdmValue call(XdmValue[] arguments) throws SaxonApiException
117121
if (cr.hasEntity())
118122
try (InputStream is = cr.readEntity(InputStream.class))
119123
{
120-
return getProcessor().newDocumentBuilder().build(new StreamSource(is));
124+
return getProcessor().newDocumentBuilder().build(new SAXSource(SecureXML.newXMLReader(), new InputSource(is)));
121125
}
122126
}
123127
else
@@ -135,14 +139,14 @@ public XdmValue call(XdmValue[] arguments) throws SaxonApiException
135139
if (cr.hasEntity())
136140
try (InputStream is = cr.readEntity(InputStream.class))
137141
{
138-
return getProcessor().newDocumentBuilder().build(new StreamSource(is));
142+
return getProcessor().newDocumentBuilder().build(new SAXSource(SecureXML.newXMLReader(), new InputSource(is)));
139143
}
140144
}
141145
}
142-
146+
143147
return XdmEmptySequence.getInstance();
144148
}
145-
catch (IOException ex)
149+
catch (IOException | ParserConfigurationException | SAXException ex)
146150
{
147151
throw new SaxonApiException(ex);
148152
}

src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/navigation.xsl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1355,7 +1355,7 @@ ORDER BY DESC(?created)
13551355
<xsl:choose>
13561356
<xsl:when test="$key-code = 'Enter'"/> <!-- handled by form-submit -->
13571357
<xsl:when test="string-length($text) gt 0">
1358-
<ixsl:schedule-action wait="$delay" document="ixsl:page()">
1358+
<ixsl:schedule-action wait="$delay">
13591359
<xsl:call-template name="ldh:SearchLoadDeferred">
13601360
<xsl:with-param name="input" select="."/>
13611361
<xsl:with-param name="text" select="$text"/>

0 commit comments

Comments
 (0)