From d3a756161546417a2fbf7b150099a46ad1938e9d Mon Sep 17 00:00:00 2001 From: amber Date: Thu, 9 Jul 2026 11:55:32 +1000 Subject: [PATCH 1/2] feat: show WMS layer legend on the detail page map --- .../core/model/enumeration/FeatureId.java | 1 + .../geoserver/wms/WmsDefaultParam.java | 1 + .../core/service/geoserver/wms/WmsServer.java | 63 +++++++++++++++++++ .../aodn/ogcapi/server/features/RestApi.java | 3 + .../ogcapi/server/features/RestServices.java | 25 ++++++++ server/src/main/resources/application.yaml | 6 ++ .../service/geoserver/wms/WmsServerTest.java | 59 +++++++++++++++++ 7 files changed, 158 insertions(+) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/FeatureId.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/FeatureId.java index 072d28fe..67e0da4d 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/FeatureId.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/FeatureId.java @@ -13,6 +13,7 @@ public enum FeatureId { moorings_latest_available_date("moorings_latest_available_date"), mooring_details_between_dates("mooring_details_between_dates"), wms_map_tile("wms_map_tile"), + wms_legend("wms_legend"), // Get the WMS GetLegendGraphic image (image/png) for a layer wms_map_feature("wms_map_feature"), wms_layers("wms_layers"), // Get all available layers from WMS GetCapabilities wfs_layers("wfs_layers"); // Get all available feature types from WFS GetCapabilities diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsDefaultParam.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsDefaultParam.java index 3d7ac5db..3bc16f8f 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsDefaultParam.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsDefaultParam.java @@ -23,6 +23,7 @@ public class WmsDefaultParam { private Map wms; private Map ncwms; private Map ncmetadata; + private Map legend; private Map descLayer; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java index 286374d0..a13abfa2 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java @@ -551,6 +551,69 @@ public byte[] getMapTile(String collectionId, FeatureRequest request) throws URI return null; } + /** + * Get the WMS GetLegendGraphic image (image/png) for a layer. + * + * @param collectionId - The uuid + * @param request - The request param, layer name is used + * @return - The legend png bytes, or null if none could be produced + * @throws URISyntaxException - Not expected + */ + public byte[] getLegend(String collectionId, FeatureRequest request) throws URISyntaxException { + Optional mapServerUrl = getMapServerUrl(collectionId, request); + log.debug("legend request for uuid {} layername {}", collectionId, request.getLayerName()); + if (mapServerUrl.isPresent()) { + String url = createLegendUrl(mapServerUrl.get(), request); + if (url != null) { + log.debug("legend request for layer name {} url {} ", request.getLayerName(), url); + ResponseEntity response = restTemplateUtils.handleRedirect(url, restTemplate.exchange(url, HttpMethod.GET, pretendUserEntity, byte[].class), byte[].class, pretendUserEntity); + // Unlike getMapTile, a non-image response here just means the layer has + // no legend (e.g. ncWMS), so return null (-> 404) rather than throwing. + if (response.getStatusCode().is2xxSuccessful() + && response.getHeaders().getContentType() != null + && response.getHeaders().getContentType().getType().equals("image")) { + return response.getBody(); + } + } + } + return null; + } + + /** + * Build a GetLegendGraphic url against the same host/path as the layer's WMS server. + * + * @param url - The WMS server url from the collection link + * @param request - The request, layer name is used as the LAYER param + * @return - The legend url, or null on syntax error + */ + protected String createLegendUrl(String url, FeatureRequest request) { + try { + UriComponents components = UriComponentsBuilder.fromUriString(url).build(); + if (components.getPath() != null) { + Map param = new HashMap<>(wmsDefaultParam.getLegend()); + // GetLegendGraphic uses LAYER (singular), unlike GetMap's LAYERS + param.put("LAYER", request.getLayerName()); + + UriComponentsBuilder builder = UriComponentsBuilder + .newInstance() + .scheme("https") // hardcode https to avoid a redirect, as elsewhere + .port(components.getPort()) + .host(components.getHost()) + .path(components.getPath()); + + param.forEach((key, value) -> { + if (value != null) { + builder.queryParam(key, value); + } + }); + return builder.build().toUriString(); + } + } catch (Exception e) { + log.error("URL syntax error {}", url, e); + } + return null; + } + /** * Query by using WMS's DescriberLayer function to find out the associated WFS layer and fields * diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java index 3f402c35..5a47a2c6 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestApi.java @@ -148,6 +148,9 @@ public ResponseEntity getFeature( case wms_map_tile -> { return featuresService.getWmsMapTile(collectionId, request); } + case wms_legend -> { + return featuresService.getWmsLegend(collectionId, request); + } case wms_map_feature -> { return featuresService.getWmsMapFeature(collectionId, request); } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java index d4246b63..dea8a25d 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/features/RestServices.java @@ -28,6 +28,7 @@ import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.CacheControl; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -36,6 +37,7 @@ import java.io.IOException; import java.io.StringReader; import java.net.URISyntaxException; +import java.time.Duration; import java.util.*; @Slf4j @@ -107,6 +109,29 @@ public ResponseEntity getWmsMapTile(String collectionId, FeatureRequest } } + /** + * Proxy the WMS GetLegendGraphic image (colour-bar / symbol legend) for a layer. + * + * @param collectionId - The uuid of the dataset + * @param request - Request holding the layer name + * @return - The legend as an image/png, or 404 if no legend could be built + */ + public ResponseEntity getWmsLegend(String collectionId, FeatureRequest request) { + try { + byte[] legend = wmsServer.getLegend(collectionId, request); + if (legend == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + // Legends are stable per layer/style, so let the browser/CDN cache them + .cacheControl(CacheControl.maxAge(Duration.ofHours(24)).cachePublic()) + .body(legend); + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + /** * This is used to get the WFS fields given a WFS layer * diff --git a/server/src/main/resources/application.yaml b/server/src/main/resources/application.yaml index 8b40984a..9a4b7e91 100644 --- a/server/src/main/resources/application.yaml +++ b/server/src/main/resources/application.yaml @@ -112,6 +112,12 @@ wms-default-param: VERSION: "1.3.0" # Must be small letter item !!! item: "layerDetails" + legend: + SERVICE: "WMS" + VERSION: "1.1.1" + REQUEST: "GetLegendGraphic" + FORMAT: "image/png" + LEGEND_OPTIONS: "forceLabels:on" allow-id: # Full list - 4402cb50-e20a-44ee-93e6-4728259250d2 - ae86e2f5-eaaf-459e-a405-e654d85adb9c diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServerTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServerTest.java index f12abc51..02c755cb 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServerTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServerTest.java @@ -21,7 +21,10 @@ import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.web.client.RestTemplate; @@ -675,4 +678,60 @@ void testRewriteUrlWithWorkSpace() { String result4 = WmsServer.rewriteUrlWithWorkSpace(url4, req); assertEquals("https://example.com/geoserver/xxx/wfs", result4); } + + @Test + public void verifyLegendUrlGenCorrect() { + FeatureRequest request = FeatureRequest.builder() + .layerName("imos:argo_profile_map") + .build(); + + String url = wmsServer.createLegendUrl("http://geoserver-123.aodn.org.au/geoserver/wms", request); + assertNotNull(url); + + // Legend is forced to https, uses REQUEST=GetLegendGraphic and the default image/png + String expectedUrl = "https://geoserver-123.aodn.org.au/geoserver/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetLegendGraphic&FORMAT=image/png&LEGEND_OPTIONS=forceLabels:on&LAYER=imos:argo_profile_map"; + UriComponents expected = UriComponentsBuilder.fromUriString(expectedUrl).build(); + UriComponents result = UriComponentsBuilder.fromUriString(url).build(); + + assertEquals(expected.getScheme(), result.getScheme()); + assertEquals(expected.getHost(), result.getHost()); + assertEquals(expected.getPath(), result.getPath()); + + expected.getQueryParams() + .forEach((key, value) -> + assertEquals(value, result.getQueryParams().get(key), key) + ); + + // GetLegendGraphic uses LAYER (singular), never GetMap's LAYERS + assertNull(result.getQueryParams().get("LAYERS")); + } + + @Test + public void getLegend_returnsImageBytesFromServer() { + String id = "id"; + FeatureRequest request = FeatureRequest.builder().layerName("imos:argo_profile_map").build(); + + ElasticSearchBase.SearchResult stac = new ElasticSearchBase.SearchResult<>(); + stac.setCollections(new ArrayList<>()); + stac.getCollections().add( + StacCollectionModel.builder() + .links(List.of( + LinkModel.builder() + .href("http://geoserver-123.aodn.org.au/geoserver/wms") + .title(request.getLayerName()) + .aiGroup(WMS_LINK_MARKER) + .build())) + .build() + ); + when(search.searchCollections(eq(id))).thenReturn(stac); + + byte[] png = new byte[]{(byte) 0x89, 'P', 'N', 'G'}; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_PNG); + when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), eq(entity), eq(byte[].class))) + .thenReturn(new ResponseEntity<>(png, headers, HttpStatus.OK)); + + byte[] result = assertDoesNotThrow(() -> wmsServer.getLegend(id, request)); + assertArrayEquals(png, result); + } } From a30ea32a7440c3369d489bbea509b4ddf4d9dd08 Mon Sep 17 00:00:00 2001 From: amber Date: Thu, 9 Jul 2026 12:46:15 +1000 Subject: [PATCH 2/2] fix: validate WMS legend layer name to prevent SSRF --- .../server/core/service/geoserver/wms/WmsServer.java | 10 +++++++++- .../core/service/geoserver/wms/WmsServerTest.java | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java index a13abfa2..dc7e7046 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java @@ -563,6 +563,8 @@ public byte[] getLegend(String collectionId, FeatureRequest request) throws URIS Optional mapServerUrl = getMapServerUrl(collectionId, request); log.debug("legend request for uuid {} layername {}", collectionId, request.getLayerName()); if (mapServerUrl.isPresent()) { + // createLegendUrl validates the user-supplied layer name (SSRF guard) and + // returns null when it is unsafe. String url = createLegendUrl(mapServerUrl.get(), request); if (url != null) { log.debug("legend request for layer name {} url {} ", request.getLayerName(), url); @@ -587,12 +589,18 @@ public byte[] getLegend(String collectionId, FeatureRequest request) throws URIS * @return - The legend url, or null on syntax error */ protected String createLegendUrl(String url, FeatureRequest request) { + String layerName = request.getLayerName(); + // SSRF guard: the layer name is the only user-supplied part of the URL, so + // restrict it to safe WMS characters and use this validated value below. + if (layerName == null || !layerName.matches("[A-Za-z0-9_:/.\\-]+")) { + return null; + } try { UriComponents components = UriComponentsBuilder.fromUriString(url).build(); if (components.getPath() != null) { Map param = new HashMap<>(wmsDefaultParam.getLegend()); // GetLegendGraphic uses LAYER (singular), unlike GetMap's LAYERS - param.put("LAYER", request.getLayerName()); + param.put("LAYER", layerName); UriComponentsBuilder builder = UriComponentsBuilder .newInstance() diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServerTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServerTest.java index 02c755cb..1c1bc266 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServerTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServerTest.java @@ -706,6 +706,17 @@ public void verifyLegendUrlGenCorrect() { assertNull(result.getQueryParams().get("LAYERS")); } + @Test + public void createLegendUrl_rejectsUnsafeLayerName() { + String base = "https://geoserver-123.aodn.org.au/geoserver/wms"; + // A normal layer name builds a url + assertNotNull(wmsServer.createLegendUrl(base, + FeatureRequest.builder().layerName("imos:argo_profile_map").build())); + // A crafted layer name is rejected (SSRF guard) -> null + assertNull(wmsServer.createLegendUrl(base, + FeatureRequest.builder().layerName("x?SERVICE=WMS&url=http://evil").build())); + } + @Test public void getLegend_returnsImageBytesFromServer() { String id = "id";