Skip to content

Route the platform's own SPARQL SERVICE through the egress proxy (imports, PATCH) - #388

Merged
namedgraph merged 2 commits into
developfrom
test-platform-service-internal
Sep 17, 2026
Merged

namedgraph merged 2 commits into
developfrom
test-platform-service-internal

Conversation

@namedgraph

Copy link
Copy Markdown
Member

Closes the last variant of the SERVICE SSRF hole: the platform's own in-JVM SPARQL. Two HTTP tests plus the fix.

The hole

SERVICE is executed by whoever runs the query. The egress proxy (#387) fronts the triplestores, so SERVICE in a query to /sparql is covered. But PATCH updates and import mappings run in the platform's own JVM — PATCH on the document's graph in memory, imports over each row — so their SERVICE calls don't pass through that proxy. A writer could put SERVICE <http://fuseki-admin:3030/ds/> in a PATCH or an import mapping and copy the admin store's agents and authorizations into a document they can read. Reproduced on a live stack whose triplestores were already behind the egress proxy: a PATCH copied an admin graph name into the patched document.

Tests (committed first)

  • http-tests/document-hierarchy/PATCH-service-internal.sh — PATCH with SERVICE SILENT <fuseki-admin> in its WHERE; fails if the leak triple lands in the document.
  • http-tests/imports/import-rdf-service-internal.sh + rdf-service-internal.rq — an import whose CONSTRUCT does the same.

Both use SERVICE SILENT, so they check the document for data, not a status code.

Fix

Application.configureServiceExecution sets, on the global ARQ context (which the in-JVM query/update execution reads):

  • route — Jena's httpQueryClient, an HttpClient whose ProxySelector sends every SERVICE request through the egress proxy (EGRESS_PROXY, default egress:3128). Public federation keeps working; internal addresses are refused, redirect hops and DNS answers included; ProxySelector.of() covers localhost too.
  • refuse — when no proxy is configured, httpServiceAllowed=false, so SERVICE is disabled rather than left open.
  • unrestricted — when ALLOW_INTERNAL_URLS is set, as it already turns off the rest of the SSRF protection.

Scoped to SERVICE alone: the platform's own SPARQL calls to its stores use their own HTTP clients. EGRESS_PROXY reaches the JVM as a system property via CATALINA_OPTS, the pattern the other infrastructure settings use, and linkeddatahub now depends_on: egress.

Federation still works. The shipped stack defaults EGRESS_PROXY=egress:3128, so SERVICE is proxied, not forbidden — public endpoints answer, only internal ones are refused.

Verified

  • On an in-memory QueryExecution (what PATCH/imports build): httpServiceAllowed=false makes SERVICE SILENT yield no triples and a non-silent SERVICE throw QueryDeniedException.
  • A ProxySelector client sends the request to the proxy, which allows public and denies internal (matching the earlier linkeddatahub.com run where SERVICE to Wikidata answered and internal targets were TCP_DENIED/403).
  • mvn compile clean.

The two tests fail on the current default stack (leak reproduced) and pass with this fix routing SERVICE through egress. The full HTTP suite runs in CI.

🤖 Generated with Claude Code

namedgraph and others added 2 commits September 17, 2026 18:22
…ot 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 { <item> <urn:test:leak> ?g } WHERE { SERVICE SILENT <http://fuseki-admin:3030/ds/> { ... } } 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 <noreply@anthropic.com>
…s 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 <noreply@anthropic.com>
@namedgraph
namedgraph merged commit cc092fb into develop Sep 17, 2026
2 checks passed
@namedgraph
namedgraph deleted the test-platform-service-internal branch September 17, 2026 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant