GeoServiceProxyController.java
/*
* Copyright (C) 2022 B3Partners B.V.
*
* SPDX-License-Identifier: MIT
*/
package org.tailormap.api.controller;
import static org.springframework.http.HttpHeaders.ACCEPT;
import static org.springframework.http.HttpHeaders.CACHE_CONTROL;
import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION;
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
import static org.springframework.http.HttpHeaders.CONTENT_RANGE;
import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
import static org.springframework.http.HttpHeaders.ETAG;
import static org.springframework.http.HttpHeaders.EXPIRES;
import static org.springframework.http.HttpHeaders.IF_MATCH;
import static org.springframework.http.HttpHeaders.IF_MODIFIED_SINCE;
import static org.springframework.http.HttpHeaders.IF_NONE_MATCH;
import static org.springframework.http.HttpHeaders.IF_RANGE;
import static org.springframework.http.HttpHeaders.IF_UNMODIFIED_SINCE;
import static org.springframework.http.HttpHeaders.LAST_MODIFIED;
import static org.springframework.http.HttpHeaders.PRAGMA;
import static org.springframework.http.HttpHeaders.RANGE;
import static org.springframework.http.HttpHeaders.REFERER;
import static org.springframework.http.HttpHeaders.USER_AGENT;
import static org.springframework.web.bind.annotation.RequestMethod.GET;
import static org.springframework.web.bind.annotation.RequestMethod.POST;
import static org.tailormap.api.persistence.helper.GeoServiceHelper.getWmsRequest;
import static org.tailormap.api.util.HttpProxyUtil.addForwardedForRequestHeaders;
import static org.tailormap.api.util.HttpProxyUtil.configureProxyRequestBuilderForUri;
import static org.tailormap.api.util.HttpProxyUtil.passthroughRequestHeaders;
import static org.tailormap.api.util.HttpProxyUtil.passthroughResponseHeaders;
import static org.tailormap.api.util.HttpProxyUtil.setHttpBasicAuthenticationHeader;
import io.micrometer.core.annotation.Timed;
import jakarta.annotation.Nullable;
import jakarta.servlet.http.HttpServletRequest;
import java.io.InputStream;
import java.lang.invoke.MethodHandles;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import org.tailormap.api.annotation.AppRestController;
import org.tailormap.api.persistence.Application;
import org.tailormap.api.persistence.GeoService;
import org.tailormap.api.persistence.helper.GeoServiceHelper;
import org.tailormap.api.persistence.json.GeoServiceLayer;
import org.tailormap.api.persistence.json.GeoServiceProtocol;
import org.tailormap.api.persistence.json.ServiceAuthentication;
import org.tailormap.api.security.AuthorisationService;
/**
* Proxy controller for OGC WMS, WMTS, and 3D Tiles services. Does not attempt to hide the original service URL. Mostly
* useful for access to HTTP Basic secured services without sending the credentials to the client. The access control is
* handled by Spring Security and the authorizations configured on the service.
*
* <p>Only supports GET requests. Does not support CORS, only meant for tailormap-viewer from the same origin.
*
* <p>Implementation note: uses the Java 11 HttpClient. Spring cloud gateway can proxy with many more features but
* cannot be used in a non-reactive application.
*/
@AppRestController
@Validated
// Can't use ${tailormap-api.base-path} because linkTo() won't work
@RequestMapping(path = "/api/{viewerKind}/{viewerName}/layer/{appLayerId}/proxy")
public class GeoServiceProxyController {
private static final Logger logger =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private final AuthorisationService authorisationService;
private final HttpClient httpClient;
public static final String TILES3D_DESCRIPTION_PATH = "tiles3dDescription";
public GeoServiceProxyController(AuthorisationService authorisationService) {
this.authorisationService = authorisationService;
final HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL);
this.httpClient = builder.build();
}
@RequestMapping(
method = {GET, POST},
path = "/tiles3d/**")
public ResponseEntity<?> proxy3dtiles(
@ModelAttribute Application application,
@ModelAttribute GeoService service,
@ModelAttribute GeoServiceLayer layer,
HttpServletRequest request) {
checkRequestValidity(application, service, layer, GeoServiceProtocol.TILES3D);
return doProxy(build3DTilesUrl(service, request), service, request);
}
@RequestMapping(
method = {GET, POST},
path = "/{protocol}")
@Timed(value = "proxy", description = "Proxy OGC service calls")
public ResponseEntity<?> proxy(
@ModelAttribute Application application,
@ModelAttribute GeoService service,
@ModelAttribute GeoServiceLayer layer,
@PathVariable("protocol") GeoServiceProtocol protocol,
HttpServletRequest request) {
checkRequestValidity(application, service, layer, protocol);
switch (protocol) {
case WMS, WMTS -> {
return doProxy(buildWMSUrl(service, request), service, request);
}
case LEGEND -> {
URI legendURI = buildLegendURI(service, layer, request);
if (legendURI == null) {
logger.warn("No legend URL found for layer {}", layer.getName());
return null;
}
return doProxy(legendURI, service, request);
}
case TILES3D ->
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Incorrect 3D Tiles proxy request: No path to capabilities or content");
default ->
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported proxy protocol: " + protocol);
}
}
private void checkRequestValidity(
Application application, GeoService service, GeoServiceLayer layer, GeoServiceProtocol protocol) {
if (service == null || layer == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
if (GeoServiceProtocol.XYZ.equals(protocol)) {
throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "XYZ proxying not implemented");
}
if (!(service.getProtocol().equals(protocol) || GeoServiceProtocol.LEGEND.equals(protocol))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid proxy protocol: " + protocol);
}
if (!service.getSettings().getUseProxy()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Proxy not enabled for requested service");
}
if (authorisationService.mustDenyAccessForSecuredProxy(service)) {
logger.debug(
"Denying proxy for layer \"{}\" in app #{} (\"{}\") from secured service #{} (URL {}): user is not authenticated",
layer.getName(),
application.getId(),
application.getName(),
service.getId(),
service.getUrl());
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
}
private @Nullable URI buildLegendURI(GeoService service, GeoServiceLayer layer, HttpServletRequest request) {
URI legendURI = GeoServiceHelper.getLayerLegendUrlFromStyles(service, layer);
if (legendURI == null) {
return null;
}
// If the original service legend URL is not a GetLegendGraphic request, do not add any parameters from the
// incoming request and proxy the request as is
String wmsRequest = getWmsRequest(legendURI);
if (!"getlegendgraphic".equalsIgnoreCase(wmsRequest)) {
return legendURI;
}
// Add all parameters from the incoming request to allow for vendor-specific parameters to enhance the legend
// image, such as font antialiasing, hi-DPI, label margins, and so on
UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(legendURI);
if (request.getParameterMap() != null) {
request.getParameterMap().forEach((key, values) -> {
for (String value : values) {
uriComponentsBuilder.replaceQueryParam(key, UriUtils.encode(value, StandardCharsets.UTF_8));
}
});
}
// Make sure the REQUEST parameter is set to GetLegendGraphic. No SSRF risk as WMSes should not do anything with
// other parameters other than returning a legend image
uriComponentsBuilder.replaceQueryParam("REQUEST", "GetLegendGraphic");
return uriComponentsBuilder.build(true).toUri();
}
private URI buildWMSUrl(GeoService service, HttpServletRequest request) {
final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
// request.getParameterMap() includes parameters from an application/x-www-form-urlencoded POST
// body
final MultiValueMap<String, String> requestParams = request.getParameterMap().entrySet().stream()
.map(entry -> new AbstractMap.SimpleEntry<>(
entry.getKey(),
Arrays.stream(entry.getValue())
.map(value -> UriUtils.encode(value, StandardCharsets.UTF_8))
.collect(Collectors.toList())))
.collect(Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedMultiValueMap::new));
final MultiValueMap<String, String> params =
buildOgcProxyRequestParams(originalServiceUrl.build(true).getQueryParams(), requestParams);
originalServiceUrl.replaceQueryParams(params);
return originalServiceUrl.build(true).toUri();
}
public static MultiValueMap<String, String> buildOgcProxyRequestParams(
MultiValueMap<String, String> originalServiceParams, MultiValueMap<String, String> requestParams) {
// Start with all the parameters from the request
final MultiValueMap<String, String> params = new LinkedMultiValueMap<>(requestParams);
// Add original service URL parameters if they are not required OGC params (case-insensitive)
// and not already set by request
final List<String> ogcParams = List.of(new String[] {"SERVICE", "REQUEST", "VERSION"});
for (Map.Entry<String, List<String>> serviceParam : originalServiceParams.entrySet()) {
if (!params.containsKey(serviceParam.getKey())
&& !ogcParams.contains(serviceParam.getKey().toUpperCase(Locale.ROOT))) {
params.put(serviceParam.getKey(), serviceParam.getValue());
}
}
return params;
}
private URI build3DTilesUrl(GeoService service, HttpServletRequest request) {
// The URL in the GeoService refers to the location of the JSON file describing the tileset,
// e.g. example.com/buildings/3dtiles. The paths to the subtrees and tiles of the tilesets do
// not include the '/3dtiles' (or '/tileset.json') part of the path. Their paths are e.g.
// example.com/buildings/subtrees/... or example.com/buildings/t/...
final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
String baseUrl = originalServiceUrl.build(true).toUriString();
String[] parts = request.getRequestURI().split("/proxy/tiles3d/", 2);
if (parts.length < 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles proxy path");
}
String pathToContent = parts[1];
// Prevent path traversal
if (pathToContent.contains("..") || pathToContent.contains("\\")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles content path");
}
// Return service URL when the request is for the JSON file describing the tileset
if (Objects.equals(pathToContent, TILES3D_DESCRIPTION_PATH)) {
return UriComponentsBuilder.fromUriString(baseUrl).build(true).toUri();
}
// Remove the part of the service URL referring to the JSON file describing the tileset
int lastSlashIndex = baseUrl.lastIndexOf('/');
if (lastSlashIndex != -1) {
baseUrl = baseUrl.substring(0, lastSlashIndex + 1);
}
// Return final URL with specific path to the tile or subtree
String finalUrl = baseUrl + pathToContent;
return UriComponentsBuilder.fromUriString(finalUrl).build(true).toUri();
}
private ResponseEntity<?> doProxy(URI uri, GeoService service, HttpServletRequest request) {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
configureProxyRequestBuilderForUri(requestBuilder, uri, request);
addForwardedForRequestHeaders(requestBuilder, request);
passthroughRequestHeaders(
requestBuilder,
request,
Set.of(
ACCEPT,
IF_MODIFIED_SINCE,
IF_UNMODIFIED_SINCE,
IF_MATCH,
IF_NONE_MATCH,
IF_RANGE,
RANGE,
REFERER,
USER_AGENT));
if (service.getAuthentication() != null
&& service.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
setHttpBasicAuthenticationHeader(
requestBuilder,
service.getAuthentication().getUsername(),
service.getAuthentication().getPassword());
}
try {
// TODO: close JPA connection before proxying
HttpResponse<InputStream> response =
this.httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
// If the server does not accept our credentials, it might 'hide' the layer or even send a 401
// Unauthorized status. We do not send the WWW-Authenticate header back, so the client will
// get the error but not an authorization popup.
// It would be nice if proxy (auth) errors were logged and available in the admin interface.
// Currently, a layer will just stop working if the geo service credentials are changed
// without updating them in the geo service registry.
InputStreamResource body = new InputStreamResource(response.body());
HttpHeaders headers = passthroughResponseHeaders(
response.headers(),
Set.of(
CONTENT_TYPE,
CONTENT_LENGTH,
CONTENT_RANGE,
CONTENT_DISPOSITION,
CACHE_CONTROL,
EXPIRES,
LAST_MODIFIED,
ETAG,
PRAGMA));
return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
}
}
}