From 72f21cd831e4c1bb281412dd7d5ea0494cfe6aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Thu, 17 Sep 2026 18:22:47 +0200 Subject: [PATCH 1/2] A SPARQL SERVICE clause in a PATCH update or an import mapping must not reach the deployment's internal services, and two HTTP tests now say so. Unlike a query to /sparql, these run in the platform's own JVM - PATCH executes the update on the document's graph in memory, an import runs the mapping over each row - so the egress proxy in front of the triplestores never sees the call, and an internal target hands a writer the admin store's agents and authorizations. PATCH-service-internal.sh patches an item with INSERT { ?g } WHERE { SERVICE SILENT { ... } } and fails if the leak triple lands in the document; import-rdf-service-internal.sh imports with rdf-service-internal.rq, a CONSTRUCT that always mints the item and gives it the leak triple only if the admin store answered. SERVICE SILENT turns a refused call into one empty solution, so both check the document for data rather than the response for a status code. The leak is real on the current code: reproduced against a live stack whose triplestore already sits behind the egress proxy, a PATCH SERVICE to the admin store copied an admin graph name into the patched document. The default stack has no restriction on SERVICE in these paths, so both tests fail against it until the platform routes or refuses these calls itself. Co-Authored-By: Claude Opus 4.8 --- .../PATCH-service-internal.sh | 73 ++++++++++++++++ .../imports/import-rdf-service-internal.sh | 83 +++++++++++++++++++ http-tests/imports/rdf-service-internal.rq | 34 ++++++++ 3 files changed, 190 insertions(+) create mode 100755 http-tests/document-hierarchy/PATCH-service-internal.sh create mode 100755 http-tests/imports/import-rdf-service-internal.sh create mode 100644 http-tests/imports/rdf-service-internal.rq diff --git a/http-tests/document-hierarchy/PATCH-service-internal.sh b/http-tests/document-hierarchy/PATCH-service-internal.sh new file mode 100755 index 000000000..5ecbbc188 --- /dev/null +++ b/http-tests/document-hierarchy/PATCH-service-internal.sh @@ -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 < ?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 '' <<< "$document"; then + echo "SERVICE <${endpoint}> in a PATCH returned data" + exit 1 +fi diff --git a/http-tests/imports/import-rdf-service-internal.sh b/http-tests/imports/import-rdf-service-internal.sh new file mode 100755 index 000000000..5e3806bac --- /dev/null +++ b/http-tests/imports/import-rdf-service-internal.sh @@ -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 '' <<< "$document"; then + echo "SERVICE in an import mapping returned data" + exit 1 +fi diff --git a/http-tests/imports/rdf-service-internal.rq b/http-tests/imports/rdf-service-internal.rq new file mode 100644 index 000000000..8db5e40e6 --- /dev/null +++ b/http-tests/imports/rdf-service-internal.rq @@ -0,0 +1,34 @@ +PREFIX dh: +PREFIX sioc: +PREFIX dct: + +# 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" ; + ?g . + } +} +WHERE +{ + BIND (uri(concat(str($base), "service-internal/")) AS ?container) + BIND (uri(concat(str(?container), "item/")) AS ?item) + + OPTIONAL + { + SERVICE SILENT + { + SELECT ?g + { + GRAPH ?g { ?s ?p ?o } + } + LIMIT 1 + } + } +} From fe7a1fe5357f0df8e86e76933c4a94bc68d79625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Thu, 17 Sep 2026 21:40:24 +0200 Subject: [PATCH 2/2] The platform routes its own SPARQL SERVICE requests through the egress proxy, closing the leak PATCH-service-internal.sh and import-rdf-service-internal.sh document. A PATCH update runs on the document's graph in memory and an import runs its mapping over each row, both in the platform's JVM, so the proxy in front of the triplestores never saw those SERVICE calls and a writer could name an internal service and copy the admin store's agents and authorizations into a document they read. Application.configureServiceExecution now sets, on the global ARQ context the in-JVM query and update execution reads, either Jena's httpQueryClient - an HttpClient whose ProxySelector sends every SERVICE request through egress, localhost included, so federation with public endpoints keeps working while internal addresses are refused with redirect hops and DNS answers checked - or, when no proxy is configured, httpServiceAllowed=false so SERVICE is disabled rather than left open. It is scoped to SERVICE alone: the platform's own SPARQL calls to its stores use their own HTTP clients, and ALLOW_INTERNAL_URLS leaves SERVICE unrestricted as it already disables the rest of the SSRF protection. EGRESS_PROXY (default egress:3128) reaches the JVM as a system property through CATALINA_OPTS, the pattern the other infrastructure settings already use rather than the parameter-saturated context.xsl plumbing, and the linkeddatahub service depends on egress. Verified: on an in-memory QueryExecution like PATCH and imports build, httpServiceAllowed=false makes SERVICE SILENT yield no triples and a non-silent SERVICE throw QueryDeniedException, and a ProxySelector client sends the request to the proxy that then allows public and denies internal. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + docker-compose.yml | 3 ++ platform/entrypoint.sh | 6 +++ .../atomgraph/linkeddatahub/Application.java | 45 +++++++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 492badcdd..c5c0be725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 4f8678243..6552105a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -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 - MAIL_SMTP_HOST=email-server diff --git a/platform/entrypoint.sh b/platform/entrypoint.sh index c968687f4..7a19363d2 100755 --- a/platform/entrypoint.sh +++ b/platform/entrypoint.sh @@ -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 diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java index 10beb76e4..7e2650ba9 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -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; @@ -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; @@ -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; @@ -964,6 +969,46 @@ protected PasswordAuthentication getPasswordAuthentication() } } + /** + * Restricts SPARQL SERVICE 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 + * SERVICE 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}), SERVICE 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, SERVICE is disabled outright. + * + * Applies only to SERVICE 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.