Skip to content

Commit 9c235a5

Browse files
authored
Merge branch 'main' into HTM-1852_geotools-35.x
2 parents efa6157 + 972628a commit 9c235a5

10 files changed

Lines changed: 254 additions & 13 deletions

File tree

.github/workflows/copilot-setup-steps.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
maven-version: 3.9.12
3333

3434
- name: 'Login to b3p.nl'
35-
uses: docker/login-action@v3
35+
uses: docker/login-action@v4
3636
with:
3737
registry: docker.b3p.nl
3838
username: ${{ secrets.DOCKER_B3P_PULL_ACTOR }}

.github/workflows/qa.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747
- name: 'QA build with Maven'
4848
run: mvn -B -V -fae -DskipQA=false -Dspotless.action=check -Dpom.fmt.action=verify -Ddocker.skip=true -DskipTests -DskipITs clean install
4949

50-
- uses: lcollins/pmd-github-action@v3.1.0
50+
- uses: lcollins/pmd-github-action@v3.2.0
5151
if: always()
5252
with:
5353
path: '**/pmd.xml'

.github/workflows/ubuntu-maven.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ jobs:
9696
maven-version: ${{ env.MAVEN_VERSION }}
9797

9898
- name: 'Login to b3p.nl'
99-
uses: docker/login-action@v3
99+
uses: docker/login-action@v4
100100
with:
101101
registry: docker.b3p.nl
102102
username: ${{ secrets.DOCKER_B3P_PULL_ACTOR }}
@@ -204,7 +204,7 @@ jobs:
204204
maven-version: ${{ env.MAVEN_VERSION }}
205205

206206
- name: 'Log in to GitHub container registry'
207-
uses: docker/login-action@v3
207+
uses: docker/login-action@v4
208208
with:
209209
registry: ghcr.io
210210
username: ${{ github.actor }}

build/ci/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ services:
123123
environment:
124124
TZ: Europe/Amsterdam
125125
SOLR_OPTS: '$SOLR_OPTS -Dsolr.environment=dev,label=Tailormap+Development,color=#6236FF'
126-
SOLR_DELETE_UNKNOWN_CORES: true
126+
SOLR_CLOUD_STARTUP_DELETE_UNKNOWN_CORES_ENABLED: true
127127
SOLR_MODE: user-managed
128128
volumes:
129129
- solr-data:/var/solr

pom.xml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ SPDX-License-Identifier: MIT
1414
</parent>
1515
<groupId>org.tailormap</groupId>
1616
<artifactId>tailormap-api</artifactId>
17-
<version>12.5.1-SNAPSHOT</version>
17+
<version>12.5.2-SNAPSHOT</version>
1818
<packaging>jar</packaging>
1919
<name>Tailormap API</name>
2020
<description>Tailormap API provides the backend for Tailormap</description>
@@ -65,7 +65,7 @@ SPDX-License-Identifier: MIT
6565
<scm>
6666
<connection>scm:git:git@github.com:Tailormap/tailormap-api.git</connection>
6767
<developerConnection>scm:git:git@github.com:Tailormap/tailormap-api.git</developerConnection>
68-
<tag>tailormap-api-12.4.2</tag>
68+
<tag>tailormap-api-12.5.1</tag>
6969
<url>https://github.com/Tailormap/tailormap-api/</url>
7070
</scm>
7171
<issueManagement>
@@ -101,7 +101,7 @@ SPDX-License-Identifier: MIT
101101
<maven.compiler.target>${java.version}</maven.compiler.target>
102102
<maven.compiler.release>${java.version}</maven.compiler.release>
103103
<maven-compiler-plugin.version>3.12.1</maven-compiler-plugin.version>
104-
<project.build.outputTimestamp>2026-02-24T11:05:11Z</project.build.outputTimestamp>
104+
<project.build.outputTimestamp>2026-03-06T10:33:43Z</project.build.outputTimestamp>
105105
<geotools.version>35-SNAPSHOT</geotools.version>
106106
<jts.version>1.20.0</jts.version>
107107
<okhttp.version>5.3.2</okhttp.version>
@@ -156,7 +156,7 @@ SPDX-License-Identifier: MIT
156156
<modernizer-maven-plugin.version>3.2.0</modernizer-maven-plugin.version>
157157
<maven-fluido-skin.version>2.1.0</maven-fluido-skin.version>
158158
<swagger-ui.version>5.32.0</swagger-ui.version>
159-
<sentry.version>8.33.0</sentry.version>
159+
<sentry.version>8.34.1</sentry.version>
160160
<errorProne.version>2.48.0</errorProne.version>
161161
<errorProneFlags>-XepDisableWarningsInGeneratedCode</errorProneFlags>
162162
<errorProneExcludePaths>${project.build.directory}/generated-sources/.*</errorProneExcludePaths>

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;

0 commit comments

Comments
 (0)