Skip to content

Commit 745fc5d

Browse files
committed
Extracted URLValidator class
Improved its tests
1 parent 32ecba7 commit 745fc5d

File tree

12 files changed

+321
-309
lines changed

12 files changed

+321
-309
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL"
5+
initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL"
6+
purge_cache "$END_USER_VARNISH_SERVICE"
7+
purge_cache "$ADMIN_VARNISH_SERVICE"
8+
purge_cache "$FRONTEND_VARNISH_SERVICE"
9+
10+
# Test SSRF protection: package-uri with link-local address (169.254.0.0/16) should return 400 Bad Request
11+
curl -k -w "%{http_code}\n" -o /dev/null -s \
12+
-E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
13+
-X POST \
14+
-H "Content-Type: application/x-www-form-urlencoded" \
15+
--data-urlencode "package-uri=http://169.254.1.1/package#this" \
16+
"${ADMIN_BASE_URL}packages/install" \
17+
| grep -q "$STATUS_BAD_REQUEST"
18+
19+
# Test SSRF protection: package-uri with private class A address (10.0.0.0/8) should return 400 Bad Request
20+
curl -k -w "%{http_code}\n" -o /dev/null -s \
21+
-E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
22+
-X POST \
23+
-H "Content-Type: application/x-www-form-urlencoded" \
24+
--data-urlencode "package-uri=http://10.0.0.1/package#this" \
25+
"${ADMIN_BASE_URL}packages/install" \
26+
| grep -q "$STATUS_BAD_REQUEST"
27+
28+
# Test SSRF protection: package-uri with private class B address (172.16.0.0/12) should return 400 Bad Request
29+
curl -k -w "%{http_code}\n" -o /dev/null -s \
30+
-E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
31+
-X POST \
32+
-H "Content-Type: application/x-www-form-urlencoded" \
33+
--data-urlencode "package-uri=http://172.16.0.0/package#this" \
34+
"${ADMIN_BASE_URL}packages/install" \
35+
| grep -q "$STATUS_BAD_REQUEST"
36+
37+
# Test SSRF protection: package-uri with private class C address (192.168.0.0/16) should return 400 Bad Request
38+
curl -k -w "%{http_code}\n" -o /dev/null -s \
39+
-E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
40+
-X POST \
41+
-H "Content-Type: application/x-www-form-urlencoded" \
42+
--data-urlencode "package-uri=http://192.168.1.1/package#this" \
43+
"${ADMIN_BASE_URL}packages/install" \
44+
| grep -q "$STATUS_BAD_REQUEST"

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import com.atomgraph.linkeddatahub.server.mapper.ResourceExistsExceptionMapper;
2020
import com.atomgraph.linkeddatahub.server.mapper.HttpHostConnectExceptionMapper;
21+
import com.atomgraph.linkeddatahub.server.mapper.InternalURLExceptionMapper;
2122
import com.atomgraph.linkeddatahub.server.mapper.MessagingExceptionMapper;
2223
import com.atomgraph.linkeddatahub.server.mapper.auth.webid.WebIDLoadingExceptionMapper;
2324
import com.atomgraph.linkeddatahub.server.mapper.auth.webid.InvalidWebIDURIExceptionMapper;
@@ -1090,6 +1091,7 @@ protected void registerExceptionMappers()
10901091
register(NotAcceptableExceptionMapper.class);
10911092
register(ClientErrorExceptionMapper.class);
10921093
register(HttpHostConnectExceptionMapper.class);
1094+
register(InternalURLExceptionMapper.class);
10931095
register(BadGatewayExceptionMapper.class);
10941096
register(OntClassNotFoundExceptionMapper.class);
10951097
register(InvalidWebIDPublicKeyExceptionMapper.class);

src/main/java/com/atomgraph/linkeddatahub/resource/Transform.java

Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,11 @@
2323
import com.atomgraph.linkeddatahub.server.io.ValidatingModelProvider;
2424
import com.atomgraph.linkeddatahub.server.model.impl.DirectGraphStoreImpl;
2525
import com.atomgraph.linkeddatahub.server.security.AgentContext;
26+
import com.atomgraph.linkeddatahub.server.util.URLValidator;
2627
import com.atomgraph.linkeddatahub.vocabulary.NFO;
2728
import com.atomgraph.spinrdf.vocabulary.SPIN;
28-
import java.net.InetAddress;
2929
import java.net.URI;
3030
import java.net.URISyntaxException;
31-
import java.net.UnknownHostException;
3231
import java.util.Map;
3332
import java.util.Optional;
3433
import jakarta.inject.Inject;
@@ -144,8 +143,8 @@ public Response post(Model model)
144143
if (queryRes == null) throw new BadRequestException("Transformation query string (spin:query) not provided");
145144

