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