View Javadoc
1   /*
2    * Copyright (C) 2022 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.controller;
7   
8   import static org.springframework.http.HttpHeaders.ACCEPT;
9   import static org.springframework.http.HttpHeaders.CACHE_CONTROL;
10  import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION;
11  import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
12  import static org.springframework.http.HttpHeaders.CONTENT_RANGE;
13  import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
14  import static org.springframework.http.HttpHeaders.ETAG;
15  import static org.springframework.http.HttpHeaders.EXPIRES;
16  import static org.springframework.http.HttpHeaders.IF_MATCH;
17  import static org.springframework.http.HttpHeaders.IF_MODIFIED_SINCE;
18  import static org.springframework.http.HttpHeaders.IF_NONE_MATCH;
19  import static org.springframework.http.HttpHeaders.IF_RANGE;
20  import static org.springframework.http.HttpHeaders.IF_UNMODIFIED_SINCE;
21  import static org.springframework.http.HttpHeaders.LAST_MODIFIED;
22  import static org.springframework.http.HttpHeaders.PRAGMA;
23  import static org.springframework.http.HttpHeaders.RANGE;
24  import static org.springframework.http.HttpHeaders.REFERER;
25  import static org.springframework.http.HttpHeaders.USER_AGENT;
26  import static org.springframework.web.bind.annotation.RequestMethod.GET;
27  import static org.springframework.web.bind.annotation.RequestMethod.POST;
28  import static org.tailormap.api.persistence.helper.GeoServiceHelper.getWmsRequest;
29  import static org.tailormap.api.util.HttpProxyUtil.addForwardedForRequestHeaders;
30  import static org.tailormap.api.util.HttpProxyUtil.configureProxyRequestBuilderForUri;
31  import static org.tailormap.api.util.HttpProxyUtil.passthroughRequestHeaders;
32  import static org.tailormap.api.util.HttpProxyUtil.passthroughResponseHeaders;
33  import static org.tailormap.api.util.HttpProxyUtil.setHttpBasicAuthenticationHeader;
34  
35  import io.micrometer.core.annotation.Timed;
36  import jakarta.annotation.Nullable;
37  import jakarta.servlet.http.HttpServletRequest;
38  import java.io.InputStream;
39  import java.lang.invoke.MethodHandles;
40  import java.net.URI;
41  import java.net.URISyntaxException;
42  import java.net.http.HttpClient;
43  import java.net.http.HttpRequest;
44  import java.net.http.HttpResponse;
45  import java.nio.charset.StandardCharsets;
46  import java.util.AbstractMap;
47  import java.util.Arrays;
48  import java.util.List;
49  import java.util.Locale;
50  import java.util.Map;
51  import java.util.Objects;
52  import java.util.Set;
53  import java.util.regex.Pattern;
54  import java.util.stream.Collectors;
55  import org.slf4j.Logger;
56  import org.slf4j.LoggerFactory;
57  import org.springframework.beans.factory.annotation.Value;
58  import org.springframework.core.io.InputStreamResource;
59  import org.springframework.http.HttpHeaders;
60  import org.springframework.http.HttpStatus;
61  import org.springframework.http.ResponseEntity;
62  import org.springframework.util.LinkedMultiValueMap;
63  import org.springframework.util.MultiValueMap;
64  import org.springframework.validation.annotation.Validated;
65  import org.springframework.web.bind.annotation.ModelAttribute;
66  import org.springframework.web.bind.annotation.PathVariable;
67  import org.springframework.web.bind.annotation.RequestMapping;
68  import org.springframework.web.server.ResponseStatusException;
69  import org.springframework.web.util.UriComponentsBuilder;
70  import org.springframework.web.util.UriUtils;
71  import org.tailormap.api.annotation.AppRestController;
72  import org.tailormap.api.persistence.Application;
73  import org.tailormap.api.persistence.GeoService;
74  import org.tailormap.api.persistence.helper.GeoServiceHelper;
75  import org.tailormap.api.persistence.json.GeoServiceLayer;
76  import org.tailormap.api.persistence.json.GeoServiceProtocol;
77  import org.tailormap.api.persistence.json.ServiceAuthentication;
78  import org.tailormap.api.security.AuthorisationService;
79  
80  /**
81   * Proxy controller for OGC WMS, WMTS, and 3D Tiles services. Does not attempt to hide the original service URL. Mostly
82   * useful for access to HTTP Basic secured services without sending the credentials to the client. The access control is
83   * handled by Spring Security and the authorizations configured on the service.
84   *
85   * <p>Only supports GET requests. Does not support CORS, only meant for tailormap-viewer from the same origin.
86   *
87   * <p>Implementation note: uses the Java 11 HttpClient. Spring cloud gateway can proxy with many more features but
88   * cannot be used in a non-reactive application.
89   */
90  @AppRestController
91  @Validated
92  // Can't use ${tailormap-api.base-path} because linkTo() won't work
93  @RequestMapping(path = "/api/{viewerKind}/{viewerName}/layer/{appLayerId}/proxy")
94  public class GeoServiceProxyController {
95    private static final Logger logger =
96        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
97    private final AuthorisationService authorisationService;
98    private final HttpClient httpClient;
99  
100   public static final String TILES3D_DESCRIPTION_PATH = "tiles3dDescription";
101 
102   @Value("${tailormap-api.proxy.passthrough.layerpatterns:}")
103   private Set<String> proxyLayerPassthroughPatterns = Set.of();
104 
105   @Value("${tailormap-api.proxy.passthrough.hostnames:}")
106   private Set<String> proxyPassthroughHostNames = Set.of();
107 
108   public GeoServiceProxyController(AuthorisationService authorisationService) {
109     this.authorisationService = authorisationService;
110 
111     final HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL);
112     this.httpClient = builder.build();
113   }
114 
115   @RequestMapping(
116       method = {GET, POST},
117       path = "/tiles3d/**")
118   public ResponseEntity<?> proxy3dtiles(
119       @ModelAttribute Application application,
120       @ModelAttribute GeoService service,
121       @ModelAttribute GeoServiceLayer layer,
122       HttpServletRequest request) {
123 
124     checkRequestValidity(application, service, layer, GeoServiceProtocol.TILES3D, request);
125 
126     return doProxy(build3DTilesUrl(service, request), service, request);
127   }
128 
129   @RequestMapping(
130       method = {GET, POST},
131       path = "/{protocol}")
132   @Timed(value = "proxy", description = "Proxy OGC service calls")
133   public ResponseEntity<?> proxy(
134       @ModelAttribute Application application,
135       @ModelAttribute GeoService service,
136       @ModelAttribute GeoServiceLayer layer,
137       @PathVariable("protocol") GeoServiceProtocol protocol,
138       HttpServletRequest request) {
139 
140     checkRequestValidity(application, service, layer, protocol, request);
141 
142     switch (protocol) {
143       case WMS, WMTS -> {
144         return doProxy(buildWMSUrl(service, request), service, request);
145       }
146       case LEGEND -> {
147         URI legendURI = buildLegendURI(service, layer, request);
148         if (legendURI == null) {
149           logger.warn("No legend URL found for layer {}", layer.getName());
150           return null;
151         }
152         return doProxy(legendURI, service, request);
153       }
154       case TILES3D ->
155         throw new ResponseStatusException(
156             HttpStatus.BAD_REQUEST, "Incorrect 3D Tiles proxy request: No path to capabilities or content");
157       default ->
158         throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported proxy protocol: " + protocol);
159     }
160   }
161 
162   private void checkRequestValidity(
163       Application application,
164       GeoService service,
165       GeoServiceLayer layer,
166       GeoServiceProtocol protocol,
167       HttpServletRequest request) {
168     if (service == null || layer == null) {
169       throw new ResponseStatusException(HttpStatus.NOT_FOUND);
170     }
171 
172     if (GeoServiceProtocol.XYZ.equals(protocol)) {
173       throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "XYZ proxying not implemented");
174     }
175 
176     if (!(service.getProtocol().equals(protocol) || GeoServiceProtocol.LEGEND.equals(protocol))) {
177       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid proxy protocol: " + protocol);
178     }
179 
180     if (!service.getSettings().getUseProxy()) {
181       throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Proxy not enabled for requested service");
182     }
183 
184     // check if there are multiple LAYERS parameters or multiple values for the LAYERS parameter,
185     // which is not supported by this proxy and can be a sign of an attempt to bypass the layer name check below
186     long wmsLayerParamCount = request.getParameterMap().entrySet().stream()
187         .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
188         .count();
189     boolean hasMultipleLayerValues = request.getParameterMap().entrySet().stream()
190         .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
191         .anyMatch(entry -> entry.getValue().length > 1
192             || (entry.getValue().length == 1 && entry.getValue()[0].indexOf(',') >= 0));
193     if (wmsLayerParamCount > 1 || hasMultipleLayerValues) {
194       throw new ResponseStatusException(
195           HttpStatus.BAD_REQUEST, "Multiple layers in LAYERS parameter not supported");
196     }
197 
198     // this can be null in case of requests that do not have a LAYERS parameter, such as GetCapabilities requests.
199     String layerNameParamValue = request.getParameterMap().entrySet().stream()
200         .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
201         .findFirst()
202         .map(entry -> entry.getValue()[0])
203         .orElse(null);
204 
205     if (layerNameParamValue != null && !layer.getName().equals(layerNameParamValue)) {
206       // check if layer matches any passthrough pattern, if not throw bad request
207       if (proxyLayerPassthroughPatterns.stream().noneMatch(pattern -> {
208         String regex = String.format(pattern, Pattern.quote(layer.getName()));
209         return Pattern.compile(regex).matcher(layerNameParamValue).matches();
210       })) {
211         throw new ResponseStatusException(
212             HttpStatus.BAD_REQUEST, "Requested layer name does not match expected layer");
213       }
214 
215       if (!proxyPassthroughHostNames.isEmpty()) {
216         // check if host matches any passthrough hostname, if not throw bad request
217         try {
218           String geoServiceHostName = new URI(service.getUrl()).getHost();
219           if (proxyPassthroughHostNames.stream()
220               .noneMatch(hostname -> hostname.equalsIgnoreCase(geoServiceHostName))) {
221             throw new ResponseStatusException(
222                 HttpStatus.BAD_REQUEST,
223                 "Requested service hostname does not match allowed hostnames for layer passthrough");
224           }
225         } catch (URISyntaxException e) {
226           logger.error(
227               "Invalid service URL \"{}\" for layer id {}: {}",
228               service.getUrl(),
229               layer.getId(),
230               e.getMessage());
231           throw new ResponseStatusException(
232               HttpStatus.INTERNAL_SERVER_ERROR, "Invalid service URL in configuration");
233         }
234       }
235     }
236 
237     if (authorisationService.mustDenyAccessForSecuredProxy(service)) {
238       logger.debug(
239           "Denying proxy for layer \"{}\" in app #{} (\"{}\") from secured service #{} (URL {}): user is not authenticated",
240           layer.getName(),
241           application.getId(),
242           application.getName(),
243           service.getId(),
244           service.getUrl());
245       throw new ResponseStatusException(HttpStatus.FORBIDDEN);
246     }
247   }
248 
249   private @Nullable URI buildLegendURI(GeoService service, GeoServiceLayer layer, HttpServletRequest request) {
250     URI legendURI = GeoServiceHelper.getLayerLegendUrlFromStyles(service, layer);
251     if (legendURI == null) {
252       return null;
253     }
254 
255     // If the original service legend URL is not a GetLegendGraphic request, do not add any parameters from the
256     // incoming request and proxy the request as is
257     String wmsRequest = getWmsRequest(legendURI);
258     if (!"getlegendgraphic".equalsIgnoreCase(wmsRequest)) {
259       return legendURI;
260     }
261 
262     // Add all parameters from the incoming request to allow for vendor-specific parameters to enhance the legend
263     // image, such as font antialiasing, hi-DPI, label margins, and so on
264     UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(legendURI);
265     if (request.getParameterMap() != null) {
266       request.getParameterMap().forEach((key, values) -> {
267         for (String value : values) {
268           uriComponentsBuilder.replaceQueryParam(key, UriUtils.encode(value, StandardCharsets.UTF_8));
269         }
270       });
271     }
272     // Make sure the REQUEST parameter is set to GetLegendGraphic. No SSRF risk as WMSes should not do anything with
273     // other parameters other than returning a legend image
274     uriComponentsBuilder.replaceQueryParam("REQUEST", "GetLegendGraphic");
275 
276     return uriComponentsBuilder.build(true).toUri();
277   }
278 
279   private URI buildWMSUrl(GeoService service, HttpServletRequest request) {
280     final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
281     // request.getParameterMap() includes parameters from an application/x-www-form-urlencoded POST
282     // body
283     final MultiValueMap<String, String> requestParams = request.getParameterMap().entrySet().stream()
284         .map(entry -> new AbstractMap.SimpleEntry<>(
285             entry.getKey(),
286             Arrays.stream(entry.getValue())
287                 .map(value -> UriUtils.encode(value, StandardCharsets.UTF_8))
288                 .collect(Collectors.toList())))
289         .collect(Collectors.toMap(
290             Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedMultiValueMap::new));
291     final MultiValueMap<String, String> params =
292         buildOgcProxyRequestParams(originalServiceUrl.build(true).getQueryParams(), requestParams);
293     originalServiceUrl.replaceQueryParams(params);
294     return originalServiceUrl.build(true).toUri();
295   }
296 
297   public static MultiValueMap<String, String> buildOgcProxyRequestParams(
298       MultiValueMap<String, String> originalServiceParams, MultiValueMap<String, String> requestParams) {
299     // Start with all the parameters from the request
300     final MultiValueMap<String, String> params = new LinkedMultiValueMap<>(requestParams);
301 
302     // Add original service URL parameters if they are not required OGC params (case-insensitive)
303     // and not already set by request
304     final List<String> ogcParams = List.of(new String[] {"SERVICE", "REQUEST", "VERSION"});
305     for (Map.Entry<String, List<String>> serviceParam : originalServiceParams.entrySet()) {
306       if (!params.containsKey(serviceParam.getKey())
307           && !ogcParams.contains(serviceParam.getKey().toUpperCase(Locale.ROOT))) {
308         params.put(serviceParam.getKey(), serviceParam.getValue());
309       }
310     }
311     return params;
312   }
313 
314   private URI build3DTilesUrl(GeoService service, HttpServletRequest request) {
315     // The URL in the GeoService refers to the location of the JSON file describing the tileset,
316     // e.g. example.com/buildings/3dtiles. The paths to the subtrees and tiles of the tilesets do
317     // not include the '/3dtiles' (or '/tileset.json') part of the path. Their paths are e.g.
318     // example.com/buildings/subtrees/... or example.com/buildings/t/...
319     final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
320     String baseUrl = originalServiceUrl.build(true).toUriString();
321     String[] parts = request.getRequestURI().split("/proxy/tiles3d/", 2);
322     if (parts.length < 2) {
323       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles proxy path");
324     }
325     String pathToContent = parts[1];
326 
327     // Prevent path traversal
328     if (pathToContent.contains("..") || pathToContent.contains("\\")) {
329       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles content path");
330     }
331 
332     // Return service URL when the request is for the JSON file describing the tileset
333     if (Objects.equals(pathToContent, TILES3D_DESCRIPTION_PATH)) {
334       return UriComponentsBuilder.fromUriString(baseUrl).build(true).toUri();
335     }
336 
337     // Remove the part of the service URL referring to the JSON file describing the tileset
338     int lastSlashIndex = baseUrl.lastIndexOf('/');
339     if (lastSlashIndex != -1) {
340       baseUrl = baseUrl.substring(0, lastSlashIndex + 1);
341     }
342 
343     // Return final URL with specific path to the tile or subtree
344     String finalUrl = baseUrl + pathToContent;
345     return UriComponentsBuilder.fromUriString(finalUrl).build(true).toUri();
346   }
347 
348   private ResponseEntity<?> doProxy(URI uri, GeoService service, HttpServletRequest request) {
349     HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
350 
351     configureProxyRequestBuilderForUri(requestBuilder, uri, request);
352 
353     addForwardedForRequestHeaders(requestBuilder, request);
354 
355     passthroughRequestHeaders(
356         requestBuilder,
357         request,
358         Set.of(
359             ACCEPT,
360             IF_MODIFIED_SINCE,
361             IF_UNMODIFIED_SINCE,
362             IF_MATCH,
363             IF_NONE_MATCH,
364             IF_RANGE,
365             RANGE,
366             REFERER,
367             USER_AGENT));
368 
369     if (service.getAuthentication() != null
370         && service.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
371       setHttpBasicAuthenticationHeader(
372           requestBuilder,
373           service.getAuthentication().getUsername(),
374           service.getAuthentication().getPassword());
375     }
376 
377     try {
378       // TODO: close JPA connection before proxying
379       HttpResponse<InputStream> response =
380           this.httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
381 
382       // If the server does not accept our credentials, it might 'hide' the layer or even send a 401
383       // Unauthorized status. We do not send the WWW-Authenticate header back, so the client will
384       // get the error but not an authorization popup.
385       // It would be nice if proxy (auth) errors were logged and available in the admin interface.
386       // Currently, a layer will just stop working if the geo service credentials are changed
387       // without updating them in the geo service registry.
388       InputStreamResource body = new InputStreamResource(response.body());
389       HttpHeaders headers = passthroughResponseHeaders(
390           response.headers(),
391           Set.of(
392               CONTENT_TYPE,
393               CONTENT_LENGTH,
394               CONTENT_RANGE,
395               CONTENT_DISPOSITION,
396               CACHE_CONTROL,
397               EXPIRES,
398               LAST_MODIFIED,
399               ETAG,
400               PRAGMA));
401       return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
402     } catch (Exception e) {
403       return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
404     }
405   }
406 }