diff --git a/SECURITY.md b/SECURITY.md
index 70fa28bd0..2c2b9d08e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -7,8 +7,8 @@ For older versions, we highly recommend upgrading to the latest version.
| Version | Supported |
|---------| ------------------ |
-| 1.19.x | :white_check_mark: |
-| < 1.19 | :x: |
+| 1.20.x | :white_check_mark: |
+| < 1.20 | :x: |
## Current Recommendations
diff --git a/checkstyle.xml b/checkstyle.xml
index 23332dd05..d6af421d4 100644
--- a/checkstyle.xml
+++ b/checkstyle.xml
@@ -328,7 +328,7 @@
-
+
diff --git a/pom.xml b/pom.xml
index 563a49320..cf264584e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
org.fairdatateam
fairdatapoint
- 1.19.1
+ 1.20.0
jar
FairDataPoint
@@ -214,20 +214,6 @@
rdf4j-sail-nativerdf
${rdf4j-runtime.version}
-
- org.eclipse.rdf4j
- rdf4j-spring-boot-sparql-web
- ${rdf4j-runtime.version}
-
-
-
- *
- *
-
-
-
io.jsonwebtoken
jjwt-api
diff --git a/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SearchSparqlController.java b/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SearchSparqlController.java
deleted file mode 100644
index cf81fe5ce..000000000
--- a/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SearchSparqlController.java
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * The MIT License
- * Copyright © 2017 FAIR Data Team
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- */
-// This code is mostly copied from rdf4j spring-boot-sparql-web, with some customizations:
-//
-// https://github.com/eclipse-rdf4j/rdf4j/blob/main/spring-components/spring-boot-sparql-web
-//
-// Copyright (c) 2021 Eclipse RDF4J contributors.
-//
-// All rights reserved. This program and the accompanying materials
-// are made available under the terms of the Eclipse Distribution License v1.0
-// which accompanies this distribution, and is available at
-// http://www.eclipse.org/org/documents/edl-v10.php.
-//
-// SPDX-License-Identifier: BSD-3-Clause
-
-package org.fairdatateam.fairdatapoint.api.controller.search;
-
-import io.swagger.v3.oas.annotations.tags.Tag;
-import jakarta.servlet.http.HttpServletResponse;
-import org.eclipse.rdf4j.http.server.readonly.sparql.EvaluateResult;
-import org.eclipse.rdf4j.http.server.readonly.sparql.SparqlQueryEvaluator;
-import org.eclipse.rdf4j.query.MalformedQueryException;
-import org.eclipse.rdf4j.repository.Repository;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-import java.io.IOException;
-import java.io.OutputStream;
-
-@Tag(name = "Search")
-@RestController
-public class SearchSparqlController {
-
- private static final String[] ALL_GRAPHS = {};
-
- private static final String JSON_MEDIA_TYPES = "application/json, application/ld+json";
-
- private final Repository rdf4jRepository;
-
- private final SparqlQueryEvaluator sparqlQueryEvaluator;
-
- /**
- * Constructor
- */
- public SearchSparqlController(Repository rdf4jRepository, SparqlQueryEvaluator sparqlQueryEvaluator) {
- this.rdf4jRepository = rdf4jRepository;
- this.sparqlQueryEvaluator = sparqlQueryEvaluator;
- }
-
- /**
- * Allows authenticated users to POST a full SPARQL query.
- * Method body copied from org.eclipse.rdf4j.http.server.readonly.QueryResponder.
- * The "Accept" header is required, and allowable media types depend on the type of query,
- * as defined in org.eclipse.rdf4j.http.server.readonly.sparql.QueryTypes.formats.
- * However, to simplify things, we restrict the allowable media types to JSON and/or JSON-LD.
- */
- @PreAuthorize("isAuthenticated()")
- @PostMapping(
- path = "/search/sparql",
- consumes = MediaType.APPLICATION_JSON_VALUE,
- produces = { MediaType.APPLICATION_JSON_VALUE, "application/ld+json" }
- )
- public void sparqlPost(
- @RequestHeader(value = HttpHeaders.ACCEPT, defaultValue = JSON_MEDIA_TYPES) String acceptHeader,
- @RequestBody SparqlQuery sparqlQuery,
- HttpServletResponse response
- ) throws IOException {
- // enforce default accept header for wildcard
- final String accept = ("*/*".equals(acceptHeader)) ? JSON_MEDIA_TYPES : acceptHeader;
- try {
- final EvaluateResultHttpResponse result = new EvaluateResultHttpResponse(response);
- sparqlQueryEvaluator.evaluate(
- result,
- rdf4jRepository,
- sparqlQuery.query,
- accept,
- toArray(sparqlQuery.defaultGraphUri),
- toArray(sparqlQuery.namedGraphUri)
- );
- }
- catch (MalformedQueryException | IllegalStateException | IOException exception) {
- response.sendError(HttpServletResponse.SC_BAD_REQUEST);
- }
- }
-
- private String[] toArray(String graphUri) {
- if (graphUri != null && !graphUri.isEmpty()) {
- return new String[]{graphUri};
- }
- return ALL_GRAPHS;
- }
-
- /**
- * Encapsulates the {@link HttpServletResponse}.
- * Copied from org.eclipse.rdf4j.http.server.readonly.EvaluateResultHttpResponse.
- */
- protected static class EvaluateResultHttpResponse implements EvaluateResult {
-
- private final HttpServletResponse response;
-
- public EvaluateResultHttpResponse(HttpServletResponse response) {
- this.response = response;
- }
-
- @Override
- public void setContentType(String contentType) {
- response.setContentType(contentType);
- }
-
- @Override
- public String getContentType() {
- return response.getContentType();
- }
-
- @Override
- public OutputStream getOutputstream() throws IOException {
- return response.getOutputStream();
- }
- }
-
- /**
- * Defines the content of the query request body, for JSON deserialization.
- * @param query
- * @param defaultGraphUri
- * @param namedGraphUri
- */
- public record SparqlQuery(String query, String defaultGraphUri, String namedGraphUri) {
- }
-}
diff --git a/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SparqlProxyCleaningService.java b/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SparqlProxyCleaningService.java
new file mode 100644
index 000000000..dee590258
--- /dev/null
+++ b/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SparqlProxyCleaningService.java
@@ -0,0 +1,116 @@
+/**
+ * The MIT License
+ * Copyright © 2017 FAIR Data Team
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.fairdatateam.fairdatapoint.api.controller.search;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpRequest;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.ClientHttpResponse;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Provides methods for cleaning request and response headers in a SPARQL proxy endpoint
+ */
+@Service
+public class SparqlProxyCleaningService {
+
+ public static final String HEADER_X_FORWARDED_FOR = "X-Forwarded-For";
+
+ // standard hop-by-hop headers mentioned in rfc2616
+ private static final List HOP_BY_HOP_HEADERS = List.of(
+ "Keep-Alive",
+ HttpHeaders.PROXY_AUTHENTICATE,
+ HttpHeaders.PROXY_AUTHORIZATION,
+ HttpHeaders.TE,
+ HttpHeaders.TRAILER,
+ HttpHeaders.TRANSFER_ENCODING,
+ HttpHeaders.UPGRADE
+ );
+
+ @Value("${openapi.title} ${openapi.version}")
+ private String headerServer;
+
+ /**
+ * Getter for hop-by-hop headers which need to be removed by a proxy.
+ */
+ public static List getHopByHopHeaders() {
+ return HOP_BY_HOP_HEADERS;
+ }
+
+ /**
+ * Returns a function that updates an HttpHeaders object based on the headers from a request.
+ * To be used as input for RestClient.*.headers() methods.
+ */
+ Consumer cleanRequestHeadersFactory(HttpServletRequest request, HttpHeaders requestHeaders) {
+ // Design note: It seems redundant to have both request and requestHeaders arguments because the headers are
+ // already available in the request. However, HttpServletRequest does not provide a simple way to get an
+ // HttpHeaders object, whereas the controller does provide one with @RequestHeader. Still, we do also need the
+ // HttpServletRequest to get the remote IP address.
+ return restRequestHeaders -> {
+ // copy all headers
+ restRequestHeaders.putAll(requestHeaders);
+ // remove any authorization to prevent privilege escalation attempts
+ restRequestHeaders.remove(HttpHeaders.AUTHORIZATION);
+ // forward the client ip (otherwise the upstream only sees the proxy ip)
+ restRequestHeaders.add(HEADER_X_FORWARDED_FOR, request.getRemoteAddr());
+ };
+ }
+
+ /**
+ * Extracts headers from response and removes the ones that should not be forwarded,
+ * such as hop-by-hop headers. These must be listed in the Connection header (see rfc9110 7.6.1).
+ */
+ private HttpHeaders cleanResponseHeaders(ClientHttpResponse response) {
+ // copy all headers from the response
+ final HttpHeaders headers = new HttpHeaders();
+ headers.putAll(response.getHeaders());
+ // remove all headers listed in the "Connection" header
+ headers.getConnection().forEach(headers::remove);
+ headers.remove(HttpHeaders.CONNECTION);
+ // explicitly remove hop-by-hop headers (although Connection should list them too)
+ HOP_BY_HOP_HEADERS.forEach(headers::remove);
+ // rewrite the server header to hide the type of backend triple store
+ headers.set(HttpHeaders.SERVER, headerServer);
+ return headers;
+ }
+
+ /**
+ * Modifies the response from RestClient before returning it from the controller.
+ * To be used as input for RestClient.*.exchange() calls.
+ */
+ ResponseEntity cleanResponse(
+ HttpRequest restRequest, ClientHttpResponse restResponse
+ ) throws IOException {
+ return ResponseEntity
+ .status(restResponse.getStatusCode())
+ .headers(cleanResponseHeaders(restResponse))
+ .body(restResponse.getBody().readAllBytes());
+ }
+
+}
diff --git a/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SparqlProxyController.java b/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SparqlProxyController.java
new file mode 100644
index 000000000..eddb51d13
--- /dev/null
+++ b/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SparqlProxyController.java
@@ -0,0 +1,222 @@
+/**
+ * The MIT License
+ * Copyright © 2017 FAIR Data Team
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.fairdatateam.fairdatapoint.api.controller.search;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+
+import lombok.extern.slf4j.Slf4j;
+import org.eclipse.rdf4j.repository.Repository;
+import org.eclipse.rdf4j.repository.http.HTTPRepository;
+import org.eclipse.rdf4j.repository.sparql.SPARQLRepository;
+import org.fairdatateam.fairdatapoint.entity.exception.ResourceNotFoundException;
+import org.hibernate.validator.constraints.URL;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.net.URI;
+import java.util.*;
+
+/**
+ * Acts as a proxy that forwards read-only query requests to a SPARQL endpoint provided by an external triple store.
+ * The triple store SPARQL endpoint must comply with the
+ * SPARQL protocol.
+ * The proxy controller performs basic input validation and removes authentication credentials before forwarding
+ * requests to the triple store SPARQL endpoint. It also cleans up the headers before returning the response.
+ * Returns status 404 NOT FOUND when using an in-memory or native (file system) triple store,
+ * because those do not provide a SPARQL endpoint.
+ * This controller is disabled by default, and can be enabled by setting instance.sparqlProxyEnabled=true.
+ */
+@ConditionalOnProperty(value = "instance.sparqlProxyEnabled", havingValue = "true")
+@Slf4j
+@Tag(name = "Search")
+@RestController
+public class SparqlProxyController {
+
+ public static final String EXAMPLE_QUERY = "SELECT * WHERE { ?s ?p ?o }";
+
+ public static final String MESSAGE_UPDATE_DENIED = "SPARQL update not allowed";
+
+ // request parameters and media types defined in https://www.w3.org/TR/sparql11-protocol/
+ public static final String MEDIA_TYPE_SPARQL_QUERY = "application/sparql-query";
+ public static final String MEDIA_TYPE_SPARQL_UPDATE = "application/sparql-update";
+ public static final String PARAM_QUERY = "query";
+ public static final String PARAM_DEFAULT_GRAPH_URI = "default-graph-uri";
+ public static final String PARAM_NAMED_GRAPH_URI = "named-graph-uri";
+ public static final String PARAM_UPDATE = "update";
+
+ private static final String DESCRIPTION_EXPERIMENTAL = """
+ [EXPERIMENTAL]
+ SPARQL endpoint - Supports SPARQL query operations following the SPARQL protocol.
+ Update operations are not allowed.
+ This endpoint is not part of the stable API and may change in future releases.
+ """;
+
+ private final SparqlProxyCleaningService cleaningService;
+
+ private final RestClient restClient;
+
+ private String sparqlEndpointUrl;
+
+ /**
+ * Constructor
+ */
+ public SparqlProxyController(
+ Repository repository, SparqlProxyCleaningService cleaningService, RestClient restClient
+ ) {
+ this.cleaningService = cleaningService;
+ this.restClient = restClient;
+ // todo: simplify as part of #824
+ if (repository instanceof SPARQLRepository sparqlRepository) {
+ this.sparqlEndpointUrl = sparqlRepository.toString();
+ }
+ else if (repository instanceof HTTPRepository httpRepository) {
+ this.sparqlEndpointUrl = httpRepository.getRepositoryURL();
+ }
+ }
+
+ /**
+ * Abort with "resource not found" if there is no upstream SPARQL endpoint
+ */
+ private void abortIfSparqlEndpointUnavailable() {
+ if (sparqlEndpointUrl == null || sparqlEndpointUrl.isEmpty()) {
+ throw new ResourceNotFoundException("SPARQL endpoint unavailable");
+ }
+ }
+
+ /**
+ * Handles GET requests with queries in URL parameters.
+ */
+ @Operation(description = DESCRIPTION_EXPERIMENTAL)
+ @PreAuthorize("isAuthenticated()")
+ @GetMapping("/search/sparql")
+ public ResponseEntity proxySparqlEndpointGet(
+ HttpServletRequest request,
+ @RequestHeader HttpHeaders requestHeaders,
+ @RequestParam(name = PARAM_QUERY) @Parameter(example = EXAMPLE_QUERY) String query,
+ @RequestParam(name = PARAM_DEFAULT_GRAPH_URI, required = false) List<@URL String> defaultGraphUri,
+ @RequestParam(name = PARAM_NAMED_GRAPH_URI, required = false) List<@URL String> namedGraphUri
+ ) {
+ abortIfSparqlEndpointUnavailable();
+ SparqlQueryValidator.validate(query);
+ // add query parameters
+ final URI uriWithQuery = UriComponentsBuilder
+ .fromUriString(sparqlEndpointUrl)
+ // the query is automatically encoded because it contains illegal characters, but the uris are not
+ .queryParam(PARAM_QUERY, query)
+ .queryParamIfPresent(PARAM_DEFAULT_GRAPH_URI, Optional.ofNullable(defaultGraphUri))
+ .queryParamIfPresent(PARAM_NAMED_GRAPH_URI, Optional.ofNullable(namedGraphUri))
+ .build()
+ // convert to URI (instead of String) to prevent RestClient from trying to do template expansion
+ // on the sparql query, e.g. {?s ?p ?o}
+ .toUri();
+ log.info("SPARQL URI query: {}", uriWithQuery);
+ // send get request to backend sparql endpoint
+ return restClient.get()
+ .uri(uriWithQuery)
+ .headers(cleaningService.cleanRequestHeadersFactory(request, requestHeaders))
+ .exchange(cleaningService::cleanResponse);
+ }
+
+ /**
+ * Handles POST requests using form data ("application/x-www-form-urlencoded").
+ */
+ @Operation(description = DESCRIPTION_EXPERIMENTAL)
+ @PreAuthorize("isAuthenticated()")
+ @PostMapping(value = "/search/sparql", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+ public ResponseEntity proxySparqlEndpointPostForm(
+ HttpServletRequest request,
+ @RequestHeader HttpHeaders requestHeaders,
+ // bind form data from request body
+ @RequestParam(name = PARAM_QUERY) @Parameter(schema = @Schema(example = EXAMPLE_QUERY)) String query,
+ @RequestParam(name = PARAM_DEFAULT_GRAPH_URI, required = false) List<@URL String> defaultGraphUri,
+ @RequestParam(name = PARAM_NAMED_GRAPH_URI, required = false) List<@URL String> namedGraphUri
+ ) {
+ abortIfSparqlEndpointUnavailable();
+ SparqlQueryValidator.validate(query);
+ // abort if request contains a SPARQL update attempt
+ if (request.getParameter(PARAM_UPDATE) != null) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, MESSAGE_UPDATE_DENIED);
+ }
+ // @RequestParam is used in the method signature for convenient validation and generation of api docs.
+ // However, this means we need to reconstruct the form data before we can forward it to the backend server.
+ // (an alternative would be to use `@RequestBody MultiValueMap sparqlForm`, combined with
+ // custom validation, instead of the individual @RequestParam entries)
+ final Map> sparqlForm = new HashMap<>(3);
+ sparqlForm.put(PARAM_QUERY, List.of(query));
+ if (defaultGraphUri != null && !defaultGraphUri.isEmpty()) {
+ sparqlForm.put(PARAM_DEFAULT_GRAPH_URI, defaultGraphUri);
+ }
+ if (namedGraphUri != null && !namedGraphUri.isEmpty()) {
+ sparqlForm.put(PARAM_NAMED_GRAPH_URI, namedGraphUri);
+ }
+ // post to backend sparql endpoint
+ final URI uri = URI.create(sparqlEndpointUrl);
+ return restClient.post()
+ .uri(uri)
+ .headers(cleaningService.cleanRequestHeadersFactory(request, requestHeaders))
+ .body(MultiValueMap.fromMultiValue(sparqlForm))
+ .exchange(cleaningService::cleanResponse);
+ }
+
+ /**
+ * Handles POST requests with raw sparql queries ("application/sparql-query")
+ */
+ @Operation(description = DESCRIPTION_EXPERIMENTAL)
+ @PreAuthorize("isAuthenticated()")
+ @PostMapping(value = "/search/sparql", consumes = MEDIA_TYPE_SPARQL_QUERY)
+ public ResponseEntity proxySparqlEndpointPostRaw(
+ HttpServletRequest request,
+ @RequestHeader HttpHeaders requestHeaders,
+ // raw query in body
+ @RequestBody @Parameter(schema = @Schema(example = EXAMPLE_QUERY)) String query,
+ // graph uris in url parameters
+ @RequestParam(name = PARAM_DEFAULT_GRAPH_URI, required = false) List<@URL String> defaultGraphUri,
+ @RequestParam(name = PARAM_NAMED_GRAPH_URI, required = false) List<@URL String> namedGraphUri
+ ) {
+ abortIfSparqlEndpointUnavailable();
+ SparqlQueryValidator.validate(query);
+ // add query parameters if present
+ final URI uriWithQuery = UriComponentsBuilder
+ .fromUriString(sparqlEndpointUrl)
+ .queryParamIfPresent(PARAM_DEFAULT_GRAPH_URI, Optional.ofNullable(defaultGraphUri))
+ .queryParamIfPresent(PARAM_NAMED_GRAPH_URI, Optional.ofNullable(namedGraphUri))
+ .build().toUri();
+ // post to backend sparql endpoint
+ return restClient.post()
+ .uri(uriWithQuery)
+ .headers(cleaningService.cleanRequestHeadersFactory(request, requestHeaders))
+ .body(query)
+ .exchange(cleaningService::cleanResponse);
+ }
+}
diff --git a/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SparqlQueryValidator.java b/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SparqlQueryValidator.java
new file mode 100644
index 000000000..6da523b98
--- /dev/null
+++ b/src/main/java/org/fairdatateam/fairdatapoint/api/controller/search/SparqlQueryValidator.java
@@ -0,0 +1,62 @@
+/**
+ * The MIT License
+ * Copyright © 2017 FAIR Data Team
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.fairdatateam.fairdatapoint.api.controller.search;
+
+import lombok.extern.slf4j.Slf4j;
+import org.eclipse.rdf4j.query.MalformedQueryException;
+import org.eclipse.rdf4j.repository.Repository;
+import org.eclipse.rdf4j.repository.RepositoryConnection;
+import org.eclipse.rdf4j.repository.RepositoryException;
+import org.eclipse.rdf4j.repository.sail.SailRepository;
+import org.eclipse.rdf4j.sail.memory.MemoryStore;
+import org.fairdatateam.fairdatapoint.entity.exception.ValidationException;
+
+/**
+ * Custom validator for SPARQL query strings. Throws a ValidationException if validation fails.
+ */
+@Slf4j
+public class SparqlQueryValidator {
+
+ public static final String MESSAGE_INVALID_QUERY = "Invalid SPARQL query";
+
+ /**
+ * Validate the SPARQL query string. This also guards against SPARQL update commands.
+ */
+ public static void validate(String query) {
+ // create temporary in-memory repository because we need a repository connection
+ // but do not want to run the risk of accidental access to the main repository
+ final Repository temporaryRepository = new SailRepository(new MemoryStore());
+ try (RepositoryConnection connection = temporaryRepository.getConnection()) {
+ // use RDF4J's built-in query validation (note there is also a prepareUpdate() method)
+ connection.prepareQuery(query);
+ }
+ catch (MalformedQueryException | RepositoryException exception) {
+ log.warn(exception.getMessage());
+ throw new ValidationException(MESSAGE_INVALID_QUERY);
+ }
+ finally {
+ temporaryRepository.shutDown();
+ }
+ }
+
+}
diff --git a/src/main/java/org/fairdatateam/fairdatapoint/config/HttpClientConfig.java b/src/main/java/org/fairdatateam/fairdatapoint/config/HttpClientConfig.java
index 7ef2c8916..30edebda9 100644
--- a/src/main/java/org/fairdatateam/fairdatapoint/config/HttpClientConfig.java
+++ b/src/main/java/org/fairdatateam/fairdatapoint/config/HttpClientConfig.java
@@ -25,6 +25,7 @@
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@@ -35,9 +36,16 @@ public class HttpClientConfig {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
+ @Bean RestClient restClient(RestClient.Builder builder) {
+ // The builder argument is required for autoconfiguration of MockRestServiceServer in tests
+ // https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
+ return builder.build();
+ }
+
@Bean
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
return restTemplateBuilder
+ // todo: these are deprecated, but we should replace RestTemplate by RestClient anyway
.setConnectTimeout(TIMEOUT)
.setReadTimeout(TIMEOUT)
.build();
diff --git a/src/main/java/org/fairdatateam/fairdatapoint/config/Rdf4jSparqlConfig.java b/src/main/java/org/fairdatateam/fairdatapoint/config/Rdf4jSparqlConfig.java
deleted file mode 100644
index f447150d8..000000000
--- a/src/main/java/org/fairdatateam/fairdatapoint/config/Rdf4jSparqlConfig.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * The MIT License
- * Copyright © 2017 FAIR Data Team
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- */
-package org.fairdatateam.fairdatapoint.config;
-
-import org.springframework.context.annotation.ComponentScan;
-import org.springframework.context.annotation.Configuration;
-
-/**
- * Make sure the SparqlQueryEvaluator implementation bean can be found (SparqlQueryEvaluatorDefault)
- */
-@Configuration
-@ComponentScan("org.eclipse.rdf4j.http.server.readonly.sparql")
-public class Rdf4jSparqlConfig {
-}
diff --git a/src/main/java/org/fairdatateam/fairdatapoint/config/properties/InstanceProperties.java b/src/main/java/org/fairdatateam/fairdatapoint/config/properties/InstanceProperties.java
index 07e413a5d..8568da6d9 100644
--- a/src/main/java/org/fairdatateam/fairdatapoint/config/properties/InstanceProperties.java
+++ b/src/main/java/org/fairdatateam/fairdatapoint/config/properties/InstanceProperties.java
@@ -41,6 +41,9 @@ public class InstanceProperties {
private boolean index;
private boolean indexAutoPermit;
+ // enable an optional sparql proxy endpoint that forwards requests to the backend triple store sparql endpoint
+ private boolean sparqlProxyEnabled = false;
+
private String title = "FAIR Data Point";
private String subtitle = "Metadata for machines";
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 3b258a600..2386c6e29 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -67,7 +67,7 @@ metadataProperties:
openapi:
title: FAIR Data Point API
- version: 1.19.1
+ version: 1.20.0
description: "The reference implementation of the metadata registration service: A service implementing the API specification. It contains an authentication system to allow maintainers to define and update metadata. Read-only access to the data is public."
contact:
name: Luiz Bonino
diff --git a/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/SparqlProxyCleaningServiceTest.java b/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/SparqlProxyCleaningServiceTest.java
new file mode 100644
index 000000000..61d21e388
--- /dev/null
+++ b/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/SparqlProxyCleaningServiceTest.java
@@ -0,0 +1,136 @@
+/**
+ * The MIT License
+ * Copyright © 2017 FAIR Data Team
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.fairdatateam.fairdatapoint.acceptance.search.sparql;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.fairdatateam.fairdatapoint.api.controller.search.SparqlProxyCleaningService;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpRequest;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.ClientHttpResponse;
+import org.springframework.mock.http.client.MockClientHttpRequest;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringJUnitConfig(SparqlProxyCleaningService.class)
+@TestPropertySource(properties = {"openapi.title=Test API", "openapi.version=1.0"})
+public class SparqlProxyCleaningServiceTest {
+
+ private final SparqlProxyCleaningService cleaningService;
+
+ /**
+ * Constructor
+ */
+ @Autowired
+ public SparqlProxyCleaningServiceTest(SparqlProxyCleaningService cleaningService) {
+ this.cleaningService = cleaningService;
+ }
+
+ @Test
+ public void cleansRequestHeaders() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
+ final String remoteIP = "1.2.3.4";
+ // headers for the incoming request from a remote client
+ HttpHeaders requestHeadersIncoming = new HttpHeaders();
+ requestHeadersIncoming.setAccept(List.of(MediaType.APPLICATION_JSON));
+ requestHeadersIncoming.setBearerAuth("dummy-token");
+ MockHttpServletRequest requestIncoming = new MockHttpServletRequest();
+ requestIncoming.setRemoteAddr(remoteIP);
+
+ // headers for the outgoing request to the upstream sparql endpoint
+ HttpHeaders requestHeadersOutgoing = new HttpHeaders();
+
+ // get access to package-private method for testing
+ Method methodUnderTest = SparqlProxyCleaningService.class.getDeclaredMethod(
+ "cleanRequestHeadersFactory", HttpServletRequest.class, HttpHeaders.class
+ );
+ methodUnderTest.setAccessible(true);
+
+ // create callable using factory
+ @SuppressWarnings("unchecked")
+ Consumer cleanRequestHeaders = (Consumer) methodUnderTest.invoke(
+ cleaningService, requestIncoming, requestHeadersIncoming);
+
+ // perform header clean-up
+ cleanRequestHeaders.accept(requestHeadersOutgoing);
+
+ // check result
+ assertThat(requestHeadersOutgoing.getAccept()).hasSize(1).contains(MediaType.APPLICATION_JSON);
+ assertThat(requestHeadersOutgoing.containsKey(HttpHeaders.AUTHORIZATION)).isFalse();
+ assertThat(requestHeadersOutgoing.get(SparqlProxyCleaningService.HEADER_X_FORWARDED_FOR))
+ .hasSize(1).contains(remoteIP);
+ }
+
+ @Test
+ public void cleansResponseHeaders() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
+ // mock headers "received" from the upstream sparql endpoint
+ final HttpHeaders mockBackendResponseHeaders = new HttpHeaders();
+
+ // add custom header
+ final String customHeaderName = "my-custom-header";
+ mockBackendResponseHeaders.add(customHeaderName, "dummy");
+
+ // add standard hop-by-hop headers
+ final List hopByHopHeaders = SparqlProxyCleaningService.getHopByHopHeaders();
+ hopByHopHeaders.forEach(header -> mockBackendResponseHeaders.add(header, "dummy"));
+
+ // add connection header (list is incomplete on purpose, e.g. it does not contain all hop-by-hop headers)
+ mockBackendResponseHeaders.put(HttpHeaders.CONNECTION, List.of(customHeaderName, hopByHopHeaders.get(0)));
+
+ // add server header
+ final String backendServerHeader = "mock backend sparql server 1.0";
+ mockBackendResponseHeaders.add(HttpHeaders.SERVER, backendServerHeader);
+
+ // get access to package-private method for testing
+ Method methodUnderTest = SparqlProxyCleaningService.class.getDeclaredMethod(
+ "cleanResponse", HttpRequest.class, ClientHttpResponse.class
+ );
+ methodUnderTest.setAccessible(true);
+
+ try (final MockClientHttpResponse sparqlServerResponse = new MockClientHttpResponse()) {
+ // clean the response
+ @SuppressWarnings("unchecked")
+ ResponseEntity cleanedResponse = (ResponseEntity) methodUnderTest.invoke(
+ cleaningService, new MockClientHttpRequest(), sparqlServerResponse
+ );
+
+ // check result
+ final HttpHeaders cleanedResponseHeaders = cleanedResponse.getHeaders();
+ assertThat(cleanedResponseHeaders).doesNotContainKey(customHeaderName);
+ hopByHopHeaders.forEach(header -> assertThat(cleanedResponseHeaders).doesNotContainKey(header));
+ assertThat(cleanedResponseHeaders).doesNotContainKey(HttpHeaders.CONNECTION);
+ assertThat(cleanedResponseHeaders).doesNotContainEntry(HttpHeaders.SERVER, List.of(backendServerHeader));
+ assertThat(cleanedResponseHeaders).containsEntry(HttpHeaders.SERVER, List.of("Test API 1.0"));
+ }
+ }
+}
diff --git a/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/SparqlProxyControllerTest.java b/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/SparqlProxyControllerTest.java
new file mode 100644
index 000000000..4d00f2b46
--- /dev/null
+++ b/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/SparqlProxyControllerTest.java
@@ -0,0 +1,358 @@
+/**
+ * The MIT License
+ * Copyright © 2017 FAIR Data Team
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.fairdatateam.fairdatapoint.acceptance.search.sparql;
+
+import org.fairdatateam.fairdatapoint.Profiles;
+import org.fairdatateam.fairdatapoint.api.controller.search.SparqlProxyController;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureMockRestServiceServer;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.*;
+import org.springframework.security.test.context.support.WithAnonymousUser;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.test.web.servlet.assertj.MockMvcTester;
+import org.springframework.test.web.servlet.assertj.MvcTestResult;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import java.net.URI;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.fairdatateam.fairdatapoint.api.controller.search.SparqlProxyController.*;
+import static org.fairdatateam.fairdatapoint.api.controller.search.SparqlQueryValidator.MESSAGE_INVALID_QUERY;
+import static org.hamcrest.Matchers.startsWith;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests proxy functionality.
+ * Note: If you encounter "AssertionError: No further requests expected", that means a request is *unexpectedly* being
+ * forwarded to the mock upstream sparql server. This may happen, for example, if the proxy controller is supposed to
+ * deny the request but does not do so.
+ */
+@ActiveProfiles(Profiles.TESTING)
+@AutoConfigureMockMvc
+@AutoConfigureMockRestServiceServer
+@SpringBootTest(properties = { "instance.sparqlProxyEnabled=true" })
+@WithMockUser
+public class SparqlProxyControllerTest {
+
+ // note we're now using @URL instead of the custom (RDF4J-based) @ValidIri because the latter ignores spaces
+ final static String INVALID_URI = "https://in valid.example.org";
+
+ final static String MALICIOUS_SPARQL_UPDATE = "CLEAR GRAPH ex:";
+
+ final static String TEST_SPARQL_ENDPOINT_URL = "https://triple.store.example.org/sparql";
+
+ // mock ResTemplate to prevent autoconfig issues with MockRestServiceServer (the controller uses RestClient)
+ @MockitoBean
+ RestTemplate restTemplate;
+
+ private final MockMvcTester mockMvc;
+
+ private final MockRestServiceServer mockBackendSparqlServer;
+
+ private final SparqlProxyController sparqlProxyController;
+
+ private final String defaultGraphUri = "http://default.graph.uri";
+
+ private final String namedGraphUri = "http://named.graph.uri";
+
+ private final String path = "/search/sparql";
+
+ // mock json response body (https://www.w3.org/TR/sparql11-results-json/)
+ private final String mockJsonBody = "{\"head\": {\"vars\": []}, \"results\": {\"bindings\": []}}";
+
+ /**
+ * Constructor
+ */
+ @Autowired
+ public SparqlProxyControllerTest(
+ MockMvcTester mockMvc,
+ MockRestServiceServer mockBackendSparqlServer,
+ SparqlProxyController sparqlProxyController
+ ) {
+ this.mockMvc = mockMvc;
+ this.mockBackendSparqlServer = mockBackendSparqlServer;
+ this.sparqlProxyController = sparqlProxyController;
+ }
+
+ @BeforeEach
+ public void setup() {
+ // override the repository query url
+ ReflectionTestUtils.setField(sparqlProxyController, "sparqlEndpointUrl", TEST_SPARQL_ENDPOINT_URL);
+ }
+
+ @Test
+ @WithAnonymousUser
+ public void unauthenticatedRequestsAreDeniedWithoutContactingRemoteSparqlServer() {
+ // perform request with url query but without authentication (@WithAnonymousUser)
+ MvcTestResult testResult = mockMvc.get()
+ .uri(path)
+ .queryParam(PARAM_QUERY, EXAMPLE_QUERY)
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange();
+
+ // should be denied
+ assertThat(testResult).hasStatus(HttpStatus.FORBIDDEN);
+ }
+
+ /**
+ * The SPARQL protocol requires that update
+ * operations are done either via POST with content-type "application/x-www-form-urlencoded" and an "update" field,
+ * or via POST with content-type "application/sparql-update" and a raw SPARQL update command string.
+ */
+ @Test
+ public void formPostWithUpdateFieldIsIgnored() {
+ // mock form data with "update" field, no "query" field (which is required)
+ final MultiValueMap formData = MultiValueMap.fromSingleValue(Map.of(
+ PARAM_UPDATE, MALICIOUS_SPARQL_UPDATE
+ ));
+
+ // execute request
+ MvcTestResult testResult = mockMvc.post().uri(URI.create(path)).formFields(formData).exchange();
+
+ // request should fail
+ assertThat(testResult).hasStatus(HttpStatus.BAD_REQUEST);
+ assertThat(testResult).hasErrorMessage("Required parameter 'query' is not present.");
+ }
+
+ @Test
+ public void formPostWithUpdateAndQueryFieldIsDenied() {
+ // mock form data with "update" field and a valid "query" field
+ final MultiValueMap formData = MultiValueMap.fromSingleValue(Map.of(
+ PARAM_UPDATE, MALICIOUS_SPARQL_UPDATE,
+ PARAM_QUERY, EXAMPLE_QUERY
+ ));
+
+ // execute request
+ MvcTestResult testResult = mockMvc.post().uri(URI.create(path)).formFields(formData).exchange();
+
+ // request should fail
+ assertThat(testResult).hasStatus(HttpStatus.BAD_REQUEST);
+ assertThat(testResult).hasErrorMessage(MESSAGE_UPDATE_DENIED);
+ }
+
+ @Test
+ public void formPostWithSparqlUpdateContentTypeIsDenied() {
+ // execute request with sparql-update content type
+ MvcTestResult testResult = mockMvc.post()
+ .uri(URI.create(path))
+ .contentType(MEDIA_TYPE_SPARQL_UPDATE)
+ .content(MALICIOUS_SPARQL_UPDATE)
+ .exchange();
+
+ // the request is denied because the content type is not supported (i.e. not in @PostMapping.consumes)
+ assertThat(testResult).hasStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE);
+ }
+
+ @Test
+ public void getRequestWithMaliciousUrlQueryIsDenied() {
+ // execute request with malicious url query (trying to update)
+ MvcTestResult testResult = mockMvc.get()
+ .uri(path)
+ .queryParam(PARAM_QUERY, MALICIOUS_SPARQL_UPDATE)
+ .exchange();
+
+ // request should fail
+ assertThat(testResult).hasStatus(HttpStatus.BAD_REQUEST);
+ assertThat(testResult).hasBodyTextEqualTo(MESSAGE_INVALID_QUERY);
+ }
+
+ @Test
+ public void formPostWithMaliciousQueryIsDenied() {
+ // execute request with malicious form data
+ MvcTestResult testResult = mockMvc.post()
+ .uri(path)
+ .formField(PARAM_QUERY, MALICIOUS_SPARQL_UPDATE)
+ .exchange();
+
+ // request should fail
+ assertThat(testResult).hasStatus(HttpStatus.BAD_REQUEST);
+ assertThat(testResult).hasBodyTextEqualTo(MESSAGE_INVALID_QUERY);
+ }
+
+ @Test
+ public void rawPostWithMaliciousQueryIsDenied() {
+ // execute request with malicious raw query
+ MvcTestResult testResult = mockMvc.post()
+ .uri(path)
+ .contentType(MEDIA_TYPE_SPARQL_QUERY)
+ .content(MALICIOUS_SPARQL_UPDATE)
+ .exchange();
+
+ // request should fail
+ assertThat(testResult).hasStatus(HttpStatus.BAD_REQUEST);
+ assertThat(testResult).hasBodyTextEqualTo(MESSAGE_INVALID_QUERY);
+ }
+
+ @Test
+ public void getWithInvalidUriIsDenied() {
+ // execute get request with invalid graph uris
+ MvcTestResult testResult = mockMvc.get()
+ .uri(path)
+ .queryParam(PARAM_DEFAULT_GRAPH_URI, INVALID_URI)
+ .queryParam(PARAM_NAMED_GRAPH_URI, INVALID_URI)
+ .queryParam(PARAM_QUERY, EXAMPLE_QUERY)
+ .exchange();
+
+ // request should fail
+ assertThat(testResult).hasStatus(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ public void rawPostWithInvalidUriIsDenied() {
+ // execute post request with invalid graph uris
+ MvcTestResult testResult = mockMvc.post()
+ .uri(path)
+ .queryParam(PARAM_DEFAULT_GRAPH_URI, INVALID_URI)
+ .queryParam(PARAM_NAMED_GRAPH_URI, INVALID_URI)
+ .contentType(MEDIA_TYPE_SPARQL_QUERY)
+ .content(EXAMPLE_QUERY)
+ .exchange();
+
+ // request should be denied
+ assertThat(testResult).hasStatus(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ public void formPostWithInvalidUriIsDenied() {
+ // mock form data
+ final MultiValueMap formData = MultiValueMap.fromSingleValue(Map.of(
+ PARAM_QUERY, EXAMPLE_QUERY,
+ PARAM_DEFAULT_GRAPH_URI, INVALID_URI,
+ PARAM_NAMED_GRAPH_URI, INVALID_URI
+ ));
+
+ // execute post request with invalid graph uris
+ MvcTestResult testResult = mockMvc.post().uri(path).formFields(formData).exchange();
+
+ // request should be denied
+ assertThat(testResult).hasStatus(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ public void proxyForwardingWorksForGetRequests() {
+ // configure mock server for remote SPARQL endpoint
+ this.mockBackendSparqlServer
+ // startsWith is required, otherwise it will expect a url without (query) parameters
+ .expect(requestTo(startsWith(TEST_SPARQL_ENDPOINT_URL)))
+ .andExpect(method(HttpMethod.GET))
+ .andRespond(withSuccess().body(mockJsonBody));
+
+ // execute request with url query
+ MvcTestResult testResult = mockMvc.get()
+ .uri(path)
+ .queryParam(PARAM_QUERY, EXAMPLE_QUERY)
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange();
+
+ // check that mock server has received expected requests
+ // (for some reason this hides exceptions from the RestClient, so comment out to debug)
+ mockBackendSparqlServer.verify();
+
+ // verify that proxy returns json response body
+ assertThat(testResult).hasStatusOk();
+ assertThat(testResult).bodyJson().hasPath("head.vars").hasPath("results.bindings");
+
+ }
+
+ @Test
+ public void proxyForwardingWorksForFormPostRequests() {
+ // mock form data
+ final MultiValueMap formData = MultiValueMap.fromSingleValue(Map.of(
+ PARAM_QUERY, EXAMPLE_QUERY,
+ PARAM_DEFAULT_GRAPH_URI, defaultGraphUri,
+ PARAM_NAMED_GRAPH_URI, namedGraphUri
+ ));
+
+ // configure mock server for remote SPARQL endpoint
+ this.mockBackendSparqlServer
+ .expect(requestTo(TEST_SPARQL_ENDPOINT_URL))
+ .andExpect(method(HttpMethod.POST))
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED))
+ // form data from original request must arrive at server unaltered (except for empty lists)
+ .andExpect(content().formData(formData))
+ .andRespond(withSuccess().body(mockJsonBody));
+
+ // execute request
+ MvcTestResult testResult = mockMvc.post()
+ .uri(URI.create(path))
+ .accept(MediaType.APPLICATION_JSON)
+ // note that empty list values in formData are not included in the actual form data
+ .formFields(formData)
+ .exchange();
+
+ // check that mock server has received expected requests
+ // (for some reason this hides exceptions from the RestClient, so comment out to debug)
+ mockBackendSparqlServer.verify();
+
+ // check response headers
+ assertThat(testResult).hasStatusOk();
+
+ // verify that proxy returns json response body
+ assertThat(testResult).bodyJson().hasPath("head.vars").hasPath("results.bindings");
+ }
+
+ @Test
+ public void proxyForwardingWorksForRawPostRequests() {
+ // configure mock server for remote SPARQL endpoint
+ this.mockBackendSparqlServer
+ .expect(requestTo(startsWith(TEST_SPARQL_ENDPOINT_URL)))
+ .andExpect(method(HttpMethod.POST))
+ .andExpect(content().contentTypeCompatibleWith(MEDIA_TYPE_SPARQL_QUERY))
+ .andExpect(queryParam(PARAM_DEFAULT_GRAPH_URI, defaultGraphUri))
+ .andExpect(queryParam(PARAM_NAMED_GRAPH_URI, namedGraphUri))
+ .andExpect(content().string(EXAMPLE_QUERY))
+ .andRespond(withSuccess().body(mockJsonBody));
+
+ // execute request with raw query in body and graph uris in url
+ MvcTestResult testResult = mockMvc.post()
+ .uri(path)
+ .queryParam(PARAM_DEFAULT_GRAPH_URI, defaultGraphUri)
+ .queryParam(PARAM_NAMED_GRAPH_URI, namedGraphUri)
+ .accept(MediaType.APPLICATION_JSON)
+ .contentType(MEDIA_TYPE_SPARQL_QUERY)
+ .content(EXAMPLE_QUERY)
+ .exchange();
+
+ // check that mock server has received expected requests
+ // (for some reason this hides exceptions from the RestClient, so comment out to debug)
+ mockBackendSparqlServer.verify();
+
+ // check response headers
+ assertThat(testResult).hasStatusOk();
+
+ // verify that proxy returns json response body
+ assertThat(testResult).bodyJson().hasPath("head.vars").hasPath("results.bindings");
+
+ }
+}
diff --git a/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/SparqlQueryValidatorTest.java b/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/SparqlQueryValidatorTest.java
new file mode 100644
index 000000000..915d8d768
--- /dev/null
+++ b/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/SparqlQueryValidatorTest.java
@@ -0,0 +1,93 @@
+/**
+ * The MIT License
+ * Copyright © 2017 FAIR Data Team
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.fairdatateam.fairdatapoint.acceptance.search.sparql;
+
+
+import org.fairdatateam.fairdatapoint.api.controller.search.SparqlQueryValidator;
+import org.fairdatateam.fairdatapoint.entity.exception.ValidationException;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class SparqlQueryValidatorTest {
+
+ // common prefixes (part of "prologue" in sparql grammar)
+ final static String PROLOGUE = """
+ PREFIX dc:
+ PREFIX ex:
+ PREFIX foaf:
+ PREFIX vcard:
+ """;
+
+ /**
+ * Verify that SPARQL query operations *are* allowed
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {
+ // some examples from https://www.w3.org/TR/sparql11-query
+ "ASK { ?x foaf:name \"Alice\" }",
+ "CONSTRUCT { vcard:FN ?name } WHERE { ?x foaf:name ?name }",
+ "DESCRIBE ?x WHERE { ?x foaf:mbox }",
+ "SELECT * WHERE {?s ?p ?o}"
+ })
+ public void queryOperationsAreValid(String query) {
+ // validation should succeed
+ assertDoesNotThrow(() -> SparqlQueryValidator.validate(PROLOGUE + query));
+ }
+
+ /**
+ * Verify that SPARQL Update operations are *not* allowed
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {
+ // https://www.w3.org/TR/sparql11-update/#graphUpdate
+ "INSERT DATA { ex:test1 dc:title \"test\" }",
+ "INSERT { ?s dc:title ?ol } WHERE { ?s dc:title ?o . BIND( STRLANG(STR(?o), \"en\") AS ?ol ) . }",
+ "DELETE DATA { ?s dc:title ?o } WHERE { ?s dc:title ?o }",
+ "DELETE WHERE { ?s dc:title ?o }",
+ "LOAD dc:",
+ "CLEAR GRAPH ex:",
+ // https://www.w3.org/TR/sparql11-update/#graphManagement
+ "CREATE GRAPH ex:",
+ "DROP GRAPH ex:",
+ "COPY DEFAULT TO GRAPH ex:",
+ "MOVE DEFAULT TO GRAPH ex:",
+ "ADD DEFAULT TO GRAPH ex:"
+ })
+ public void updateOperationsAreNotValid(String update) {
+ // common prefixes (part of prologue in sparql grammar)
+ final String prologue = """
+ PREFIX dc:
+ PREFIX ex:
+ """;
+
+ // validation should fail because these are SPARQL update operations instead of SPARQL query operations
+ ValidationException exception = assertThrows(
+ ValidationException.class,
+ () -> SparqlQueryValidator.validate(PROLOGUE + update)
+ );
+ assertEquals(SparqlQueryValidator.MESSAGE_INVALID_QUERY, exception.getMessage());
+ }
+
+}
diff --git a/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/TestSearchSparqlController.java b/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/TestSearchSparqlController.java
deleted file mode 100644
index e98eb9f1e..000000000
--- a/src/test/java/org/fairdatateam/fairdatapoint/acceptance/search/sparql/TestSearchSparqlController.java
+++ /dev/null
@@ -1,198 +0,0 @@
-/**
- * The MIT License
- * Copyright © 2017 FAIR Data Team
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- */
-package org.fairdatateam.fairdatapoint.acceptance.search.sparql;
-
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import org.fairdatateam.fairdatapoint.WebIntegrationTest;
-import org.fairdatateam.fairdatapoint.api.controller.search.SearchSparqlController;
-import org.eclipse.rdf4j.rio.RDFFormat;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
-import org.springframework.http.*;
-
-import java.net.URI;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Set;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-@DisplayName("POST /search/sparql")
-public class TestSearchSparqlController extends WebIntegrationTest {
-
- private final URI url = URI.create("/search/sparql");
-
- private final ObjectMapper jsonMapper = new ObjectMapper();
-
- private final String querySelectAll = "SELECT * WHERE { ?s ?p ?o }";
-
- @Test
- public void postSparqlUnauthenticated() throws JsonProcessingException {
- // prepare request
- SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
- querySelectAll, null, null);
- RequestEntity> request = RequestEntity
- .post(url)
- .accept(MediaType.APPLICATION_JSON)
- .body(sparqlQuery);
-
- // perform
- ResponseEntity response = client.exchange(request, String.class);
-
- // evaluate
- // TODO: this should actually be HttpStatus.UNAUTHORIZED, but FDP returns the wrong status code (see #704)
- assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
- HashMap responseBodyMap = jsonMapper.readValue(response.getBody(), new TypeReference<>() {
- });
- assertTrue(responseBodyMap.containsKey("error"));
- }
-
- @ParameterizedTest
- @ValueSource(strings = { MediaType.APPLICATION_JSON_VALUE, "*/*" })
- public void postSparqlSelectAll(String acceptHeader) throws JsonProcessingException {
- // prepare request
- SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
- querySelectAll, null, null);
- RequestEntity request = RequestEntity
- .post(url)
- .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN)
- .accept(MediaType.valueOf(acceptHeader))
- .contentType(MediaType.APPLICATION_JSON)
- .body(sparqlQuery);
-
- // perform
- ResponseEntity response = client.exchange(request, String.class);
-
- // evaluate
- assertEquals(HttpStatus.OK, response.getStatusCode());
- HashMap responseBodyMap = jsonMapper.readValue(response.getBody(), new TypeReference<>() {
- });
-
- // expected SPARQL SELECT result structure: https://www.w3.org/TR/sparql11-results-json/
- assertEquals(Set.of("head", "results"), responseBodyMap.keySet());
- if (responseBodyMap.get("results") instanceof HashMap, ?> results) {
- assertEquals(Set.of("bindings"), results.keySet());
- }
- }
-
- @Test
- public void postSparqlAskAny() throws JsonProcessingException {
- // prepare request
- SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
- "ASK { ?s ?p ?o }", null, null);
- RequestEntity> request = RequestEntity
- .post(url)
- .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN)
- .accept(MediaType.APPLICATION_JSON)
- .body(sparqlQuery);
-
- // perform
- ResponseEntity response = client.exchange(request, String.class);
-
- // evaluate
- assertEquals(HttpStatus.OK, response.getStatusCode());
- HashMap responseBodyMap = jsonMapper.readValue(response.getBody(), new TypeReference<>() {
- });
-
- // expected SPARQL ASK result structure: https://www.w3.org/TR/sparql11-results-json/
- assertEquals(Set.of("head", "boolean"), responseBodyMap.keySet());
- if (responseBodyMap.get("boolean") instanceof Boolean bool) {
- assertTrue(bool);
- }
- }
-
- @ParameterizedTest
- @ValueSource(strings = {
- "CONSTRUCT WHERE { ?s a }",
- "DESCRIBE ?s WHERE { ?s a }"
- })
- public void postSparqlConstructOrDescribe(String query) throws JsonProcessingException {
- // prepare request
- SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
- query, null, null);
- RequestEntity> request = RequestEntity
- .post(url)
- .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN)
- // QueryTypes.CONSTRUCT_OR_DESCRIBE does not support simple JSON, only application/ld+json (or ttl, n3)
- .accept(MediaType.valueOf(RDFFormat.JSONLD.getDefaultMIMEType()))
- .body(sparqlQuery);
-
- // perform
- ResponseEntity response = client.exchange(request, String.class);
-
- // evaluate
- assertEquals(HttpStatus.OK, response.getStatusCode());
- List> responseBodyList = jsonMapper.readValue(
- response.getBody(), new TypeReference<>() {}
- );
- assertFalse(responseBodyList.isEmpty());
- }
-
-
- /**
- * Verify that SPARQL Update operations are disallowed.
- * The SparqlQueryEvaluator implements a whitelist in the QueryTypes enum.
- */
- @ParameterizedTest
- @ValueSource(strings = {
- // https://www.w3.org/TR/sparql11-update/#graphUpdate
- "INSERT DATA { ex:test1 dc:title \"test\" }",
- "INSERT { ?s dc:title ?ol } WHERE { ?s dc:title ?o . BIND( STRLANG(STR(?o), \"en\") AS ?ol ) . }",
- "DELETE DATA { ?s dc:title ?o } WHERE { ?s dc:title ?o }",
- "DELETE WHERE { ?s dc:title ?o }",
- "LOAD dc:",
- "CLEAR GRAPH ex:",
- // https://www.w3.org/TR/sparql11-update/#graphManagement
- "CREATE GRAPH ex:",
- "DROP GRAPH ex:",
- "COPY DEFAULT TO GRAPH ex:",
- "MOVE DEFAULT TO GRAPH ex:",
- "ADD DEFAULT TO GRAPH ex:"
- })
- public void postSparqlUpdateDenied(String update) throws JsonProcessingException {
- // common prefixes (part of prologue in sparql grammar)
- final String prologue = """
- PREFIX dc:
- PREFIX ex:
- """;
-
- // prepare request
- SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
- prologue + update, null, null);
- RequestEntity> request = RequestEntity
- .post(url)
- .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN)
- .accept(MediaType.APPLICATION_JSON)
- .body(sparqlQuery);
-
- // perform
- ResponseEntity response = client.exchange(request, String.class);
-
- // SPARQL Update operations should always be denied
- assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
- }
-}