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     return switch (protocol) {
143       case WMS, WMTS -> doProxy(buildWMSUrl(service, request), service, request);
144       case LEGEND -> {
145         URI legendURI = buildLegendURI(service, layer, request);
146         if (legendURI == null) {
147           logger.warn("No legend URL found for layer {}", layer.getName());
148           yield null;
149         }
150         yield doProxy(legendURI, service, request);
151       }
152       case TILES3D ->
153         throw new ResponseStatusException(
154             HttpStatus.BAD_REQUEST, "Incorrect 3D Tiles proxy request: No path to capabilities or content");
155       default ->
156         throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported proxy protocol: " + protocol);
157     };
158   }
159 
160   private void checkRequestValidity(
161       Application application,
162       GeoService service,
163       GeoServiceLayer layer,
164       GeoServiceProtocol protocol,
165       HttpServletRequest request) {
166     if (service == null || layer == null) {
167       throw new ResponseStatusException(HttpStatus.NOT_FOUND);
168     }
169 
170     if (GeoServiceProtocol.XYZ.equals(protocol)) {
171       throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "XYZ proxying not implemented");
172     }
173 
174     if (!(service.getProtocol().equals(protocol) || GeoServiceProtocol.LEGEND.equals(protocol))) {
175       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid proxy protocol: " + protocol);
176     }
177 
178     if (!service.getSettings().getUseProxy()) {
179       throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Proxy not enabled for requested service");
180     }
181 
182     // check if there are multiple LAYERS parameters or multiple values for the LAYERS parameter,
183     // which is not supported by this proxy and can be a sign of an attempt to bypass the layer name check below
184     long wmsLayerParamCount = request.getParameterMap().entrySet().stream()
185         .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
186         .count();
187     boolean hasMultipleLayerValues = request.getParameterMap().entrySet().stream()
188         .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
189         .anyMatch(entry -> entry.getValue().length > 1
190             || (entry.getValue().length == 1 && entry.getValue()[0].indexOf(',') >= 0));
191     if (wmsLayerParamCount > 1 || hasMultipleLayerValues) {
192       throw new ResponseStatusException(
193           HttpStatus.BAD_REQUEST, "Multiple layers in LAYERS parameter not supported");
194     }
195 
196     // this can be null in case of requests that do not have a LAYERS parameter, such as GetCapabilities requests.
197     String layerNameParamValue = request.getParameterMap().entrySet().stream()
198         .filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
199         .findFirst()
200         .map(entry -> entry.getValue()[0])
201         .orElse(null);
202 
203     if (layerNameParamValue != null && !layer.getName().equals(layerNameParamValue)) {
204       // check if layer matches any passthrough pattern, if not throw bad request
205       if (proxyLayerPassthroughPatterns.stream().noneMatch(pattern -> {
206         return Pattern.compile(pattern.formatted(Pattern.quote(layer.getName())))
207             .matcher(layerNameParamValue)
208             .matches();
209       })) {
210         throw new ResponseStatusException(
211             HttpStatus.BAD_REQUEST, "Requested layer name does not match expected layer");
212       }
213 
214       if (!proxyPassthroughHostNames.isEmpty()) {
215         // check if host matches any passthrough hostname, if not throw bad request
216         try {
217           String geoServiceHostName = new URI(service.getUrl()).getHost();
218           if (proxyPassthroughHostNames.stream()
219               .noneMatch(hostname -> hostname.equalsIgnoreCase(geoServiceHostName))) {
220             throw new ResponseStatusException(
221                 HttpStatus.BAD_REQUEST,
222                 "Requested service hostname does not match allowed hostnames for layer passthrough");
223           }
224         } catch (URISyntaxException e) {
225           logger.error(
226               "Invalid service URL \"{}\" for layer id {}: {}",
227               service.getUrl(),
228               layer.getId(),
229               e.getMessage());
230           throw new ResponseStatusException(
231               HttpStatus.INTERNAL_SERVER_ERROR, "Invalid service URL in configuration");
232         }
233       }
234     }
235 
236     if (authorisationService.mustDenyAccessForSecuredProxy(service)) {
237       logger.debug(
238           "Denying proxy for layer \"{}\" in app #{} (\"{}\") from secured service #{} (URL {}): user is not authenticated",
239           layer.getName(),
240           application.getId(),
241           application.getName(),
242           service.getId(),
243           service.getUrl());
244       throw new ResponseStatusException(HttpStatus.FORBIDDEN);
245     }
246   }
247 
248   private @Nullable URI buildLegendURI(GeoService service, GeoServiceLayer layer, HttpServletRequest request) {
249     URI legendURI = GeoServiceHelper.getLayerLegendUrlFromStyles(service, layer);
250     if (legendURI == null) {
251       return null;
252     }
253 
254     // If the original service legend URL is not a GetLegendGraphic request, do not add any parameters from the
255     // incoming request and proxy the request as is
256     String wmsRequest = getWmsRequest(legendURI);
257     if (!"getlegendgraphic".equalsIgnoreCase(wmsRequest)) {
258       return legendURI;
259     }
260 
261     // Add all parameters from the incoming request to allow for vendor-specific parameters to enhance the legend
262     // image, such as font antialiasing, hi-DPI, label margins, and so on
263     UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(legendURI);
264     if (request.getParameterMap() != null) {
265       request.getParameterMap().forEach((key, values) -> {
266         for (String value : values) {
267           uriComponentsBuilder.replaceQueryParam(key, UriUtils.encode(value, StandardCharsets.UTF_8));
268         }
269       });
270     }
271     // Make sure the REQUEST parameter is set to GetLegendGraphic. No SSRF risk as WMSes should not do anything with
272     // other parameters other than returning a legend image
273     uriComponentsBuilder.replaceQueryParam("REQUEST", "GetLegendGraphic");
274 
275     return uriComponentsBuilder.build(true).toUri();
276   }
277 
278   private URI buildWMSUrl(GeoService service, HttpServletRequest request) {
279     final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
280     // request.getParameterMap() includes parameters from an application/x-www-form-urlencoded POST
281     // body
282     final MultiValueMap<String, String> requestParams = request.getParameterMap().entrySet().stream()
283         .map(entry -> new AbstractMap.SimpleEntry<>(
284             entry.getKey(),
285             Arrays.stream(entry.getValue())
286                 .map(value -> UriUtils.encode(value, StandardCharsets.UTF_8))
287                 .collect(Collectors.toList())))
288         .collect(Collectors.toMap(
289             Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedMultiValueMap::new));
290     final MultiValueMap<String, String> params =
291         buildOgcProxyRequestParams(originalServiceUrl.build(true).getQueryParams(), requestParams);
292     originalServiceUrl.replaceQueryParams(params);
293     return originalServiceUrl.build(true).toUri();
294   }
295 
296   public static MultiValueMap<String, String> buildOgcProxyRequestParams(
297       MultiValueMap<String, String> originalServiceParams, MultiValueMap<String, String> requestParams) {
298     // Start with all the parameters from the request
299     final MultiValueMap<String, String> params = new LinkedMultiValueMap<>(requestParams);
300 
301     // Add original service URL parameters if they are not required OGC params (case-insensitive)
302     // and not already set by request
303     final List<String> ogcParams = List.of(new String[] {"SERVICE", "REQUEST", "VERSION"});
304     for (Map.Entry<String, List<String>> serviceParam : originalServiceParams.entrySet()) {
305       if (!params.containsKey(serviceParam.getKey())
306           && !ogcParams.contains(serviceParam.getKey().toUpperCase(Locale.ROOT))) {
307         params.put(serviceParam.getKey(), serviceParam.getValue());
308       }
309     }
310     return params;
311   }
312 
313   private URI build3DTilesUrl(GeoService service, HttpServletRequest request) {
314     // The URL in the GeoService refers to the location of the JSON file describing the tileset,
315     // e.g. example.com/buildings/3dtiles. The paths to the subtrees and tiles of the tilesets do
316     // not include the '/3dtiles' (or '/tileset.json') part of the path. Their paths are e.g.
317     // example.com/buildings/subtrees/... or example.com/buildings/t/...
318     final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
319     String baseUrl = originalServiceUrl.build(true).toUriString();
320     String[] parts = request.getRequestURI().split("/proxy/tiles3d/", 2);
321     if (parts.length < 2) {
322       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles proxy path");
323     }
324     String pathToContent = parts[1];
325 
326     // Prevent path traversal
327     if (pathToContent.contains("..") || pathToContent.contains("\\")) {
328       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 3D tiles content path");
329     }
330 
331     // Return service URL when the request is for the JSON file describing the tileset
332     if (Objects.equals(pathToContent, TILES3D_DESCRIPTION_PATH)) {
333       return UriComponentsBuilder.fromUriString(baseUrl).build(true).toUri();
334     }
335 
336     // Remove the part of the service URL referring to the JSON file describing the tileset
337     int lastSlashIndex = baseUrl.lastIndexOf('/');
338     if (lastSlashIndex != -1) {
339       baseUrl = baseUrl.substring(0, lastSlashIndex + 1);
340     }
341 
342     // Return final URL with specific path to the tile or subtree
343     String finalUrl = baseUrl + pathToContent;
344     return UriComponentsBuilder.fromUriString(finalUrl).build(true).toUri();
345   }
346 
347   private ResponseEntity<?> doProxy(URI uri, GeoService service, HttpServletRequest request) {
348     HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
349 
350     configureProxyRequestBuilderForUri(requestBuilder, uri, request);
351 
352     addForwardedForRequestHeaders(requestBuilder, request);
353 
354     passthroughRequestHeaders(
355         requestBuilder,
356         request,
357         Set.of(
358             ACCEPT,
359             IF_MODIFIED_SINCE,
360             IF_UNMODIFIED_SINCE,
361             IF_MATCH,
362             IF_NONE_MATCH,
363             IF_RANGE,
364             RANGE,
365             REFERER,
366             USER_AGENT));
367 
368     if (service.getAuthentication() != null
369         && service.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
370       setHttpBasicAuthenticationHeader(
371           requestBuilder,
372           service.getAuthentication().getUsername(),
373           service.getAuthentication().getPassword());
374     }
375 
376     try {
377       // TODO: close JPA connection before proxying
378       HttpResponse<InputStream> response =
379           this.httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
380 
381       // If the server does not accept our credentials, it might 'hide' the layer or even send a 401
382       // Unauthorized status. We do not send the WWW-Authenticate header back, so the client will
383       // get the error but not an authorization popup.
384       // It would be nice if proxy (auth) errors were logged and available in the admin interface.
385       // Currently, a layer will just stop working if the geo service credentials are changed
386       // without updating them in the geo service registry.
387       InputStreamResource body = new InputStreamResource(response.body());
388       HttpHeaders headers = passthroughResponseHeaders(
389           response.headers(),
390           Set.of(
391               CONTENT_TYPE,
392               CONTENT_LENGTH,
393               CONTENT_RANGE,
394               CONTENT_DISPOSITION,
395               CACHE_CONTROL,
396               EXPIRES,
397               LAST_MODIFIED,
398               ETAG,
399               PRAGMA));
400       return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
401     } catch (Exception e) {
402       return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
403     }
404   }
405 }