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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Bootstrap 2 is gone, and with it the class vocabulary application stylesheets we

### Security
- A SPARQL `SERVICE` clause in a query to `/sparql` could read the admin store — agents, authorizations — because the triplestore executes it from inside the stack. The triplestores' outbound requests now go through an `egress` Squid proxy that refuses loopback, private and link-local destinations, so federation with public endpoints keeps working. Deployments with their own compose files need the `egress` service and the stores' `JAVA_TOOL_OPTIONS`, including the empty `http.nonProxyHosts`
- A `SERVICE` clause in a PATCH update or an import mapping runs in the platform's own JVM, which the triplestores' egress proxy does not front, so it could still reach an internal service. The platform now routes its own `SERVICE` requests through the egress proxy too (`EGRESS_PROXY`, default `egress:3128`, set on the global ARQ context so only `SERVICE` is affected) — federation with public endpoints keeps working, internal addresses are refused, and `ProxySelector` covers loopback as well. With no proxy configured and `ALLOW_INTERNAL_URLS` unset, in-JVM `SERVICE` is disabled rather than left open
- CSV/RDF imports now validate the `ldh:file` source and `spin:query` URIs via `URLValidator` before fetching, closing a previously unguarded SSRF surface on the import path (same class as LNK-002; the import client carries delegation/client-cert). Loopback stays allowed; `ALLOW_INTERNAL_URLS` remains the escape hatch

### Known limitations
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ services:
- fuseki-admin
- fuseki-end-user
- sef-compiler
- egress
environment:
# - JPDA_ADDRESS=*:8000 # debugger host - performance hit when enabled
- CATALINA_OPTS=-XX:+UseContainerSupport -XX:MaxRAMPercentage=75 --add-exports java.base/sun.security.tools.keytool=ALL-UNNAMED # heap will use up to 75% of container's RAM
Expand All @@ -66,6 +67,8 @@ services:
- SIGN_UP_CERT_VALIDITY=180
- MAX_CONTENT_LENGTH=${MAX_CONTENT_LENGTH:-2097152}
- ALLOW_INTERNAL_URLS=${ALLOW_INTERNAL_URLS:-}
- EGRESS_PROXY=${EGRESS_PROXY:-egress:3128} # the platform's own SPARQL SERVICE (PATCH, imports) goes through egress too

- CONNECTION_REQUEST_TIMEOUT=${CONNECTION_REQUEST_TIMEOUT:-}
- NOTIFICATION_ADDRESS=LinkedDataHub <notifications@localhost>
- MAIL_SMTP_HOST=email-server
Expand Down
73 changes: 73 additions & 0 deletions http-tests/document-hierarchy/PATCH-service-internal.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail

initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL"
initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL"
purge_cache "$END_USER_VARNISH_SERVICE"
purge_cache "$ADMIN_VARNISH_SERVICE"
purge_cache "$FRONTEND_VARNISH_SERVICE"

# A PATCH update is executed by the platform itself, on the document's graph in memory, so a SERVICE clause in its
# WHERE is fetched from inside the platform container rather than from the triplestore. It must not reach the internal
# admin store: a writer could otherwise copy agents and authorizations into a document they can read.
# SERVICE SILENT turns a refused call into a single empty solution, which leaves ?g unbound and inserts nothing, so the
# document is checked for data rather than the response for a status code

# add agent to the writers group

ldh admin add agent \
-f "$OWNER_CERT_KEYSTORE" \
-p "$OWNER_CERT_PWD" \
--agent "$AGENT_URI" \
"${ADMIN_BASE_URL}acl/groups/writers/"

# create an item to patch

item=$(ldh create item \
-f "$AGENT_CERT_KEYSTORE" \
-p "$AGENT_CERT_PWD" \
-b "$END_USER_BASE_URL" \
--title "SERVICE PATCH target" \
--container "$END_USER_BASE_URL")

endpoint="http://fuseki-admin:3030/ds/"

update=$(cat <<EOF
INSERT
{
<${item}> <urn:test:leak> ?g
}
WHERE
{
SERVICE SILENT <${endpoint}>
{
SELECT ?g
{
GRAPH ?g { ?s ?p ?o }
}
LIMIT 1
}
}
EOF
)

curl -k -f -s -o /dev/null \
-E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
-X PATCH \
-H "Content-Type: application/sparql-update" \
"$item" \
--data-binary "$update"

