Skip to content

Commit f91b0d3

Browse files
committed
HTM-1893: WMS proxy does not validate LAYERS parameter against application layer name
1 parent ee1db6d commit f91b0d3

5 files changed

Lines changed: 245 additions & 4 deletions

File tree

src/main/java/org/tailormap/api/controller/GeoServiceProxyController.java

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import java.io.InputStream;
3939
import java.lang.invoke.MethodHandles;
4040
import java.net.URI;
41+
import java.net.URISyntaxException;
4142
import java.net.http.HttpClient;
4243
import java.net.http.HttpRequest;
4344
import java.net.http.HttpResponse;
@@ -49,9 +50,11 @@
4950
import java.util.Map;
5051
import java.util.Objects;
5152
import java.util.Set;
53+
import java.util.regex.Pattern;
5254
import java.util.stream.Collectors;
5355
import org.slf4j.Logger;
5456
import org.slf4j.LoggerFactory;
57+
import org.springframework.beans.factory.annotation.Value;
5558
import org.springframework.core.io.InputStreamResource;
5659
import org.springframework.http.HttpHeaders;
5760
import org.springframework.http.HttpStatus;
@@ -96,6 +99,12 @@ public class GeoServiceProxyController {
9699

97100
public static final String TILES3D_DESCRIPTION_PATH = "tiles3dDescription";
98101

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+
99108
public GeoServiceProxyController(AuthorisationService authorisationService) {
100109
this.authorisationService = authorisationService;
101110

@@ -112,7 +121,7 @@ public ResponseEntity<?> proxy3dtiles(
112121
@ModelAttribute GeoServiceLayer layer,
113122
HttpServletRequest request) {
114123

115-
checkRequestValidity(application, service, layer, GeoServiceProtocol.TILES3D);
124+
checkRequestValidity(application, service, layer, GeoServiceProtocol.TILES3D, request);
116125

117126
return doProxy(build3DTilesUrl(service, request), service, request);
118127
}
@@ -128,7 +137,7 @@ public ResponseEntity<?> proxy(
128137
@PathVariable("protocol") GeoServiceProtocol protocol,
129138
HttpServletRequest request) {
130139

131-
checkRequestValidity(application, service, layer, protocol);
140+
checkRequestValidity(application, service, layer, protocol, request);
132141

133142
switch (protocol) {
134143
case WMS, WMTS -> {
@@ -151,7 +160,11 @@ public ResponseEntity<?> proxy(
151160
}
152161

153162
private void checkRequestValidity(
154-
Application application, GeoService service, GeoServiceLayer layer, GeoServiceProtocol protocol) {
163+
Application application,
164+
GeoService service,
165+
GeoServiceLayer layer,
166+
GeoServiceProtocol protocol,
167+
HttpServletRequest request) {
155168
if (service == null || layer == null) {
156169
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
157170
}
@@ -168,6 +181,54 @@ private void checkRequestValidity(
168181
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Proxy not enabled for requested service");
169182
}
170183

184+
int wmsLayerCount = request.getParameterMap().entrySet().stream()
185+
.filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
186+
.mapToInt(entry -> entry.getValue().length)
187+
.sum();
188+
if (wmsLayerCount > 1) {
189+
throw new ResponseStatusException(
190+
HttpStatus.BAD_REQUEST, "Multiple layers in LAYERS parameter not supported");
191+
}
192+
193+
// this can be null in case of requests that do not have a LAYERS parameter, such as GetCapabilities requests.
194+
String layerNameParamValue = request.getParameterMap().entrySet().stream()
195+
.filter(entry -> "LAYERS".equalsIgnoreCase(entry.getKey()))
196+
.findFirst()
197+
.map(entry -> entry.getValue()[0])
198+
.orElse(null);
199+
200+
if (layerNameParamValue != null && !layer.getName().equals(layerNameParamValue)) {
201+
// check if layer matches any passthrough pattern, if not throw bad request
202+
if (proxyLayerPassthroughPatterns.stream().noneMatch(pattern -> {
203+
String regex = String.format(pattern, Pattern.quote(layer.getName()));
204+
return Pattern.compile(regex).matcher(layerNameParamValue).matches();
205+
})) {
206+
throw new ResponseStatusException(
207+
HttpStatus.BAD_REQUEST, "Requested layer name does not match expected layer");
208+
}
209+
210+
if (!proxyPassthroughHostNames.isEmpty()) {
211+
// check if host matches any passthrough hostname, if not throw bad request
212+
try {
213+
String geoServiceHostName = new URI(service.getUrl()).getHost();
214+
if (proxyPassthroughHostNames.stream()
215+
.noneMatch(hostname -> hostname.equalsIgnoreCase(geoServiceHostName))) {
216+
throw new ResponseStatusException(
217+
HttpStatus.BAD_REQUEST,
218+
"Requested service hostname does not match allowed hostnames for layer passthrough");
219+
}
220+
} catch (URISyntaxException e) {
221+
logger.error(
222+
"Invalid service URL \"{}\" for layer id {}: {}",
223+
service.getUrl(),
224+
layer.getId(),
225+
e.getMessage());
226+
throw new ResponseStatusException(
227+
HttpStatus.INTERNAL_SERVER_ERROR, "Invalid service URL in configuration");
228+
}
229+
}
230+
}
231+
171232
if (authorisationService.mustDenyAccessForSecuredProxy(service)) {
172233
logger.debug(
173234
"Denying proxy for layer \"{}\" in app #{} (\"{}\") from secured service #{} (URL {}): user is not authenticated",

src/main/resources/application.properties

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ tailormap-api.feature.info.maxitems=30
3030
# Should match the list in tailormap-viewer class AttributeListExportService
3131
tailormap-api.export.allowed-outputformats=csv,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,excel2007,application/vnd.shp,application/x-zipped-shp,SHAPE-ZIP,application/geopackage+sqlite3,application/x-gpkg,geopackage,geopkg,gpkg,application/geo+json,application/geojson,application/json,json,DXF-ZIP
3232

33+
# proxy passthrough regex patterns for layer names, when empty no additional layers are allowed to be proxied
34+
# eg. use vw_t_gi_%s_[a-fA-F0-9]{32} to match `vw_t_gi_layername_70cae9814c6144808f1c9bb921099794` as a sub-layer of layername
35+
# %s is replaced with the layer name from the configuration (this uses String.format() syntax, so be aware of the escaping rules for % and \)
36+
# for regex help see eg: https://regex101.com/ or https://www.regexplanet.com/advanced/java/index.html or https://regexr.com/
37+
tailormap-api.proxy.passthrough.layerpatterns=
38+
## list of allowed host names eg. test.com,localhost (no spaces) to validate the layer name patterns, can be empty to allow any host name
39+
tailormap-api.proxy.passthrough.hostnames=
40+
3341
# whether the API should use GeoTools "Unique Collection" (use DISTINCT in SQL statements) or just
3442
# retrieve all values when calculating the unique values for a property.
3543
# There might be a performance difference between the two, depending on the data
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright (C) 2026 B3Partners B.V.
3+
*
4+
* SPDX-License-Identifier: MIT
5+
*/
6+
package org.tailormap.api.controller;
7+
8+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
9+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
10+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
11+
import static org.tailormap.api.TestRequestProcessor.setServletPath;
12+
13+
import org.junit.jupiter.api.BeforeAll;
14+
import org.junit.jupiter.api.Test;
15+
import org.junit.jupiter.api.TestInstance;
16+
import org.junit.jupiter.api.parallel.Execution;
17+
import org.junit.jupiter.api.parallel.ExecutionMode;
18+
import org.springframework.beans.factory.annotation.Autowired;
19+
import org.springframework.beans.factory.annotation.Value;
20+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
21+
import org.springframework.http.MediaType;
22+
import org.springframework.test.context.TestPropertySource;
23+
import org.springframework.test.web.servlet.MockMvc;
24+
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
25+
import org.springframework.web.context.WebApplicationContext;
26+
import org.tailormap.api.annotation.PostgresIntegrationTest;
27+
28+
@PostgresIntegrationTest
29+
@AutoConfigureMockMvc
30+
@Execution(ExecutionMode.CONCURRENT)
31+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
32+
@TestPropertySource(
33+
properties = {
34+
// Use the default proxy configuration, which denies layer patterns,
35+
// to test the default configuration of the GeoServiceProxyController
36+
"tailormap-api.proxy.passthrough.hostnames=",
37+
"tailormap-api.proxy.passthrough.layerpatterns="
38+
})
39+
class GeoServiceProxyControllerDefaultProxyConfigIntegrationTest {
40+
private final String begroeidterreindeelUrl =
41+
"/app/default/layer/lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel/proxy/wms";
42+
43+
@Autowired
44+
private WebApplicationContext context;
45+
46+
private MockMvc mockMvc;
47+
48+
@Value("${tailormap-api.base-path}")
49+
private String apiBasePath;
50+
51+
@BeforeAll
52+
void initialize() {
53+
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
54+
}
55+
56+
@Test
57+
void post_request() throws Exception {
58+
final String path = apiBasePath + begroeidterreindeelUrl;
59+
mockMvc.perform(post(path)
60+
.param("REQUEST", "GetMap")
61+
.param("SERVICE", "WMS")
62+
.param("VERSION", "1.3.0")
63+
.param("FORMAT", "image/png")
64+
.param("STYLES", "")
65+
.param("TRANSPARENT", "TRUE")
66+
.param("LAYERS", "postgis:begroeidterreindeel")
67+
.param("WIDTH", "2775")
68+
.param("HEIGHT", "1002")
69+
.param("CRS", "EPSG:28992")
70+
.param("BBOX", "130574.85495843932,457818.25613033347,133951.6192003861,459037.5418133715")
71+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
72+
.with(setServletPath(path)))
73+
.andExpect(status().isOk());
74+
}
75+
76+
@Test
77+
void get_request() throws Exception {
78+
final String path = apiBasePath + begroeidterreindeelUrl;
79+
mockMvc.perform(get(path)
80+
.param("REQUEST", "GetMap")
81+
.param("SERVICE", "WMS")
82+
.param("VERSION", "1.3.0")
83+
.param("FORMAT", "image/png")
84+
.param("STYLES", "")
85+
.param("TRANSPARENT", "TRUE")
86+
.param("LAYERS", "postgis:begroeidterreindeel")
87+
.param("WIDTH", "2775")
88+
.param("HEIGHT", "1002")
89+
.param("CRS", "EPSG:28992")
90+
.param("BBOX", "130574.85495843932,457818.25613033347,133951.6192003861,459037.5418133715")
91+
.with(setServletPath(path)))
92+
.andExpect(status().isOk());
93+
}
94+
95+
@Test
96+
void disallow_invalid_layer_name_param() throws Exception {
97+
final String path = apiBasePath + begroeidterreindeelUrl;
98+
mockMvc.perform(post(path)
99+
.param("REQUEST", "GetMap")
100+
.param("SERVICE", "WMS")
101+
.param("LAYERS", "postgis:invalid_layer_name")
102+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
103+
.with(setServletPath(path)))
104+
.andExpect(status().isBadRequest());
105+
}
106+
107+
@Test
108+
void disallow_two_layer_names_param() throws Exception {
109+
final String path = apiBasePath + begroeidterreindeelUrl;
110+
mockMvc.perform(post(path)
111+
.param("REQUEST", "GetMap")
112+
.param("SERVICE", "WMS")
113+
.param("LAYERS", "postgis:invalid_layer_name,postgis:begroeidterreindeel")
114+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
115+
.with(setServletPath(path)))
116+
.andExpect(status().isBadRequest());
117+
}
118+
}

src/test/java/org/tailormap/api/controller/GeoServiceProxyControllerIntegrationTest.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,56 @@ void large_http_post() throws Exception {
207207
.andExpect(status().isOk());
208208
}
209209

210+
@Test
211+
void disallow_invalid_layer_name_param() throws Exception {
212+
final String path = apiBasePath + begroeidterreindeelUrl;
213+
mockMvc.perform(post(path)
214+
.param("REQUEST", "GetMap")
215+
.param("SERVICE", "WMS")
216+
.param("LAYERS", "postgis:invalid_layer_name")
217+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
218+
.with(setServletPath(path)))
219+
.andExpect(status().isBadRequest());
220+
}
221+
222+
@Test
223+
void disallow_two_layer_names_param() throws Exception {
224+
final String path = apiBasePath + begroeidterreindeelUrl;
225+
mockMvc.perform(post(path)
226+
.param("REQUEST", "GetMap")
227+
.param("SERVICE", "WMS")
228+
.param("LAYERS", "postgis:invalid_layer_name,postgis:begroeidterreindeel")
229+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
230+
.with(setServletPath(path)))
231+
.andExpect(status().isBadRequest());
232+
}
233+
234+
@Test
235+
void disallow_comma_separated_layer_names_matching_pattern_param() throws Exception {
236+
final String path = apiBasePath + begroeidterreindeelUrl;
237+
mockMvc.perform(post(path)
238+
.param("REQUEST", "GetMap")
239+
.param("SERVICE", "WMS")
240+
.param(
241+
"LAYERS",
242+
"vw_t_gi_postgis:begroeidterreindeel_70cae9814c6144808f1c9bb921099794,other_layer")
243+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
244+
.with(setServletPath(path)))
245+
.andExpect(status().isBadRequest());
246+
}
247+
248+
@Test
249+
void allow_valid_layer_name_pattern_param() throws Exception {
250+
final String path = apiBasePath + begroeidterreindeelUrl;
251+
mockMvc.perform(post(path)
252+
.param("REQUEST", "GetMap")
253+
.param("SERVICE", "WMS")
254+
.param("LAYERS", "vw_t_gi_postgis:begroeidterreindeel_70cae9814c6144808f1c9bb921099794")
255+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
256+
.with(setServletPath(path)))
257+
.andExpect(status().isOk());
258+
}
259+
210260
@Test
211261
void allow_http_get() throws Exception {
212262
final String path = apiBasePath + begroeidterreindeelUrl;

src/test/resources/application-postgresql.properties

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,8 @@ spring.mail.protocol=smtp
4444
# password reset options
4545
tailormap-api.password-reset.enabled=true
4646
tailormap-api.password-reset.disabled-for=actuator
47-
tailormap-api.password-reset.token-expiration-minutes=1
47+
tailormap-api.password-reset.token-expiration-minutes=1
48+
49+
tailormap-api.proxy.passthrough.layerpatterns=vw_t_gi_%s_[a-fA-F0-9]{32},test_%s_.*
50+
tailormap-api.proxy.passthrough.hostnames=localhost,snapshot.tailormap.nl
51+
# tailormap-api.proxy.passthrough.hostnames=

0 commit comments

Comments
 (0)