diff --git a/README.md b/README.md index 3194e4c..c2650b6 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,42 @@ docker run -p 8080:8080 -e hapi.fhir.default_encoding=xml hapiproject/hapi:lates HAPI looks in the environment variables for properties in the [application.yaml](https://github.com/hapifhir/hapi-fhir-jpaserver-starter/blob/master/src/main/resources/application.yaml) file for defaults. +### CORS configuration (including ETag/If-Match) + +The starter CORS configuration now supports the following configurable keys: + +- `hapi.fhir.cors.allowed_origin` +- `hapi.fhir.cors.allow_Credentials` +- `hapi.fhir.cors.allowed_headers` +- `hapi.fhir.cors.exposed_headers` +- `hapi.fhir.cors.allowed_methods` + +Defaults include `If-Match` in allowed headers and `ETag` in exposed headers to support browser-based optimistic locking workflows. +The `allowed_headers`, `exposed_headers`, and `allowed_methods` keys are optional; if omitted, built-in defaults are applied. +The default for `allow_Credentials` is `false`. If you set `allow_Credentials=true`, do not use `"*"` for `allowed_origin`; configure explicit origins. + +Example override file: + +```yaml +hapi: + fhir: + cors: + allowed_origin: + - "http://localhost:3000" + allowed_headers: + - Origin + - Accept + - Content-Type + - Authorization + - Cache-Control + - If-Match + - If-None-Match + exposed_headers: + - Location + - Content-Location + - ETag +``` + ### Binary storage configuration To stream large `Binary` payloads to disk instead of the database, configure the starter with filesystem storage properties: diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java index 0960197..f6bf4a2 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -867,11 +867,37 @@ public class AppProperties { } public static class Cors { - private Boolean allow_Credentials = true; + private static final List DEFAULT_ALLOWED_HEADERS = List.of( + "Origin", + "Accept", + "Content-Type", + "Authorization", + "Cache-Control", + "If-Match", + "If-None-Match", + "x-fhir-starter", + "X-Requested-With", + "Prefer"); + private static final List DEFAULT_EXPOSED_HEADERS = List.of( + "Location", + "Content-Location", + "ETag", + "Date", + "Retry-After", + "X-Correlation-Id", + "X-Progress", + "X-Request-Id"); + private static final List DEFAULT_ALLOWED_METHODS = + List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"); + + private Boolean allow_Credentials = false; private List allowed_origin = List.of("*"); + private List allowed_headers = DEFAULT_ALLOWED_HEADERS; + private List exposed_headers = DEFAULT_EXPOSED_HEADERS; + private List allowed_methods = DEFAULT_ALLOWED_METHODS; public List getAllowed_origin() { - return allowed_origin; + return defaultIfNull(allowed_origin, List.of("*")); } public void setAllowed_origin(List allowed_origin) { @@ -885,6 +911,30 @@ public class AppProperties { public void setAllow_Credentials(Boolean allow_Credentials) { this.allow_Credentials = allow_Credentials; } + + public List getAllowed_headers() { + return defaultIfNull(allowed_headers, DEFAULT_ALLOWED_HEADERS); + } + + public void setAllowed_headers(List allowed_headers) { + this.allowed_headers = allowed_headers; + } + + public List getExposed_headers() { + return defaultIfNull(exposed_headers, DEFAULT_EXPOSED_HEADERS); + } + + public void setExposed_headers(List exposed_headers) { + this.exposed_headers = exposed_headers; + } + + public List getAllowed_methods() { + return defaultIfNull(allowed_methods, DEFAULT_ALLOWED_METHODS); + } + + public void setAllowed_methods(List allowed_methods) { + this.allowed_methods = allowed_methods; + } } public static class Logger { diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/ErrorHandling.java b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/ErrorHandling.java index 0b99b5b..2da620b 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/ErrorHandling.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/cdshooks/ErrorHandling.java @@ -7,7 +7,6 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; -import java.util.Arrays; public class ErrorHandling { @@ -68,30 +67,14 @@ public class ErrorHandling { public static void setAccessControlHeaders(HttpServletResponse resp, AppProperties myAppProperties) { if (myAppProperties.getCors() != null) { - if (myAppProperties.getCors().getAllow_Credentials()) { - resp.setHeader( - "Access-Control-Allow-Origin", - myAppProperties.getCors().getAllowed_origin().stream() - .findFirst() - .get()); - resp.setHeader( - "Access-Control-Allow-Methods", - String.join(", ", Arrays.asList("GET", "HEAD", "POST", "OPTIONS"))); - resp.setHeader( - "Access-Control-Allow-Headers", - String.join( - ", ", - Arrays.asList( - "x-fhir-starter", - "Origin", - "Accept", - "X-Requested-With", - "Content-Type", - "Authorization", - "Cache-Control"))); - resp.setHeader( - "Access-Control-Expose-Headers", - String.join(", ", Arrays.asList("Location", "Content-Location"))); + AppProperties.Cors cors = myAppProperties.getCors(); + if (cors.getAllow_Credentials()) { + String allowOrigin = + cors.getAllowed_origin().stream().findFirst().orElse("*"); + resp.setHeader("Access-Control-Allow-Origin", allowOrigin); + resp.setHeader("Access-Control-Allow-Methods", String.join(", ", cors.getAllowed_methods())); + resp.setHeader("Access-Control-Allow-Headers", String.join(", ", cors.getAllowed_headers())); + resp.setHeader("Access-Control-Expose-Headers", String.join(", ", cors.getExposed_headers())); resp.setHeader("Access-Control-Max-Age", "86400"); } } diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java index 79ee250..60fdd66 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java @@ -96,7 +96,6 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Primary; -import org.springframework.http.HttpHeaders; import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.web.cors.CorsConfiguration; @@ -280,24 +279,17 @@ public class StarterJpaConfig { // showing a typical setup. You should customize this // to your specific needs ourLog.info("CORS is enabled on this server"); + AppProperties.Cors corsProperties = appProperties.getCors(); CorsConfiguration config = new CorsConfiguration(); - config.addAllowedHeader(HttpHeaders.ORIGIN); - config.addAllowedHeader(HttpHeaders.ACCEPT); - config.addAllowedHeader(HttpHeaders.CONTENT_TYPE); - config.addAllowedHeader(HttpHeaders.AUTHORIZATION); - config.addAllowedHeader(HttpHeaders.CACHE_CONTROL); - config.addAllowedHeader("x-fhir-starter"); - config.addAllowedHeader("X-Requested-With"); - config.addAllowedHeader("Prefer"); + corsProperties.getAllowed_headers().forEach(config::addAllowedHeader); - List allAllowedCORSOrigins = appProperties.getCors().getAllowed_origin(); + List allAllowedCORSOrigins = corsProperties.getAllowed_origin(); allAllowedCORSOrigins.forEach(config::addAllowedOriginPattern); ourLog.info("CORS allows the following origins: {}", String.join(", ", allAllowedCORSOrigins)); - config.addExposedHeader("Location"); - config.addExposedHeader("Content-Location"); - config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD")); - config.setAllowCredentials(appProperties.getCors().getAllow_Credentials()); + corsProperties.getExposed_headers().forEach(config::addExposedHeader); + config.setAllowedMethods(corsProperties.getAllowed_methods()); + config.setAllowCredentials(corsProperties.getAllow_Credentials()); // Create the interceptor and register it return new CorsInterceptor(config); diff --git a/src/main/resources/application-cds.yaml b/src/main/resources/application-cds.yaml index 31394df..07b691d 100644 --- a/src/main/resources/application-cds.yaml +++ b/src/main/resources/application-cds.yaml @@ -267,7 +267,7 @@ hapi: # ------------------------------------------------------------------------------- bulk_export_enabled: false bulk_import_enabled: false - bulk_export_file_retention_period_hours: 2 + bulk_export_file_retention_period_hours: 2 # ------------------------------------------------------------------------------- # F. Write / Delete / Integrity @@ -338,9 +338,31 @@ hapi: # K. CORS # ------------------------------------------------------------------------------- cors: - allow_Credentials: true + # allow_Credentials: false allowed_origin: - "*" + # If you enable allow_Credentials=true, use explicit origins instead of "*". + # Optional overrides. If omitted, built-in defaults are used. + # allowed_headers: + # - Origin + # - Accept + # - Content-Type + # - Authorization + # - Cache-Control + # - If-Match + # - If-None-Match + # exposed_headers: + # - Location + # - Content-Location + # - ETag + # allowed_methods: + # - GET + # - POST + # - PUT + # - DELETE + # - OPTIONS + # - PATCH + # - HEAD # ------------------------------------------------------------------------------- # L. Search Orchestration diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 368b366..3e9a8d7 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -368,9 +368,31 @@ hapi: # K. CORS # ------------------------------------------------------------------------------- cors: - allow_Credentials: true + # allow_Credentials: false allowed_origin: - "*" + # If you enable allow_Credentials=true, use explicit origins instead of "*". + # Optional overrides. If omitted, built-in defaults are used. + # allowed_headers: + # - Origin + # - Accept + # - Content-Type + # - Authorization + # - Cache-Control + # - If-Match + # - If-None-Match + # exposed_headers: + # - Location + # - Content-Location + # - ETag + # allowed_methods: + # - GET + # - POST + # - PUT + # - DELETE + # - OPTIONS + # - PATCH + # - HEAD # ------------------------------------------------------------------------------- # L. Search Orchestration diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/AppPropertiesCorsDefaultsTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/AppPropertiesCorsDefaultsTest.java new file mode 100644 index 0000000..685f049 --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/AppPropertiesCorsDefaultsTest.java @@ -0,0 +1,30 @@ +package ca.uhn.fhir.jpa.starter; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AppPropertiesCorsDefaultsTest { + + @Test + void defaultCorsHeadersIncludeFhirOptimisticLockingHeaders() { + AppProperties.Cors cors = new AppProperties.Cors(); + + assertFalse(cors.getAllow_Credentials()); + assertTrue(cors.getAllowed_headers().contains("If-Match")); + assertTrue(cors.getExposed_headers().contains("ETag")); + } + + @Test + void nullCorsListsFallBackToDefaults() { + AppProperties.Cors cors = new AppProperties.Cors(); + cors.setAllowed_headers(null); + cors.setExposed_headers(null); + cors.setAllowed_methods(null); + + assertTrue(cors.getAllowed_headers().contains("If-Match")); + assertTrue(cors.getExposed_headers().contains("ETag")); + assertTrue(cors.getAllowed_methods().contains("PATCH")); + } +}