# check that nothing from the admin store was inserted

document=$(curl -k -f -s -G \
-E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
-H "Accept: application/n-triples" \
"$item")

echo "DEBUG: ${item} after PATCH: ${document}"

if grep -q '<urn:test:leak>' <<< "$document"; then
echo "SERVICE <${endpoint}> in a PATCH returned data"
exit 1
fi
83 changes: 83 additions & 0 deletions http-tests/imports/import-rdf-service-internal.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail

initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL"
initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL"
purge_cache "$END_USER_VARNISH_SERVICE"
purge_cache "$ADMIN_VARNISH_SERVICE"
purge_cache "$FRONTEND_VARNISH_SERVICE"

# An import's mapping query is executed by the platform itself, so a SERVICE clause in it is fetched from inside the
# platform container rather than from the triplestore. It must not reach the internal admin store: whoever can create
# an import could otherwise copy agents and authorizations into the documents it creates.
# rdf-service-internal.rq always constructs the item, and gives its leak triple a value only if the admin store answered

pwd=$(realpath "$PWD")

# add agent to the writers group

ldh admin add agent \
-f "$OWNER_CERT_KEYSTORE" \
-p "$OWNER_CERT_PWD" \
--agent "$AGENT_URI" \
"${ADMIN_BASE_URL}acl/groups/writers/"

# create import item

item=$(ldh create item \
-f "$AGENT_CERT_KEYSTORE" \
-p "$AGENT_CERT_PWD" \
-b "$END_USER_BASE_URL" \
--title "RDF import with SERVICE" \
--container "$END_USER_BASE_URL")

# create target container

container=$(ldh create container \
-f "$AGENT_CERT_KEYSTORE" \
-p "$AGENT_CERT_PWD" \
-b "$END_USER_BASE_URL" \
--title "SERVICE import" \
--slug "service-internal" \
--parent "$END_USER_BASE_URL")

# import RDF with the mapping query that uses SERVICE

ldh import rdf \
-f "$AGENT_CERT_KEYSTORE" \
-p "$AGENT_CERT_PWD" \
-b "$END_USER_BASE_URL" \
--title "Test" \
--query-file "$pwd/rdf-service-internal.rq" \
--rdf-file "$pwd/test.ttl" \
--content-type "text/turtle" \
"$item"

imported="${container}item/"

# wait until the imported item appears (since import is executed asynchronously)

counter=20
i=0

while [ "$i" -lt "$counter" ] && ! curl -k -s -f -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" "$imported" -H "Accept: application/n-triples" >/dev/null 2>&1
do
sleep 1 ;
i=$(( i+1 ))

echo "Waited ${i}s..."
done

document=$(curl -k -f -s \
-E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
-H "Accept: application/n-triples" \
"$imported") || { echo "The imported item ${imported} did not appear"; exit 1; }

echo "DEBUG: ${imported}: ${document}"

# check that nothing from the admin store was imported

if grep -q '<urn:test:leak>' <<< "$document"; then
echo "SERVICE <http://fuseki-admin:3030/ds/> in an import mapping returned data"
exit 1
fi
34 changes: 34 additions & 0 deletions http-tests/imports/rdf-service-internal.rq
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
PREFIX dh: <https://www.w3.org/ns/ldt/document-hierarchy#>
PREFIX sioc: <http://rdfs.org/sioc/ns#>
PREFIX dct: <http://purl.org/dc/terms/>

# An import mapping that reaches for the internal admin store through SERVICE. The item is always constructed; the
# leak triple only gets a value if the admin store answered, since SERVICE SILENT otherwise yields one empty solution

CONSTRUCT
{
GRAPH ?item
{
?item a dh:Item ;
sioc:has_container ?container ;
dct:title "SERVICE import target" ;
<urn:test:leak> ?g .
}
}
WHERE
{
BIND (uri(concat(str($base), "service-internal/")) AS ?container)
BIND (uri(concat(str(?container), "item/")) AS ?item)

OPTIONAL
{
SERVICE SILENT <http://fuseki-admin:3030/ds/>
{
SELECT ?g
{
GRAPH ?g { ?s ?p ?o }
}
LIMIT 1
}
}
}
6 changes: 6 additions & 0 deletions platform/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,12 @@ if [ -n "$ALLOW_INTERNAL_URLS" ]; then
export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.allowInternalUrls=$ALLOW_INTERNAL_URLS"
fi