146145
// LNK-002: Validate URIs to prevent SSRF attacks
147-
validateNotInternalURL(URI.create(queryRes.getURI()));
148-
validateNotInternalURL(URI.create(source.getURI()));
146+
new URLValidator(URI.create(queryRes.getURI())).validate();
147+
new URLValidator(URI.create(source.getURI())).validate();
149148

150149
GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()).
151150
delegation(getUriInfo().getBaseUri(), getAgentContext().orElse(null));
@@ -236,7 +235,7 @@ public Response postFileBodyPart(Model model, Map<String, FormDataBodyPart> file
236235
if (queryRes == null) throw new BadRequestException("Transformation query string (spin:query) not provided");
237236

238237
// LNK-002: Validate query URI to prevent SSRF attacks
239-
validateNotInternalURL(URI.create(queryRes.getURI()));
238+
new URLValidator(URI.create(queryRes.getURI())).validate();
240239

241240
GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()).
242241
delegation(getUriInfo().getBaseUri(), getAgentContext().orElse(null));
@@ -278,42 +277,6 @@ protected Response forwardPost(Entity entity, String graphURI)
278277
}
279278
}
280279

281-
/**
282-
* Validates that the given URI does not point to an internal/private network address.
283-
* Prevents SSRF attacks by blocking access to RFC 1918 private addresses and link-local addresses.
284-
*
285-
* @param uri the URI to validate
286-
* @throws IllegalArgumentException if URI or host is null
287-
* @throws BadRequestException if the URI resolves to an internal address
288-
* @see <a href="https://github.com/AtomGraph/LinkedDataHub/issues/253">LNK-002: SSRF primitives in admin endpoint</a>
289-
*/
290-
protected static void validateNotInternalURL(URI uri)
291-
{
292-
if (uri == null) throw new IllegalArgumentException("URI cannot be null");
293-
294-
String host = uri.getHost();
295-
if (host == null) throw new IllegalArgumentException("URI host cannot be null");
296-
297-
// Resolve hostname to IP and check if it's private/internal
298-
try
299-
{
300-
InetAddress address = InetAddress.getByName(host);
301-
302-
// Note: We don't block loopback addresses (127.0.0.1, localhost) because transformation queries
303-
// and data sources may legitimately reference resources on the same server
304-
305-
if (address.isLinkLocalAddress())
306-
throw new BadRequestException("URI cannot resolve to link-local addresses: " + address.getHostAddress());
307-
if (address.isSiteLocalAddress())
308-
throw new BadRequestException("URI cannot resolve to private addresses (RFC 1918): " + address.getHostAddress());
309-
}
310-
catch (UnknownHostException e)
311-
{
312-
if (log.isWarnEnabled()) log.warn("Could not resolve hostname for SSRF validation: {}", host);
313-
// Allow request to proceed - will fail later with better error message
314-
}
315-
}
316-
317280
/**
318281
* Returns the supported media types.
319282
*

src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.atomgraph.linkeddatahub.client.GraphStoreClient;
2424
import com.atomgraph.linkeddatahub.resource.admin.ClearOntology;
2525
import com.atomgraph.linkeddatahub.server.security.AgentContext;
26+
import com.atomgraph.linkeddatahub.server.util.URLValidator;
2627
import com.atomgraph.linkeddatahub.server.util.XSLTMasterUpdater;
2728
import static com.atomgraph.server.status.UnprocessableEntityStatus.UNPROCESSABLE_ENTITY;
2829
import jakarta.inject.Inject;
@@ -131,6 +132,9 @@ public Response post(@FormParam("package-uri") String packageURI, @HeaderParam("
131132
throw new BadRequestException("Package URI not specified");
132133
}
133134

135+
// Validate package URI to prevent SSRF attacks
136+
new URLValidator(URI.create(packageURI)).validate();
137+
134138
if (log.isInfoEnabled()) log.info("Installing package: {}", packageURI);
135139
com.atomgraph.linkeddatahub.apps.model.Package pkg = getPackage(packageURI);
136140
if (pkg == null)
@@ -155,6 +159,9 @@ public Response post(@FormParam("package-uri") String packageURI, @HeaderParam("
155159

156160
if (ontology != null)
157161
{
162+
// Validate ontology URI to prevent SSRF attacks
163+
new URLValidator(URI.create(ontology.getURI())).validate();
164+
158165
if (log.isDebugEnabled()) log.debug("Downloading package ontology from: {}", ontology.getURI());
159166
Model ontologyModel = downloadOntology(ontology.getURI());
160167

@@ -166,6 +173,9 @@ public Response post(@FormParam("package-uri") String packageURI, @HeaderParam("
166173
URI stylesheetURI = URI.create(stylesheet.getURI());
167174
String packagePath = pkg.getStylesheetPath();
168175

176+
// Validate stylesheet URI to prevent SSRF attacks
177+
new URLValidator(stylesheetURI).validate();
178+
169179
if (log.isDebugEnabled()) log.debug("Downloading package stylesheet from: {}", stylesheetURI);
170180
String stylesheetContent = downloadStylesheet(stylesheetURI);
171181

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Copyright 2025 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.exception;
18+
19+
import java.net.URI;
20+
21+
/**
22+
* Exception thrown when attempting to load data from an internal/private network address.
23+
* This is part of SSRF (Server-Side Request Forgery) attack prevention.
24+
*
25+
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
26+
* @see <a href="https://github.com/AtomGraph/LinkedDataHub/issues/252">LNK-004: SSRF primitive via On-Behalf-Of header</a>
27+
* @see <a href="https://github.com/AtomGraph/LinkedDataHub/issues/253">LNK-002: SSRF primitives in admin endpoint</a>
28+
* @see <a href="https://github.com/AtomGraph/LinkedDataHub/issues/287">LNK-009: SSRF via proxy URI parameter</a>
29+
*/
30+
public class InternalURLException extends RuntimeException
31+
{
32+
33+
/** The URI that resolves to an internal address */
34+
private final URI uri;
35+
36+
/** The resolved IP address */
37+
private final String ipAddress;
38+
39+
/**
40+
* Constructs exception for link-local address.
41+
*
42+
* @param uri the URI that resolves to a link-local address
43+
* @param ipAddress the resolved link-local IP address
44+
*/
45+
public InternalURLException(URI uri, String ipAddress)
46+
{
47+
super("URL cannot resolve to internal addresses: " + ipAddress);
48+
this.uri = uri;
49+
this.ipAddress = ipAddress;
50+
}
51+
52+
/**
53+
* Returns the URI that resolves to an internal address.
54+
*
55+
* @return the URI
56+
*/
57+
public URI getURI()
58+
{
59+
return uri;
60+
}
61+
62+
/**
63+
* Returns the resolved IP address.
64+
*
65+
* @return the IP address
66+
*/
67+
public String getIPAddress()
68+
{
69+
return ipAddress;
70+
}
71+
72+
}

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

Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,12 @@
2525
import com.atomgraph.linkeddatahub.server.exception.auth.webid.WebIDLoadingException;
2626
import com.atomgraph.linkeddatahub.server.exception.auth.webid.WebIDDelegationException;
2727
import com.atomgraph.linkeddatahub.server.security.WebIDSecurityContext;
28+
import com.atomgraph.linkeddatahub.server.util.URLValidator;
2829
import com.atomgraph.linkeddatahub.vocabulary.ACL;
2930
import com.atomgraph.linkeddatahub.vocabulary.Cert;
3031
import com.atomgraph.linkeddatahub.vocabulary.FOAF;
31-
import java.net.InetAddress;
3232
import java.net.URI;
3333
import java.net.URISyntaxException;
34-
import java.net.UnknownHostException;
3534
import java.security.cert.CertificateException;
3635
import java.security.cert.CertificateParsingException;
3736
import java.security.cert.X509Certificate;
@@ -130,7 +129,7 @@ public SecurityContext authenticate(ContainerRequestContext request)
130129
}
131130
if (log.isTraceEnabled()) log.trace("Client WebID: {}", webID);
132131

