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()); - } -}