# host:port of the egress forward proxy that the platform's in-JVM SPARQL SERVICE requests (PATCH, imports) go through;
# without it SERVICE is disabled in those paths (unless ALLOW_INTERNAL_URLS)
if [ -n "$EGRESS_PROXY" ]; then
export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.egressProxy=$EGRESS_PROXY"
fi

if [ -n "$CONNECTION_REQUEST_TIMEOUT" ]; then
export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.connectionRequestTimeout=$CONNECTION_REQUEST_TIMEOUT"
fi
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/com/atomgraph/linkeddatahub/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,11 @@
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.ProxySelector;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
Expand All @@ -165,6 +168,7 @@
import javax.xml.transform.Source;
import org.apache.jena.ontapi.UnionGraph;
import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.query.ARQ;
import org.apache.jena.query.Dataset;
import org.apache.jena.query.Query;
import org.apache.jena.query.QueryExecution;
Expand Down Expand Up @@ -592,6 +596,7 @@ public Application(final ServletConfig servletConfig, final MediaTypes mediaType
this.enableLinkedDataProxy = enableLinkedDataProxy;
this.allowInternalUrls = allowInternalUrls;
this.urlValidator = new URLValidator(allowInternalUrls);
configureServiceExecution(allowInternalUrls);
this.maxContentLength = maxContentLength;
this.invalidateCache = invalidateCache;
this.enableWebIDSignUp = enableWebIDSignUp;
Expand Down Expand Up @@ -964,6 +969,46 @@ protected PasswordAuthentication getPasswordAuthentication()
}
}

/**
* Restricts SPARQL <code>SERVICE</code> execution in the platform's own JVM (PATCH updates and import mappings run
* their queries here, in-process, so the triplestore's egress proxy never sees them). Without this, a
* <code>SERVICE</code> clause a writer supplies could reach an internal service — the admin store, Varnish — and copy
* data the ACL never grants into a document. Consistent with the SSRF model of {@link URLValidator}: with
* {@code allowInternalUrls} the check is off entirely; otherwise, if an egress proxy is configured
* (system property {@code com.atomgraph.linkeddatahub.egressProxy}, {@code host:port}), <code>SERVICE</code> requests
* go through it — so public federation still works while internal addresses are refused, redirect hops and DNS
* answers included — and if no proxy is configured, <code>SERVICE</code> is disabled outright.
*
* Applies only to <code>SERVICE</code> execution: it is set on the global ARQ context, which the in-JVM query/update
* execution reads, and the platform's own SPARQL calls to its stores use their own HTTP clients, not this one.
*
* @param allowInternalUrls true if SSRF protection is disabled for this deployment
*/
protected final void configureServiceExecution(boolean allowInternalUrls)
{
if (allowInternalUrls) return; // SSRF protection disabled; leave SERVICE unrestricted

String egressProxy = System.getProperty("com.atomgraph.linkeddatahub.egressProxy");
if (egressProxy != null && !egressProxy.isBlank())
{
URI proxyURI = URI.create(egressProxy.contains("://") ? egressProxy : "http://" + egressProxy);
int port = proxyURI.getPort() != -1 ? proxyURI.getPort() : 3128;
// ProxySelector.of() proxies every request, localhost included, so SERVICE cannot bypass the proxy the way
// the JVM-wide http.nonProxyHosts default would let it
HttpClient serviceClient = HttpClient.newBuilder().
proxy(ProxySelector.of(new InetSocketAddress(proxyURI.getHost(), port))).
followRedirects(HttpClient.Redirect.NORMAL).
build();
ARQ.getContext().set(org.apache.jena.sparql.exec.http.Service.httpQueryClient, serviceClient);
if (log.isDebugEnabled()) log.debug("SPARQL SERVICE requests routed through egress proxy: {}", proxyURI);
}
else
{
ARQ.getContext().setFalse(org.apache.jena.sparql.exec.http.Service.httpServiceAllowed);
if (log.isWarnEnabled()) log.warn("SPARQL SERVICE execution disabled (no egress proxy configured); set com.atomgraph.linkeddatahub.egressProxy to allow federation");
}
}

/**
* Post-construct initialization.
* Additional initialization (e.g. registering JAX-RS providers and factories) that cannot be cleanly done in the class constructor.
Expand Down
Loading