133-
validateNotInternalURL(webID); // LNK-004: Prevent SSRF via WebID URI
132+
new URLValidator(webID).validate(); // LNK-004: Prevent SSRF via WebID URI
134133
Resource agent = authenticate(loadWebID(webID), webID, publicKey);
135134
if (agent == null)
136135
{
@@ -143,7 +142,7 @@ public SecurityContext authenticate(ContainerRequestContext request)
143142
if (onBehalfOf != null)
144143
{
145144
URI principalWebID = new URI(onBehalfOf);
146-
validateNotInternalURL(principalWebID); // LNK-004: Prevent SSRF via On-Behalf-Of header
145+
new URLValidator(principalWebID).validate(); // LNK-004: Prevent SSRF via On-Behalf-Of header
147146
Model principalWebIDModel = loadWebID(principalWebID);
148147
Resource principal = principalWebIDModel.createResource(onBehalfOf);
149148
// if we verify that the current agent is a secretary of the principal, that principal becomes current agent. Else throw error
@@ -301,7 +300,7 @@ public Model loadWebIDFromURI(URI webID)
301300
if (certKeyRes != null && certKeyRes.isURIResource())
302301
{
303302
URI certKey = URI.create(certKeyRes.getURI());
304-
validateNotInternalURL(certKey); // LNK-004: Prevent SSRF via cert:key reference in WebID document
303+
new URLValidator(certKey).validate(); // LNK-004: Prevent SSRF via cert:key reference in WebID document
305304
// remove fragment identifier to get document URI
306305
URI certKeyDoc = new URI(certKey.getScheme(), certKey.getSchemeSpecificPart(), null).normalize();
307306

@@ -383,40 +382,4 @@ public void logout(Application app, ContainerRequestContext request)
383382
throw new UnsupportedOperationException("Not supported yet."); // logout not really possible with HTTP certificates
384383
}
385384

386-
/**
387-
* Validates that the given URI does not point to an internal/private network address.
388-
* Prevents SSRF attacks by blocking access to RFC 1918 private addresses and link-local addresses.
389-
*
390-
* @param uri the URI to validate
391-
* @throws IllegalArgumentException if URI or host is null
392-
* @throws BadRequestException if the URI resolves to an internal address
393-
* @see <a href="https://github.com/AtomGraph/LinkedDataHub/issues/252">LNK-004: SSRF primitive via On-Behalf-Of header</a>
394-
*/
395-
protected static void validateNotInternalURL(URI uri)
396-
{
397-
if (uri == null) throw new IllegalArgumentException("URI cannot be null");
398-
399-
String host = uri.getHost();
400-
if (host == null) throw new IllegalArgumentException("URI host cannot be null");
401-
402-
// Resolve hostname to IP and check if it's private/internal
403-
try
404-
{
405-
InetAddress address = InetAddress.getByName(host);
406-
407-
// Note: We don't block loopback addresses (127.0.0.1, localhost) because WebID documents
408-
// may legitimately be hosted on the same server during development/testing
409-
410-
if (address.isLinkLocalAddress())
411-
throw new BadRequestException("WebID URI cannot resolve to link-local addresses: " + address.getHostAddress());
412-
if (address.isSiteLocalAddress())
413-
throw new BadRequestException("WebID URI cannot resolve to private addresses (RFC 1918): " + address.getHostAddress());
414-
}
415-
catch (UnknownHostException e)
416-
{
417-
if (log.isWarnEnabled()) log.warn("Could not resolve hostname for SSRF validation: {}", host);
418-
// Allow request to proceed - will fail later with better error message
419-
}
420-
}
421-
422385
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Copyright 2025 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.mapper;
18+
19+
import com.atomgraph.core.MediaTypes;
20+
import com.atomgraph.linkeddatahub.server.exception.InternalURLException;
21+
import com.atomgraph.server.mapper.ExceptionMapperBase;
22+
import jakarta.inject.Inject;
23+
import jakarta.ws.rs.core.Response;
24+
import jakarta.ws.rs.ext.ExceptionMapper;
25+
import org.apache.jena.rdf.model.Resource;
26+
import org.apache.jena.rdf.model.ResourceFactory;
27+
28+
/**
29+
* JAX-RS mapper for internal URL exceptions.
30+
* Maps {@link InternalURLException} to HTTP 400 Bad Request response.
31+
*
32+
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
33+
*/
34+
public class InternalURLExceptionMapper extends ExceptionMapperBase implements ExceptionMapper<InternalURLException>
35+
{
36+
37+
/**
38+
* Constructs exception mapper from media type registry.
39+
*
40+
* @param mediaTypes registry of readable/writable media types
41+
*/
42+
@Inject
43+
public InternalURLExceptionMapper(MediaTypes mediaTypes)
44+
{
45+
super(mediaTypes);
46+
}
47+
48+
@Override
49+
public Response toResponse(InternalURLException ex)
50+
{
51+
Resource resource = toResource(ex, Response.Status.BAD_REQUEST,
52+
ResourceFactory.createResource("http://www.w3.org/2011/http-statusCodes#BadRequest"));
53+
54+
return getResponseBuilder(resource.getModel()).
55+
status(Response.Status.BAD_REQUEST).
56+
build();
57+
}
58+
59+
}

0 commit comments

Comments
 (0)