Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.geotools/gt-cql -->
<dependency>
<groupId>org.geotools</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package au.org.aodn.ogcapi.server.core.configuration;

import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
public class WfsServerConfig {
private final List<String> urls = List.of(
"https://geoserver.imas.utas.edu.au/geoserver/wfs",
"https://geoserver-123.aodn.org.au/geoserver/wfs",
"https://www.cmar.csiro.au/geoserver/wfs",
"https://geoserver.apps.aims.gov.au/aims/wfs"
);

public boolean isAllowed(String serverUrl) {
if (serverUrl == null) {
return false;
}
return urls.contains(normalizeUrl(serverUrl));
}

public String normalizeUrl(String serverUrl) {
if (serverUrl == null) {
return null;
}

// Normalize the URL by removing trailing slashes and query parameters
String normalizedUrl = serverUrl.trim();
if (normalizedUrl.endsWith("/")) {
normalizedUrl = normalizedUrl.substring(0, normalizedUrl.length() - 1);
}

// Remove query parameters if present
int queryIndex = normalizedUrl.indexOf('?');
if (queryIndex != -1) {
normalizedUrl = normalizedUrl.substring(0, queryIndex);
}

return normalizedUrl;
}

/**
* SSRF Protection: Validate user input against whitelist and return approved URL
* This ensures no user input is directly used in HTTP requests
*/
public String validateAndGetApprovedServerUrl(String userProvidedUrl) {
if (!isAllowed(userProvidedUrl)) {
throw new au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException(
String.format("Access to WFS server '%s' is not authorized. Only approved servers are allowed.", userProvidedUrl)
);
}

// Return the exact approved URL from our whitelist, not user input
return urls.stream()
.filter(approvedUrl -> normalizeUrl(userProvidedUrl).equals(normalizeUrl(approvedUrl)))
.findFirst()
.orElseThrow(() -> new au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException(
String.format("Access to WFS server '%s' is not authorized. Only approved servers are allowed.", userProvidedUrl)
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package au.org.aodn.ogcapi.server.core.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class DownloadableFieldsNotFoundException extends RuntimeException {
public DownloadableFieldsNotFoundException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,32 @@ public ResponseEntity<ErrorResponse> handleCustomException(Exception ex, WebRequ
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}

@ExceptionHandler(DownloadableFieldsNotFoundException.class)
public ResponseEntity<ErrorResponse> handleDownloadableFieldsNotFoundException(DownloadableFieldsNotFoundException ex, WebRequest request) {
ErrorResponse errorResponse = ErrorResponse
.builder()
.timestamp(LocalDateTime.now())
.message(ex.getMessage())
.details(request.getDescription(false))
.build();

return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
}

@ExceptionHandler(UnauthorizedServerException.class)
public ResponseEntity<ErrorResponse> handleUnauthorizedServerException(UnauthorizedServerException ex, WebRequest request) {
ErrorResponse errorResponse = ErrorResponse
.builder()
.timestamp(LocalDateTime.now())
.message(ex.getMessage())
.details(request.getDescription(false))
.build();

return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN);
}



@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGlobalException(Exception ex, WebRequest request) {
ErrorResponse errorResponse = ErrorResponse
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package au.org.aodn.ogcapi.server.core.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.FORBIDDEN)
public class UnauthorizedServerException extends RuntimeException {
public UnauthorizedServerException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package au.org.aodn.ogcapi.server.core.model.dto.wfs;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;

import java.math.BigDecimal;
import java.util.List;

@Schema(description = "Query parameters for feature requests")
@Data
@Builder
public class FeatureRequest {
@Schema(description = "Property to be return")
private List<String> properties;

@Schema(description = "Only records that have a geometry that intersects the bounding box are selected. The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (height or depth): * Lower left corner, coordinate axis 1 * Lower left corner, coordinate axis 2 * Minimum value, coordinate axis 3 (optional) * Upper right corner, coordinate axis 1 * Upper right corner, coordinate axis 2 * Maximum value, coordinate axis 3 (optional) The coordinate reference system of the values is WGS 84 long/lat (http://www.opengis.net/def/crs/OGC/1.3/CRS84) unless a different coordinate reference system is specified in the parameter `bbox-crs`. For WGS 84 longitude/latitude the values are in most cases the sequence of minimum longitude, minimum latitude, maximum longitude and maximum latitude. However, in cases where the box spans the antimeridian the first value (west-most box edge) is larger than the third value (east-most box edge). If the vertical axis is included, the third and the sixth number are the bottom and the top of the 3-dimensional bounding box. If a record has multiple spatial geometry properties, it is the decision of the server whether only a single spatial geometry property is used to determine the extent or all relevant geometries.")
private List<BigDecimal> bbox;

@Schema(description = "Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots. Examples: * A date-time: \"2018-02-12T23:20:50Z\" * A closed interval: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\" * Open intervals: \"2018-02-12T00:00:00Z/..\" or \"../2018-03-18T12:31:12Z\" Only records that have a temporal property that intersects the value of `datetime` are selected. It is left to the decision of the server whether only a single temporal property is used to determine the extent or all relevant temporal properties.")
private String datetime;

@Schema(description = "WFS server URL (required when featureId is 'downloadableFields')")
private String serverUrl;

@Schema(description = "WFS type name (required when featureId is 'downloadableFields')")
private String layerName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package au.org.aodn.ogcapi.server.core.model.dto.wfs;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
import lombok.Data;

import java.util.List;

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@JacksonXmlRootElement(localName = "schema", namespace = "http://www.w3.org/2001/XMLSchema")
public class WfsDescribeFeatureTypeResponse {

@JacksonXmlProperty(localName = "complexType")
@JacksonXmlElementWrapper(useWrapping = false)
private List<ComplexType> complexTypes;

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ComplexType {
@JacksonXmlProperty(isAttribute = true)
private String name;

@JacksonXmlProperty(localName = "complexContent")
private ComplexContent complexContent;
}

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ComplexContent {
@JacksonXmlProperty(localName = "extension")
private Extension extension;
}

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Extension {
@JacksonXmlProperty(localName = "sequence")
private Sequence sequence;
}

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Sequence {
@JacksonXmlProperty(localName = "element")
@JacksonXmlElementWrapper(useWrapping = false)
private List<Element> elements;
}

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Element {
@JacksonXmlProperty(isAttribute = true)
private String name;

@JacksonXmlProperty(isAttribute = true)
private String type;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package au.org.aodn.ogcapi.server.core.model.enumeration;

public enum FeatureId {
summary("summary");
summary("summary"),
downloadableFields("downloadableFields");

private final String featureId;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package au.org.aodn.ogcapi.server.core.model.wfs;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;

@Data
public class DownloadableFieldModel {
@JsonProperty("label")
private String label;

@JsonProperty("type")
private String type;

@JsonProperty("name")
private String name;
}
Loading