From e46c83bf6d8e55fae188c360e27eaa0edf561498 Mon Sep 17 00:00:00 2001 From: patelila Date: Fri, 27 Feb 2026 16:24:39 +0000 Subject: [PATCH 01/41] Harden callback SSRF controls, redact URL secrets, and align tests/config/docs --- README.md | 3 + .../values.preview.template.yaml | 4 + charts/ccd-data-store-api/values.yaml | 4 + docs/api/security.md | 25 +++ .../uk/gov/hmcts/ccd/ApplicationParams.java | 21 +++ .../service/callbacks/CallbackService.java | 142 ++++++++++++++++-- src/main/resources/application.properties | 4 + .../CallbackInvokerWireMockTest.java | 8 +- .../callbacks/CallbackServiceTest.java | 121 ++++++++++++++- .../CallbackServiceWireMockTest.java | 7 +- 10 files changed, 321 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 617508e2d6..513cf03b1d 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ The following environment variables are required: | DRAFT_STORE_URL | - | Base URL for Draft Store API service. `http://localhost:8800` for the dockerised local instance. | | DRAFT_ENCRYPTION_KEY | - | Draft encryption key. The encryption key used by draft store to encrypt documents with. | | DRAFT_TTL_DAYS | - | Number of days after which the saved draft will be deleted if unmodified. | +| CCD_CALLBACK_ALLOWED_HOSTS | localhost,127.0.0.1 | Comma-separated callback destination host allowlist (`*` and `*.domain.tld` supported). | +| CCD_CALLBACK_ALLOWED_HTTP_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to use `http` for callbacks (all other callback hosts must use `https`). | +| CCD_CALLBACK_ALLOW_PRIVATE_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to resolve to private/local addresses for callbacks. | ### Building diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index 696373792b..989c5ebb22 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -40,6 +40,10 @@ java: MIGRATIONS_ENDPOINT_ENABLED: true LOGGING_LEVEL_UK_GOV_HMCTS_CCD_SECURITY_IDAM: DEBUG LOG_CALLBACK_DETAILS: + # callback hardening: destination allowlists used by CallbackService URL validation + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1 + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1 + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1 DEFAULT_CACHE_TTL_SEC: 1 BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" SPRING_APPLICATION_JSON: | diff --git a/charts/ccd-data-store-api/values.yaml b/charts/ccd-data-store-api/values.yaml index c2ae820393..b9dd48e846 100644 --- a/charts/ccd-data-store-api/values.yaml +++ b/charts/ccd-data-store-api/values.yaml @@ -48,6 +48,10 @@ java: DATA_STORE_DB_MAX_POOL_SIZE: 48 # this variable takes a comma separated list of callback urls for which details needs to be logged, or '*' for all LOG_CALLBACK_DETAILS: + # callback hardening: destination allowlists used by CallbackService URL validation + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1 + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1 + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1 CCD_DRAFT_TTL_DAYS: 180 TTL_GUARD: 365 diff --git a/docs/api/security.md b/docs/api/security.md index c061a1d778..419b407f5c 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -66,3 +66,28 @@ Once a service has been authenticated, it has to be authorised. CCD's APIs can only be called by a list of authorised `microservice_name`. This authorisation is achieved by comparing the service JWT token with the list of authorised services provided as part of the API's configuration. To get your micro-service authorised, please raise a ticket with CCD. + +## Callback security hardening + +Event callback URLs are validated before outbound requests are sent. This is required to reduce SSRF and token leakage risk when callback URLs originate from case definition data. + +### Objective + +Prevent untrusted callback destinations from being invoked and prevent sensitive credential/context headers from being leaked during callback execution. + +- Callback hosts must be allowlisted (`CCD_CALLBACK_ALLOWED_HOSTS`). +- Callback URLs must use `https` unless the host is explicitly approved for `http` (`CCD_CALLBACK_ALLOWED_HTTP_HOSTS`). +- Callback hosts that resolve to local/private ranges are blocked unless explicitly approved (`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`). +- Sensitive inbound/user headers are not forwarded to callbacks (`Authorization`, `ServiceAuthorization`, `user-id`, `user-roles`). + +### Service rollout checklist + +After enabling callback hardening, service teams should: + +1. Ensure callback URLs do not contain embedded credentials (`user:pass@host`). +2. Configure trusted callback destinations with: + - `CCD_CALLBACK_ALLOWED_HOSTS` + - `CCD_CALLBACK_ALLOWED_HTTP_HOSTS` (only for explicitly approved `http` hosts) + - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` (only for explicitly approved private/local hosts) +3. Re-run callback integration tests and verify expected callback hosts are accepted. +4. Update alerting/log triage rules to use redacted callback URL output (query and credentials are not logged). diff --git a/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java b/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java index bc11977026..efe09e7ace 100644 --- a/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java +++ b/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java @@ -242,6 +242,15 @@ public class ApplicationParams { @Value("#{'${ccd.callback.passthru-header-contexts}'.split(',')}") private List callbackPassthruHeaderContexts; + @Value("#{'${ccd.callback.allowed-hosts}'.split(',')}") + private List callbackAllowedHosts; + + @Value("#{'${ccd.callback.allowed-http-hosts}'.split(',')}") + private List callbackAllowedHttpHosts; + + @Value("#{'${ccd.callback.allow-private-hosts}'.split(',')}") + private List callbackAllowPrivateHosts; + @Value("#{'${case.data.exclude.verifyaccess.casetype.validate}'.split(',')}") private List excludeVerifyAccessCaseTypesForValidate; @@ -652,6 +661,18 @@ public List getCallbackPassthruHeaderContexts() { return callbackPassthruHeaderContexts; } + public List getCallbackAllowedHosts() { + return callbackAllowedHosts; + } + + public List getCallbackAllowedHttpHosts() { + return callbackAllowedHttpHosts; + } + + public List getCallbackAllowPrivateHosts() { + return callbackAllowPrivateHosts; + } + public List getUploadTimestampFeaturedCaseTypes() { return uploadTimestampFeaturedCaseTypes; } diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java index 7c1f7fe218..d160016f1e 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java @@ -31,8 +31,14 @@ import uk.gov.hmcts.ccd.util.ClientContextUtil; import jakarta.servlet.http.HttpServletRequest; +import java.net.InetAddress; +import java.net.Inet6Address; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Duration; import java.time.Instant; +import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.function.Predicate; @@ -46,6 +52,15 @@ public class CallbackService { private static final Logger LOG = LoggerFactory.getLogger(CallbackService.class); private static final String WILDCARD = "*"; public static final String CLIENT_CONTEXT = "Client-Context"; + private static final String HTTPS_SCHEME = "https"; + private static final String HTTP_SCHEME = "http"; + private static final String METADATA_ENDPOINT = "169.254.169.254"; + private static final List SENSITIVE_HEADERS = List.of( + HttpHeaders.AUTHORIZATION, + SecurityUtils.SERVICE_AUTHORIZATION, + "user-id", + "user-roles" + ); private static final String DEFAULT_CALLBACK_ERROR_MESSAGE = "Unable to proceed because there are one or more callback Errors or Warnings"; @@ -108,11 +123,13 @@ public Optional sendSingleRequest(final String url, final Optional> responseEntity = sendRequest(url, callbackType, CallbackResponse.class, callbackRequest); return responseEntity.map(re -> Optional.of(re.getBody())).orElseThrow(() -> { - LOG.warn("Unsuccessful callback to {} for caseType {} and event {}", url, caseDetails.getCaseTypeId(), + final String safeUrl = sanitizeUrl(url); + LOG.warn("Unsuccessful callback to {} for caseType {} and event {}", safeUrl, caseDetails.getCaseTypeId(), caseEvent.getId()); String callbackTypeString = callbackType != null ? callbackType.getValue() : "null"; return new CallbackException("Callback to service has been unsuccessful for event " + caseEvent.getName() - + " url " + url + " caseTypeId " + caseDetails.getCaseTypeId() + " caseEvent Id " + caseEvent.getId() + + " url " + safeUrl + " caseTypeId " + caseDetails.getCaseTypeId() + " caseEvent Id " + + caseEvent.getId() + " callbackType " + callbackTypeString); }); } @@ -126,7 +143,8 @@ public ResponseEntity sendSingleRequest(final String url, final CallbackRequest callbackRequest = new CallbackRequest(caseDetails, caseDetailsBefore, caseEvent.getId()); final Optional> requestEntity = sendRequest(url, callbackType, clazz, callbackRequest); return requestEntity.orElseThrow(() -> { - LOG.warn("Unsuccessful callback to {} for caseType {} and event {}", url, caseDetails.getCaseTypeId(), + LOG.warn("Unsuccessful callback to {} for caseType {} and event {}", sanitizeUrl(url), + caseDetails.getCaseTypeId(), caseEvent.getId()); return new CallbackException("Callback to service has been unsuccessful for event " + caseEvent.getName()); }); @@ -136,8 +154,8 @@ private Optional> sendRequest(final String url, final CallbackType callbackType, final Class clazz, final CallbackRequest callbackRequest) { - - HttpHeaders securityHeaders = securityUtils.authorizationHeaders(); + validateCallbackUrl(url); + final String safeUrl = sanitizeUrl(url); CallbackTelemetryThreadContext.setTelemetryContext(new CallbackTelemetryContext(callbackType)); int httpStatus = 0; @@ -146,18 +164,16 @@ private Optional> sendRequest(final String url, try { final HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add("Content-Type", "application/json"); + httpHeaders.add(SecurityUtils.SERVICE_AUTHORIZATION, securityUtils.getServiceAuthorization()); addPassThroughHeaders(httpHeaders); - if (null != securityHeaders) { - httpHeaders.putAll(securityHeaders); - } final HttpEntity requestEntity = new HttpEntity(callbackRequest, httpHeaders); if (logCallbackDetails(url)) { - LOG.info("Invoking callback {} of type {} with request: {}", url, callbackType, + LOG.info("Invoking callback {} of type {} with request: {}", safeUrl, callbackType, printCallbackDetails(requestEntity)); } ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.POST, requestEntity, clazz); if (logCallbackDetails(url)) { - LOG.info("Callback {} response received: {}", url, printCallbackDetails(responseEntity)); + LOG.info("Callback {} response received: {}", safeUrl, printCallbackDetails(responseEntity)); } storePassThroughHeadersAsRequestAttributes(responseEntity, requestEntity, request); @@ -166,7 +182,7 @@ private Optional> sendRequest(final String url, return Optional.of(responseEntity); } catch (RestClientException e) { LOG.warn("Unable to connect to callback service {} because of {} {}", - url, e.getClass().getSimpleName(), e.getMessage()); + safeUrl, e.getClass().getSimpleName(), e.getMessage()); LOG.debug("", e); // debug stack trace if (e instanceof HttpStatusCodeException) { httpStatus = ((HttpStatusCodeException) e).getStatusCode().value(); @@ -174,7 +190,7 @@ private Optional> sendRequest(final String url, return Optional.empty(); } finally { Duration duration = Duration.between(startTime, Instant.now()); - appinsights.trackCallbackEvent(callbackType, url, String.valueOf(httpStatus), duration); + appinsights.trackCallbackEvent(callbackType, safeUrl, String.valueOf(httpStatus), duration); } } @@ -208,10 +224,112 @@ protected void addPassThroughHeaders(final HttpHeaders httpHeaders) { if (null != request && null != applicationParams && null != applicationParams.getCallbackPassthruHeaderContexts()) { applicationParams.getCallbackPassthruHeaderContexts().stream() + .filter(this::isAllowedPassThroughHeader) .forEach(context -> addPassThruContextValuesToHttpHeaders(httpHeaders, context)); } } + private boolean isAllowedPassThroughHeader(String headerName) { + return StringUtils.hasLength(headerName) + && SENSITIVE_HEADERS.stream().noneMatch(sensitive -> sensitive.equalsIgnoreCase(headerName)); + } + + private void validateCallbackUrl(String url) { + final URI callbackUri; + try { + callbackUri = new URI(url); + } catch (URISyntaxException e) { + throw new CallbackException("Invalid callback URL: " + sanitizeUrl(url)); + } + + final String scheme = Optional.ofNullable(callbackUri.getScheme()).orElse("").toLowerCase(Locale.UK); + final String host = callbackUri.getHost(); + if (!StringUtils.hasLength(host)) { + throw new CallbackException("Callback URL must include a host: " + sanitizeUrl(url)); + } + if (StringUtils.hasLength(callbackUri.getUserInfo())) { + throw new CallbackException("Callback URL must not include credentials: " + sanitizeUrl(url)); + } + if (!isAllowedHost(host, applicationParams.getCallbackAllowedHosts())) { + throw new CallbackException("Callback URL host is not allowlisted: " + host); + } + if (!HTTPS_SCHEME.equals(scheme) + && !(HTTP_SCHEME.equals(scheme) && isAllowedHost(host, applicationParams.getCallbackAllowedHttpHosts()))) { + throw new CallbackException("Callback URL scheme is not permitted: " + scheme); + } + if (resolvesToPrivateAddress(host) && !isAllowedHost(host, applicationParams.getCallbackAllowPrivateHosts())) { + throw new CallbackException("Callback URL resolves to a private or local network address: " + host); + } + } + + private boolean isAllowedHost(String host, List allowedHosts) { + if (!StringUtils.hasLength(host) || allowedHosts == null) { + return false; + } + return allowedHosts.stream() + .filter(StringUtils::hasLength) + .map(String::trim) + .anyMatch(allowed -> hostMatches(host, allowed)); + } + + private boolean hostMatches(String host, String allowedHost) { + if (WILDCARD.equals(allowedHost)) { + return true; + } + final String normalisedHost = host.toLowerCase(Locale.UK); + final String normalisedAllowedHost = allowedHost.toLowerCase(Locale.UK); + if (normalisedAllowedHost.startsWith("*.")) { + String suffix = normalisedAllowedHost.substring(1); + return normalisedHost.endsWith(suffix); + } + return normalisedHost.equals(normalisedAllowedHost); + } + + private boolean resolvesToPrivateAddress(String host) { + try { + for (InetAddress address : InetAddress.getAllByName(host)) { + if (isPrivateOrLocal(address)) { + return true; + } + } + return false; + } catch (Exception e) { + throw new CallbackException("Unable to resolve callback host: " + host); + } + } + + private boolean isPrivateOrLocal(InetAddress address) { + if (address instanceof Inet6Address) { + final byte[] addressBytes = address.getAddress(); + if (addressBytes.length > 0 && (addressBytes[0] & (byte) 0xFE) == (byte) 0xFC) { + return true; // IPv6 unique local addresses fc00::/7, including fd00::/8 + } + } + return address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress() + || METADATA_ENDPOINT.equals(address.getHostAddress()); + } + + private String sanitizeUrl(String url) { + if (!StringUtils.hasLength(url)) { + return ""; + } + try { + URI uri = new URI(url); + String scheme = Optional.ofNullable(uri.getScheme()).orElse("unknown"); + String host = Optional.ofNullable(uri.getHost()).orElse("unknown-host"); + int port = uri.getPort(); + String path = Optional.ofNullable(uri.getPath()).orElse(""); + String portPart = port > -1 ? ":" + port : ""; + return scheme + "://" + host + portPart + path; + } catch (URISyntaxException e) { + return ""; + } + } + private void addPassThruContextValuesToHttpHeaders(HttpHeaders httpHeaders, String context) { if (null != request.getAttribute(context)) { if (httpHeaders.containsKey(context)) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 594640355c..99703afe3b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -282,6 +282,10 @@ ccd.tx-timeout.default=${DATA_STORE_TX_TIMEOUT_DEFAULT:30} # CCD list of callback passthru header contexts, comma-separated ccd.callback.passthru-header-contexts=${CALLBACK_PASSTHRU_HEADER_CONTEXTS:Client-Context} +# Callback destination controls. +ccd.callback.allowed-hosts=${CCD_CALLBACK_ALLOWED_HOSTS:localhost,127.0.0.1} +ccd.callback.allowed-http-hosts=${CCD_CALLBACK_ALLOWED_HTTP_HOSTS:localhost,127.0.0.1} +ccd.callback.allow-private-hosts=${CCD_CALLBACK_ALLOW_PRIVATE_HOSTS:localhost,127.0.0.1} # Messaging type mappings - if a type is not specified here, the mapping defaults to the type itself ccd.messaging.type-mappings.Text=SimpleText diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackInvokerWireMockTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackInvokerWireMockTest.java index a180dfedf5..e3f7683a04 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackInvokerWireMockTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackInvokerWireMockTest.java @@ -125,7 +125,7 @@ public void aboutToSubmitShouldRespectReadTimeout() throws Exception { } @Test - public void aboutToSubmitShouldFailFastOnConnectTimeout() { + public void aboutToSubmitShouldRejectNonAllowlistedHostFast() { String unreachableUrl = "http://10.255.255.1:9/unreachable-callback"; caseEventDefinition.setCallBackURLAboutToSubmitEvent(unreachableUrl); caseEventDefinition.setRetriesTimeoutURLAboutToSubmitEvent(Lists.newArrayList(0)); @@ -136,10 +136,10 @@ public void aboutToSubmitShouldFailFastOnConnectTimeout() { caseEventDefinition, caseDetails, caseDetails, caseTypeDefinition, false)); Duration duration = Duration.between(start, Instant.now()); - MatcherAssert.assertThat("connect timeout should not wait for default 30s", + MatcherAssert.assertThat("non-allowlisted host should fail fast", duration.toMillis() < 2_000L); - MatcherAssert.assertThat("exception should mention unsuccessful callback", - ex.getMessage(), CoreMatchers.containsString("Callback to service has been unsuccessful")); + MatcherAssert.assertThat("exception should mention callback host validation", + ex.getMessage(), CoreMatchers.containsString("host is not allowlisted")); } } diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java index 04d9c35ec8..02835059c0 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java @@ -49,6 +49,7 @@ import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -59,7 +60,7 @@ class CallbackServiceTest { - public static final String URL = "/test-callback.*"; + public static final String URL = "https://localhost/test-callback"; public static final CallbackType CALLBACK_TYPE = CallbackType.ABOUT_TO_START; @Mock private SecurityUtils securityUtils; @@ -155,6 +156,10 @@ void setUp() { when(restTemplate .exchange(eq(URL), eq(HttpMethod.POST), isA(HttpEntity.class), eq(CallbackResponse.class))) .thenReturn(responseEntity); + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("*")); + when(applicationParams.getCallbackAllowedHttpHosts()).thenReturn(List.of("*")); + when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(List.of("localhost")); + when(applicationParams.getCcdCallbackLogControl()).thenReturn(List.of()); logger = (Logger) LoggerFactory.getLogger(CallbackService.class); listAppender = new ListAppender<>(); @@ -194,6 +199,101 @@ void shouldNotSetIgnoreWarningsFlagInCallbackRequestIfNullSetByClient() throws E assertThat(argument.getValue().getBody(), hasProperty("ignoreWarning", nullValue())); } + @Test + @DisplayName("Should not forward sensitive security headers to callback") + void shouldNotForwardSensitiveSecurityHeadersToCallback() throws Exception { + callbackService.send(URL, CALLBACK_TYPE, caseEventDefinition, null, caseDetails, false); + + verify(restTemplate).exchange(eq(URL), eq(HttpMethod.POST), argument.capture(), eq(CallbackResponse.class)); + HttpHeaders headers = argument.getValue().getHeaders(); + assertTrue(headers.containsKey(SecurityUtils.SERVICE_AUTHORIZATION)); + assertFalse(headers.containsKey(HttpHeaders.AUTHORIZATION)); + assertFalse(headers.containsKey("user-id")); + assertFalse(headers.containsKey("user-roles")); + } + + @Test + @DisplayName("Should reject callback URL when host not allowlisted") + void shouldRejectCallbackHostWhenNotAllowlisted() { + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("trusted.example.com")); + + assertThrows(CallbackException.class, () -> + callbackService.send("https://evil.example.com/callback", CALLBACK_TYPE, + caseEventDefinition, null, caseDetails, false) + ); + } + + @Test + @DisplayName("Should reject callback URL when non-https host not in approved HTTP host list") + void shouldRejectHttpCallbackHostWhenNotApproved() { + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("*")); + when(applicationParams.getCallbackAllowedHttpHosts()).thenReturn(List.of("localhost")); + + assertThrows(CallbackException.class, () -> + callbackService.send("http://trusted.example.com/callback", CALLBACK_TYPE, + caseEventDefinition, null, caseDetails, false) + ); + } + + @Test + @DisplayName("Should reject callback URL when resolving to localhost and private hosts are not approved") + void shouldRejectLocalhostCallbackWhenPrivateHostNotApproved() { + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("*")); + when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(List.of("trusted.example.com")); + + assertThrows(CallbackException.class, () -> + callbackService.send("https://localhost/callback", CALLBACK_TYPE, + caseEventDefinition, null, caseDetails, false) + ); + } + + @Test + @DisplayName("Should reject callback URL when host is IPv6 unique local address") + void shouldRejectIpv6UlaCallbackWhenPrivateHostNotApproved() { + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("*")); + when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(List.of("trusted.example.com")); + + CallbackException callbackException = assertThrows(CallbackException.class, () -> + callbackService.send("https://[fd00::1]/callback", CALLBACK_TYPE, + caseEventDefinition, null, caseDetails, false) + ); + + assertTrue(callbackException.getMessage().contains("private or local network address")); + } + + @Test + @DisplayName("Should reject callback URL when it includes embedded credentials") + void shouldRejectCallbackUrlWithEmbeddedCredentials() { + assertThrows(CallbackException.class, () -> + callbackService.send("https://user:pass@localhost/callback", CALLBACK_TYPE, + caseEventDefinition, null, caseDetails, false) + ); + } + + @Test + @DisplayName("Should redact callback URL query from validation exception message") + void shouldRedactCallbackUrlQueryFromValidationException() { + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("trusted.example.com")); + + CallbackException callbackException = assertThrows(CallbackException.class, () -> + callbackService.send("https://evil.example.com/callback?token=secret-value", CALLBACK_TYPE, + caseEventDefinition, null, caseDetails, false) + ); + + assertFalse(callbackException.getMessage().contains("secret-value")); + } + + @Test + @DisplayName("Should allow callback URL when host is allowlisted and HTTPS") + void shouldAllowAllowlistedHttpsHost() { + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("localhost")); + + assertThatNoException().isThrownBy(() -> + callbackService.send(URL, CALLBACK_TYPE, + caseEventDefinition, null, caseDetails, false) + ); + } + @Test @DisplayName("Should track callback event") void shouldTrackCallbackEvent() throws Exception { @@ -302,6 +402,25 @@ void shouldAddCallbackPassthruHeadersFromRequestHeader() throws Exception { assertFalse(httpHeaders.containsKey(customHeaders.get(2))); } + @Test + @DisplayName("Should block sensitive callback passthru header contexts") + void shouldBlockSensitiveCallbackPassthruHeaderContexts() { + List customHeaders = List.of("Client-Context", "Authorization", "user-id", "ServiceAuthorization"); + when(applicationParams.getCallbackPassthruHeaderContexts()).thenReturn(customHeaders); + when(request.getHeader("Client-Context")).thenReturn("{ctx:true}"); + when(request.getHeader("Authorization")).thenReturn("Bearer leaked"); + when(request.getHeader("user-id")).thenReturn("u123"); + when(request.getHeader("ServiceAuthorization")).thenReturn("s2s-token"); + + HttpHeaders httpHeaders = new HttpHeaders(); + callbackService.addPassThroughHeaders(httpHeaders); + + assertTrue(httpHeaders.containsKey("Client-Context")); + assertFalse(httpHeaders.containsKey("Authorization")); + assertFalse(httpHeaders.containsKey("user-id")); + assertFalse(httpHeaders.containsKey("ServiceAuthorization")); + } + @Test @DisplayName("Should add callback passthru headers from request attribute") void shouldAddCallbackPassthruHeadersFromRequestAttribute() throws Exception { diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceWireMockTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceWireMockTest.java index 94a3230fba..af6a9bb8ab 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceWireMockTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceWireMockTest.java @@ -474,12 +474,17 @@ public void shouldThrowCallbackException_whenSendInvalidUrlGetGenericBody() { final RestTemplate restTemplate = Mockito.mock(RestTemplate.class); final ApplicationParams applicationParams = Mockito.mock(ApplicationParams.class); given(applicationParams.getCallbackRetries()).willReturn(Arrays.asList(3, 5)); + given(applicationParams.getCallbackAllowedHosts()).willReturn(List.of("localhost")); + given(applicationParams.getCallbackAllowedHttpHosts()).willReturn(List.of("localhost")); + given(applicationParams.getCallbackAllowPrivateHosts()).willReturn(List.of("localhost")); + given(applicationParams.getCallbackPassthruHeaderContexts()).willReturn(Collections.emptyList()); + given(applicationParams.getCcdCallbackLogControl()).willReturn(Collections.emptyList()); given(restTemplate.exchange(anyString(), eq(POST), isA(HttpEntity.class), eq(String.class))) .willThrow(new RestClientException("Fail to process")); // Builds a new callback service to avoid wiremock exception to get in the way final CallbackService underTest = new CallbackService(Mockito.mock(SecurityUtils.class), restTemplate, - Mockito.mock(ApplicationParams.class), Mockito.mock(AppInsights.class), + applicationParams, Mockito.mock(AppInsights.class), Mockito.mock(HttpServletRequest.class), Mockito.mock(ObjectMapper.class)); final CaseDetails caseDetails = new CaseDetails(); final CaseEventDefinition caseEventDefinition = new CaseEventDefinition(); From 66a4bef84ce6b9b81ea10c735f7bbeab6994e5f3 Mon Sep 17 00:00:00 2001 From: hmcts-jenkins-a-to-c <62422075+hmcts-jenkins-a-to-c[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:32:00 +0000 Subject: [PATCH 02/41] Bumping chart version/ fixing aliases --- charts/ccd-data-store-api/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/ccd-data-store-api/Chart.yaml b/charts/ccd-data-store-api/Chart.yaml index 88984aaf76..ddb96abdc3 100644 --- a/charts/ccd-data-store-api/Chart.yaml +++ b/charts/ccd-data-store-api/Chart.yaml @@ -2,7 +2,7 @@ description: Helm chart for the HMCTS CCD Data Store name: ccd-data-store-api apiVersion: v2 home: https://github.com/hmcts/ccd-data-store-api -version: 2.0.37 +version: 2.0.38 maintainers: - name: HMCTS CCD Dev Team email: ccd-devops@HMCTS.NET From f3d91acf44c681eedbd3911a654a137d520a968a Mon Sep 17 00:00:00 2001 From: patelila Date: Fri, 27 Feb 2026 17:31:58 +0000 Subject: [PATCH 03/41] feat(callback-security): enforce ingestion+runtime callback URL validation and update tests/config wiring --- docs/api/security.md | 8 +- .../ContractTestCaseDefinitionRepository.java | 6 +- .../DefaultCaseDefinitionRepository.java | 40 +++++- .../service/callbacks/CallbackService.java | 114 ++------------- .../callbacks/CallbackUrlValidator.java | 124 ++++++++++++++++ .../uk/gov/hmcts/ccd/TestConfiguration.java | 8 +- ...itionRepositoryCallbackValidationTest.java | 136 ++++++++++++++++++ .../DefaultCaseDefinitionRepositoryTest.java | 6 +- .../callbacks/CallbackServiceTest.java | 31 +++- .../CallbackServiceWireMockTest.java | 3 +- .../service/stdapi/CallbackInvokerTest.java | 10 +- .../UICaseControllerGetCaseCallbackIT.java | 9 +- 12 files changed, 373 insertions(+), 122 deletions(-) create mode 100644 src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java create mode 100644 src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java diff --git a/docs/api/security.md b/docs/api/security.md index 419b407f5c..5134b259ea 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -69,7 +69,7 @@ To get your micro-service authorised, please raise a ticket with CCD. ## Callback security hardening -Event callback URLs are validated before outbound requests are sent. This is required to reduce SSRF and token leakage risk when callback URLs originate from case definition data. +Event callback URLs are validated both at definition ingestion/read-time and again before outbound requests are sent (defense in depth). This is required to reduce SSRF and token leakage risk when callback URLs originate from case definition data. ### Objective @@ -78,6 +78,7 @@ Prevent untrusted callback destinations from being invoked and prevent sensitive - Callback hosts must be allowlisted (`CCD_CALLBACK_ALLOWED_HOSTS`). - Callback URLs must use `https` unless the host is explicitly approved for `http` (`CCD_CALLBACK_ALLOWED_HTTP_HOSTS`). - Callback hosts that resolve to local/private ranges are blocked unless explicitly approved (`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`). +- Callback URLs with embedded credentials are rejected (`https://user:pass@host/...`). - Sensitive inbound/user headers are not forwarded to callbacks (`Authorization`, `ServiceAuthorization`, `user-id`, `user-roles`). ### Service rollout checklist @@ -89,5 +90,6 @@ After enabling callback hardening, service teams should: - `CCD_CALLBACK_ALLOWED_HOSTS` - `CCD_CALLBACK_ALLOWED_HTTP_HOSTS` (only for explicitly approved `http` hosts) - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` (only for explicitly approved private/local hosts) -3. Re-run callback integration tests and verify expected callback hosts are accepted. -4. Update alerting/log triage rules to use redacted callback URL output (query and credentials are not logged). +3. Validate callback URLs during definition onboarding/import so invalid URLs are rejected before runtime. +4. Re-run callback integration tests and verify expected callback hosts are accepted. +5. Update alerting/log triage rules to use redacted callback URL output (query and credentials are not logged). diff --git a/src/contractTest/java/uk/gov/hmcts/ccd/v2/external/controller/ContractTestCaseDefinitionRepository.java b/src/contractTest/java/uk/gov/hmcts/ccd/v2/external/controller/ContractTestCaseDefinitionRepository.java index 2b121ae283..e9cfd11563 100644 --- a/src/contractTest/java/uk/gov/hmcts/ccd/v2/external/controller/ContractTestCaseDefinitionRepository.java +++ b/src/contractTest/java/uk/gov/hmcts/ccd/v2/external/controller/ContractTestCaseDefinitionRepository.java @@ -8,6 +8,7 @@ import uk.gov.hmcts.ccd.data.definition.CaseTypeDefinitionVersion; import uk.gov.hmcts.ccd.data.definition.DefaultCaseDefinitionRepository; import uk.gov.hmcts.ccd.data.definition.DefinitionStoreClient; +import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackUrlValidator; @Service @Primary @@ -15,8 +16,9 @@ @Profile("SECURITY_MOCK") public class ContractTestCaseDefinitionRepository extends DefaultCaseDefinitionRepository { public ContractTestCaseDefinitionRepository(ApplicationParams applicationParams, - DefinitionStoreClient definitionStoreClient) { - super(applicationParams, definitionStoreClient); + DefinitionStoreClient definitionStoreClient, + CallbackUrlValidator callbackUrlValidator) { + super(applicationParams, definitionStoreClient, callbackUrlValidator); } public CaseTypeDefinitionVersion getLatestVersion(String caseTypeId) { diff --git a/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java b/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java index e0b63d816d..787b552ab1 100644 --- a/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java +++ b/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java @@ -10,10 +10,12 @@ import org.springframework.web.util.UriComponentsBuilder; import uk.gov.hmcts.ccd.ApplicationParams; import uk.gov.hmcts.ccd.domain.model.definition.CaseFieldDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.CaseEventDefinition; import uk.gov.hmcts.ccd.domain.model.definition.CaseTypeDefinition; import uk.gov.hmcts.ccd.domain.model.definition.FieldTypeDefinition; import uk.gov.hmcts.ccd.domain.model.definition.JurisdictionDefinition; import uk.gov.hmcts.ccd.domain.model.definition.UserRole; +import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackUrlValidator; import uk.gov.hmcts.ccd.endpoint.exceptions.ResourceNotFoundException; import uk.gov.hmcts.ccd.endpoint.exceptions.ServiceException; @@ -42,11 +44,14 @@ public class DefaultCaseDefinitionRepository implements CaseDefinitionRepository private final ApplicationParams applicationParams; private final DefinitionStoreClient definitionStoreClient; + private final CallbackUrlValidator callbackUrlValidator; public DefaultCaseDefinitionRepository(final ApplicationParams applicationParams, - final DefinitionStoreClient definitionStoreClient) { + final DefinitionStoreClient definitionStoreClient, + final CallbackUrlValidator callbackUrlValidator) { this.applicationParams = applicationParams; this.definitionStoreClient = definitionStoreClient; + this.callbackUrlValidator = callbackUrlValidator; } /** @@ -57,9 +62,12 @@ public DefaultCaseDefinitionRepository(final ApplicationParams applicationParams @Override public List getCaseTypesForJurisdiction(final String jurisdictionId) { try { - return Arrays.asList(Objects.requireNonNull(definitionStoreClient.invokeGetRequest( + List caseTypeDefinitions = Arrays.asList(Objects.requireNonNull(definitionStoreClient + .invokeGetRequest( applicationParams.jurisdictionCaseTypesDefURL(jurisdictionId), CaseTypeDefinition[].class).getBody())); + caseTypeDefinitions.forEach(this::validateCaseTypeCallbackUrls); + return caseTypeDefinitions; } catch (Exception e) { LOG.warn("Error while retrieving base type", e); if (e instanceof HttpClientErrorException @@ -89,6 +97,7 @@ public CaseTypeDefinition getCaseType(final String caseTypeId) { if (caseTypeDefinition != null) { caseTypeDefinition.getCaseFieldDefinitions().stream() .forEach(CaseFieldDefinition::propagateACLsToNestedFields); + validateCaseTypeCallbackUrls(caseTypeDefinition); } return caseTypeDefinition; @@ -239,6 +248,33 @@ private List getCaseTypeIdFromJurisdictionDefinition(List getJurisdictionsFromDefinitionStore(Optional> jurisdictionIds) { try { UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(applicationParams.jurisdictionDefURL()); diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java index d160016f1e..fca2c0eb3f 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java @@ -31,14 +31,9 @@ import uk.gov.hmcts.ccd.util.ClientContextUtil; import jakarta.servlet.http.HttpServletRequest; -import java.net.InetAddress; -import java.net.Inet6Address; -import java.net.URI; -import java.net.URISyntaxException; import java.time.Duration; import java.time.Instant; import java.util.List; -import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.function.Predicate; @@ -70,6 +65,7 @@ public class CallbackService { private final AppInsights appinsights; private final HttpServletRequest request; private final ObjectMapper objectMapper; + private final CallbackUrlValidator callbackUrlValidator; @Autowired public CallbackService(final SecurityUtils securityUtils, @@ -77,13 +73,15 @@ public CallbackService(final SecurityUtils securityUtils, final ApplicationParams applicationParams, AppInsights appinsights, HttpServletRequest request, - @Qualifier("DefaultObjectMapper") ObjectMapper objectMapper) { + @Qualifier("DefaultObjectMapper") ObjectMapper objectMapper, + CallbackUrlValidator callbackUrlValidator) { this.securityUtils = securityUtils; this.restTemplate = restTemplate; this.applicationParams = applicationParams; this.appinsights = appinsights; this.request = request; this.objectMapper = objectMapper; + this.callbackUrlValidator = callbackUrlValidator; } // The retry will be on seconds T=1 and T=3 if the initial call fails at T=0 @@ -123,7 +121,7 @@ public Optional sendSingleRequest(final String url, final Optional> responseEntity = sendRequest(url, callbackType, CallbackResponse.class, callbackRequest); return responseEntity.map(re -> Optional.of(re.getBody())).orElseThrow(() -> { - final String safeUrl = sanitizeUrl(url); + final String safeUrl = callbackUrlValidator.sanitizeUrl(url); LOG.warn("Unsuccessful callback to {} for caseType {} and event {}", safeUrl, caseDetails.getCaseTypeId(), caseEvent.getId()); String callbackTypeString = callbackType != null ? callbackType.getValue() : "null"; @@ -143,7 +141,7 @@ public ResponseEntity sendSingleRequest(final String url, final CallbackRequest callbackRequest = new CallbackRequest(caseDetails, caseDetailsBefore, caseEvent.getId()); final Optional> requestEntity = sendRequest(url, callbackType, clazz, callbackRequest); return requestEntity.orElseThrow(() -> { - LOG.warn("Unsuccessful callback to {} for caseType {} and event {}", sanitizeUrl(url), + LOG.warn("Unsuccessful callback to {} for caseType {} and event {}", callbackUrlValidator.sanitizeUrl(url), caseDetails.getCaseTypeId(), caseEvent.getId()); return new CallbackException("Callback to service has been unsuccessful for event " + caseEvent.getName()); @@ -154,8 +152,8 @@ private Optional> sendRequest(final String url, final CallbackType callbackType, final Class clazz, final CallbackRequest callbackRequest) { - validateCallbackUrl(url); - final String safeUrl = sanitizeUrl(url); + callbackUrlValidator.validateCallbackUrl(url); + final String safeUrl = callbackUrlValidator.sanitizeUrl(url); CallbackTelemetryThreadContext.setTelemetryContext(new CallbackTelemetryContext(callbackType)); int httpStatus = 0; @@ -234,102 +232,6 @@ private boolean isAllowedPassThroughHeader(String headerName) { && SENSITIVE_HEADERS.stream().noneMatch(sensitive -> sensitive.equalsIgnoreCase(headerName)); } - private void validateCallbackUrl(String url) { - final URI callbackUri; - try { - callbackUri = new URI(url); - } catch (URISyntaxException e) { - throw new CallbackException("Invalid callback URL: " + sanitizeUrl(url)); - } - - final String scheme = Optional.ofNullable(callbackUri.getScheme()).orElse("").toLowerCase(Locale.UK); - final String host = callbackUri.getHost(); - if (!StringUtils.hasLength(host)) { - throw new CallbackException("Callback URL must include a host: " + sanitizeUrl(url)); - } - if (StringUtils.hasLength(callbackUri.getUserInfo())) { - throw new CallbackException("Callback URL must not include credentials: " + sanitizeUrl(url)); - } - if (!isAllowedHost(host, applicationParams.getCallbackAllowedHosts())) { - throw new CallbackException("Callback URL host is not allowlisted: " + host); - } - if (!HTTPS_SCHEME.equals(scheme) - && !(HTTP_SCHEME.equals(scheme) && isAllowedHost(host, applicationParams.getCallbackAllowedHttpHosts()))) { - throw new CallbackException("Callback URL scheme is not permitted: " + scheme); - } - if (resolvesToPrivateAddress(host) && !isAllowedHost(host, applicationParams.getCallbackAllowPrivateHosts())) { - throw new CallbackException("Callback URL resolves to a private or local network address: " + host); - } - } - - private boolean isAllowedHost(String host, List allowedHosts) { - if (!StringUtils.hasLength(host) || allowedHosts == null) { - return false; - } - return allowedHosts.stream() - .filter(StringUtils::hasLength) - .map(String::trim) - .anyMatch(allowed -> hostMatches(host, allowed)); - } - - private boolean hostMatches(String host, String allowedHost) { - if (WILDCARD.equals(allowedHost)) { - return true; - } - final String normalisedHost = host.toLowerCase(Locale.UK); - final String normalisedAllowedHost = allowedHost.toLowerCase(Locale.UK); - if (normalisedAllowedHost.startsWith("*.")) { - String suffix = normalisedAllowedHost.substring(1); - return normalisedHost.endsWith(suffix); - } - return normalisedHost.equals(normalisedAllowedHost); - } - - private boolean resolvesToPrivateAddress(String host) { - try { - for (InetAddress address : InetAddress.getAllByName(host)) { - if (isPrivateOrLocal(address)) { - return true; - } - } - return false; - } catch (Exception e) { - throw new CallbackException("Unable to resolve callback host: " + host); - } - } - - private boolean isPrivateOrLocal(InetAddress address) { - if (address instanceof Inet6Address) { - final byte[] addressBytes = address.getAddress(); - if (addressBytes.length > 0 && (addressBytes[0] & (byte) 0xFE) == (byte) 0xFC) { - return true; // IPv6 unique local addresses fc00::/7, including fd00::/8 - } - } - return address.isAnyLocalAddress() - || address.isLoopbackAddress() - || address.isLinkLocalAddress() - || address.isSiteLocalAddress() - || address.isMulticastAddress() - || METADATA_ENDPOINT.equals(address.getHostAddress()); - } - - private String sanitizeUrl(String url) { - if (!StringUtils.hasLength(url)) { - return ""; - } - try { - URI uri = new URI(url); - String scheme = Optional.ofNullable(uri.getScheme()).orElse("unknown"); - String host = Optional.ofNullable(uri.getHost()).orElse("unknown-host"); - int port = uri.getPort(); - String path = Optional.ofNullable(uri.getPath()).orElse(""); - String portPart = port > -1 ? ":" + port : ""; - return scheme + "://" + host + portPart + path; - } catch (URISyntaxException e) { - return ""; - } - } - private void addPassThruContextValuesToHttpHeaders(HttpHeaders httpHeaders, String context) { if (null != request.getAttribute(context)) { if (httpHeaders.containsKey(context)) { diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java new file mode 100644 index 0000000000..5015f7ecfa --- /dev/null +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java @@ -0,0 +1,124 @@ +package uk.gov.hmcts.ccd.domain.service.callbacks; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import uk.gov.hmcts.ccd.ApplicationParams; +import uk.gov.hmcts.ccd.endpoint.exceptions.CallbackException; + +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +@Component +public class CallbackUrlValidator { + private static final String WILDCARD = "*"; + private static final String HTTPS_SCHEME = "https"; + private static final String HTTP_SCHEME = "http"; + private static final String METADATA_ENDPOINT = "169.254.169.254"; + + private final ApplicationParams applicationParams; + + public CallbackUrlValidator(ApplicationParams applicationParams) { + this.applicationParams = applicationParams; + } + + public void validateCallbackUrl(String url) { + final URI callbackUri; + try { + callbackUri = new URI(url); + } catch (URISyntaxException e) { + throw new CallbackException("Invalid callback URL: " + sanitizeUrl(url)); + } + + final String scheme = Optional.ofNullable(callbackUri.getScheme()).orElse("").toLowerCase(Locale.UK); + final String host = callbackUri.getHost(); + if (!StringUtils.hasLength(host)) { + throw new CallbackException("Callback URL must include a host: " + sanitizeUrl(url)); + } + if (StringUtils.hasLength(callbackUri.getUserInfo())) { + throw new CallbackException("Callback URL must not include credentials: " + sanitizeUrl(url)); + } + if (!isAllowedHost(host, applicationParams.getCallbackAllowedHosts())) { + throw new CallbackException("Callback URL host is not allowlisted: " + host); + } + if (!HTTPS_SCHEME.equals(scheme) + && !(HTTP_SCHEME.equals(scheme) && isAllowedHost(host, applicationParams.getCallbackAllowedHttpHosts()))) { + throw new CallbackException("Callback URL scheme is not permitted: " + scheme); + } + if (resolvesToPrivateAddress(host) && !isAllowedHost(host, applicationParams.getCallbackAllowPrivateHosts())) { + throw new CallbackException("Callback URL resolves to a private or local network address: " + host); + } + } + + public String sanitizeUrl(String url) { + if (!StringUtils.hasLength(url)) { + return ""; + } + try { + URI uri = new URI(url); + String scheme = Optional.ofNullable(uri.getScheme()).orElse("unknown"); + String host = Optional.ofNullable(uri.getHost()).orElse("unknown-host"); + int port = uri.getPort(); + String path = Optional.ofNullable(uri.getPath()).orElse(""); + String portPart = port > -1 ? ":" + port : ""; + return scheme + "://" + host + portPart + path; + } catch (URISyntaxException e) { + return ""; + } + } + + private boolean isAllowedHost(String host, List allowedHosts) { + if (!StringUtils.hasLength(host) || allowedHosts == null) { + return false; + } + return allowedHosts.stream() + .filter(StringUtils::hasLength) + .map(String::trim) + .anyMatch(allowed -> hostMatches(host, allowed)); + } + + private boolean hostMatches(String host, String allowedHost) { + if (WILDCARD.equals(allowedHost)) { + return true; + } + final String normalisedHost = host.toLowerCase(Locale.UK); + final String normalisedAllowedHost = allowedHost.toLowerCase(Locale.UK); + if (normalisedAllowedHost.startsWith("*.")) { + String suffix = normalisedAllowedHost.substring(1); + return normalisedHost.endsWith(suffix); + } + return normalisedHost.equals(normalisedAllowedHost); + } + + private boolean resolvesToPrivateAddress(String host) { + try { + for (InetAddress address : InetAddress.getAllByName(host)) { + if (isPrivateOrLocal(address)) { + return true; + } + } + return false; + } catch (Exception e) { + throw new CallbackException("Unable to resolve callback host: " + host); + } + } + + private boolean isPrivateOrLocal(InetAddress address) { + if (address instanceof Inet6Address) { + final byte[] addressBytes = address.getAddress(); + if (addressBytes.length > 0 && (addressBytes[0] & (byte) 0xFE) == (byte) 0xFC) { + return true; // IPv6 unique local addresses fc00::/7, including fd00::/8 + } + } + return address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress() + || METADATA_ENDPOINT.equals(address.getHostAddress()); + } +} diff --git a/src/test/java/uk/gov/hmcts/ccd/TestConfiguration.java b/src/test/java/uk/gov/hmcts/ccd/TestConfiguration.java index 608c4c0d71..e2bb020c61 100644 --- a/src/test/java/uk/gov/hmcts/ccd/TestConfiguration.java +++ b/src/test/java/uk/gov/hmcts/ccd/TestConfiguration.java @@ -14,6 +14,7 @@ import uk.gov.hmcts.ccd.data.definition.DefaultCaseDefinitionRepository; import uk.gov.hmcts.ccd.data.definition.DefinitionStoreClient; import uk.gov.hmcts.ccd.domain.model.definition.FieldTypeDefinition; +import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackUrlValidator; import uk.gov.hmcts.ccd.domain.service.common.UIDService; import java.io.IOException; @@ -33,6 +34,7 @@ class TestConfiguration extends ContextCleanupListener { private final ApplicationParams applicationParams; private final DefinitionStoreClient definitionStoreClient; + private final CallbackUrlValidator callbackUrlValidator; private static final ObjectMapper mapper = new ObjectMapper(); @@ -98,9 +100,12 @@ class TestConfiguration extends ContextCleanupListener { + "]"; @Autowired - TestConfiguration(final ApplicationParams applicationParams, DefinitionStoreClient definitionStoreClient) { + TestConfiguration(final ApplicationParams applicationParams, + DefinitionStoreClient definitionStoreClient, + CallbackUrlValidator callbackUrlValidator) { this.applicationParams = applicationParams; this.definitionStoreClient = definitionStoreClient; + this.callbackUrlValidator = callbackUrlValidator; } @Bean @@ -113,6 +118,7 @@ CaseDefinitionRepository caseDefinitionRepository() throws IOException { ReflectionTestUtils.setField(caseDefinitionRepository, "applicationParams", applicationParams); ReflectionTestUtils.setField(caseDefinitionRepository, "definitionStoreClient", definitionStoreClient); + ReflectionTestUtils.setField(caseDefinitionRepository, "callbackUrlValidator", callbackUrlValidator); when(caseDefinitionRepository.getCaseType(any())).thenCallRealMethod(); when(caseDefinitionRepository.getLatestVersion(anyString())).thenCallRealMethod(); diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java new file mode 100644 index 0000000000..bfc4a5e862 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java @@ -0,0 +1,136 @@ +package uk.gov.hmcts.ccd.data.definition; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import uk.gov.hmcts.ccd.ApplicationParams; +import uk.gov.hmcts.ccd.domain.model.definition.CaseEventDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.CaseTypeDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.JurisdictionDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.Version; +import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackUrlValidator; +import uk.gov.hmcts.ccd.endpoint.exceptions.CallbackException; +import uk.gov.hmcts.ccd.endpoint.exceptions.ServiceException; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +class DefaultCaseDefinitionRepositoryCallbackValidationTest { + + @Mock + private ApplicationParams applicationParams; + @Mock + private DefinitionStoreClient definitionStoreClient; + @Mock + private CallbackUrlValidator callbackUrlValidator; + + private DefaultCaseDefinitionRepository subject; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + subject = new DefaultCaseDefinitionRepository(applicationParams, definitionStoreClient, callbackUrlValidator); + } + + @Test + @DisplayName("should fail case type retrieval when callback host is not allowlisted") + void shouldFailOnNonAllowlistedCallbackHost() { + final String callbackUrl = "https://evil.example.com/callback"; + mockCaseTypeResponse(callbackUrl); + doThrow(new CallbackException("Callback URL host is not allowlisted: evil.example.com")) + .when(callbackUrlValidator).validateCallbackUrl(callbackUrl); + + ServiceException exception = assertThrows(ServiceException.class, () -> subject.getCaseType("CT1")); + assertServiceExceptionCauseContains(exception, "host is not allowlisted"); + } + + @Test + @DisplayName("should fail case type retrieval when callback scheme is not permitted") + void shouldFailOnNonPermittedCallbackScheme() { + final String callbackUrl = "http://evil.example.com/callback"; + mockCaseTypeResponse(callbackUrl); + doThrow(new CallbackException("Callback URL scheme is not permitted: http")) + .when(callbackUrlValidator).validateCallbackUrl(callbackUrl); + + ServiceException exception = assertThrows(ServiceException.class, () -> subject.getCaseType("CT2")); + assertServiceExceptionCauseContains(exception, "scheme is not permitted"); + } + + @Test + @DisplayName("should fail case type retrieval when callback resolves to private or local host") + void shouldFailOnPrivateOrLocalCallbackHost() { + final String callbackUrl = "https://localhost/callback"; + mockCaseTypeResponse(callbackUrl); + doThrow(new CallbackException("Callback URL resolves to a private or local network address: localhost")) + .when(callbackUrlValidator).validateCallbackUrl(callbackUrl); + + ServiceException exception = assertThrows(ServiceException.class, () -> subject.getCaseType("CT3")); + assertServiceExceptionCauseContains(exception, "private or local network address"); + } + + @Test + @DisplayName("should fail case type retrieval when callback URL includes embedded credentials") + void shouldFailOnCallbackUrlWithEmbeddedCredentials() { + final String callbackUrl = "https://user:pass@localhost/callback"; + mockCaseTypeResponse(callbackUrl); + doThrow(new CallbackException("Callback URL must not include credentials: https://localhost/callback")) + .when(callbackUrlValidator).validateCallbackUrl(callbackUrl); + + ServiceException exception = assertThrows(ServiceException.class, () -> subject.getCaseType("CT4")); + assertServiceExceptionCauseContains(exception, "must not include credentials"); + } + + @Test + @DisplayName("should validate event callback URLs during case type retrieval") + void shouldValidateEventCallbackUrls() { + CaseTypeDefinition caseTypeDefinition = buildCaseTypeWithCallback("https://localhost/callback"); + CaseEventDefinition eventDefinition = new CaseEventDefinition(); + eventDefinition.setCallBackURLAboutToSubmitEvent("https://evil.example.com/event-callback"); + caseTypeDefinition.setEvents(List.of(eventDefinition)); + when(definitionStoreClient.invokeGetRequest(nullable(String.class), eq(CaseTypeDefinition.class))) + .thenReturn(new ResponseEntity<>(caseTypeDefinition, HttpStatus.OK)); + doThrow(new CallbackException("Callback URL host is not allowlisted: evil.example.com")) + .when(callbackUrlValidator).validateCallbackUrl("https://evil.example.com/event-callback"); + + ServiceException exception = assertThrows(ServiceException.class, () -> subject.getCaseType("CT5")); + assertServiceExceptionCauseContains(exception, "host is not allowlisted"); + } + + private void mockCaseTypeResponse(String callbackUrl) { + when(definitionStoreClient.invokeGetRequest(nullable(String.class), eq(CaseTypeDefinition.class))) + .thenReturn(new ResponseEntity<>(buildCaseTypeWithCallback(callbackUrl), HttpStatus.OK)); + } + + private CaseTypeDefinition buildCaseTypeWithCallback(String callbackUrl) { + CaseTypeDefinition caseTypeDefinition = new CaseTypeDefinition(); + caseTypeDefinition.setId("CT"); + Version version = new Version(); + version.setNumber(1); + caseTypeDefinition.setVersion(version); + caseTypeDefinition.setName("Case Type"); + caseTypeDefinition.setDescription("Case Type Desc"); + JurisdictionDefinition jurisdictionDefinition = new JurisdictionDefinition(); + jurisdictionDefinition.setId("PROBATE"); + jurisdictionDefinition.setName("Probate"); + caseTypeDefinition.setJurisdictionDefinition(jurisdictionDefinition); + caseTypeDefinition.setCaseFieldDefinitions(new ArrayList<>()); + caseTypeDefinition.setCallbackGetCaseUrl(callbackUrl); + return caseTypeDefinition; + } + + private void assertServiceExceptionCauseContains(ServiceException exception, String expectedMessagePart) { + assertTrue(exception.getCause() instanceof CallbackException); + assertTrue(exception.getCause().getMessage().contains(expectedMessagePart)); + } +} diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryTest.java index f240e4b07a..63e269d80d 100644 --- a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryTest.java @@ -18,6 +18,7 @@ import uk.gov.hmcts.ccd.domain.model.definition.FieldTypeDefinition; import uk.gov.hmcts.ccd.domain.model.definition.JurisdictionDefinition; import uk.gov.hmcts.ccd.domain.model.definition.UserRole; +import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackUrlValidator; import uk.gov.hmcts.ccd.endpoint.exceptions.ResourceNotFoundException; import uk.gov.hmcts.ccd.endpoint.exceptions.ServiceException; @@ -47,6 +48,8 @@ public class DefaultCaseDefinitionRepositoryTest { @Mock private RestTemplate restTemplate; + @Mock + private CallbackUrlValidator callbackUrlValidator; private CaseDefinitionRepository caseDefinitionRepository; @@ -56,7 +59,8 @@ public void setup() { doReturn(new HttpHeaders()).when(securityUtils).authorizationHeaders(); doReturn(new HttpHeaders()).when(securityUtils).userAuthorizationHeaders(); - caseDefinitionRepository = new DefaultCaseDefinitionRepository(applicationParams, definitionStoreClient); + caseDefinitionRepository = new DefaultCaseDefinitionRepository(applicationParams, definitionStoreClient, + callbackUrlValidator); } @Test diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java index 02835059c0..3b688a78b2 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java @@ -54,6 +54,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -80,6 +81,8 @@ class CallbackServiceTest { private Jwt principal; @Mock private ObjectMapper objectMapper; + @Mock + private CallbackUrlValidator callbackUrlValidator; @Captor private ArgumentCaptor argument; @@ -150,7 +153,7 @@ void setUp() { initSecurityContext(); callbackService = new CallbackService(securityUtils, restTemplate, applicationParams, appinsights, request, - objectMapper); + objectMapper, callbackUrlValidator); final ResponseEntity responseEntity = new ResponseEntity<>(callbackResponse, HttpStatus.OK); when(restTemplate @@ -160,6 +163,32 @@ void setUp() { when(applicationParams.getCallbackAllowedHttpHosts()).thenReturn(List.of("*")); when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(List.of("localhost")); when(applicationParams.getCcdCallbackLogControl()).thenReturn(List.of()); + when(callbackUrlValidator.sanitizeUrl(URL)).thenReturn(URL); + when(callbackUrlValidator.sanitizeUrl("https://evil.example.com/callback")) + .thenReturn("https://evil.example.com/callback"); + when(callbackUrlValidator.sanitizeUrl("http://trusted.example.com/callback")) + .thenReturn("http://trusted.example.com/callback"); + when(callbackUrlValidator.sanitizeUrl("https://localhost/callback")) + .thenReturn("https://localhost/callback"); + when(callbackUrlValidator.sanitizeUrl("https://[fd00::1]/callback")) + .thenReturn("https://[fd00::1]/callback"); + when(callbackUrlValidator.sanitizeUrl("https://user:pass@localhost/callback")) + .thenReturn("https://localhost/callback"); + when(callbackUrlValidator.sanitizeUrl("https://evil.example.com/callback?token=secret-value")) + .thenReturn("https://evil.example.com/callback"); + + doThrow(new CallbackException("Callback URL host is not allowlisted: evil.example.com")) + .when(callbackUrlValidator).validateCallbackUrl("https://evil.example.com/callback"); + doThrow(new CallbackException("Callback URL scheme is not permitted: http")) + .when(callbackUrlValidator).validateCallbackUrl("http://trusted.example.com/callback"); + doThrow(new CallbackException("Callback URL resolves to a private or local network address: localhost")) + .when(callbackUrlValidator).validateCallbackUrl("https://localhost/callback"); + doThrow(new CallbackException("Callback URL resolves to a private or local network address: fd00::1")) + .when(callbackUrlValidator).validateCallbackUrl("https://[fd00::1]/callback"); + doThrow(new CallbackException("Callback URL must not include credentials: https://localhost/callback")) + .when(callbackUrlValidator).validateCallbackUrl("https://user:pass@localhost/callback"); + doThrow(new CallbackException("Callback URL host is not allowlisted: evil.example.com")) + .when(callbackUrlValidator).validateCallbackUrl("https://evil.example.com/callback?token=secret-value"); logger = (Logger) LoggerFactory.getLogger(CallbackService.class); listAppender = new ListAppender<>(); diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceWireMockTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceWireMockTest.java index af6a9bb8ab..a13a719c16 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceWireMockTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceWireMockTest.java @@ -483,9 +483,10 @@ public void shouldThrowCallbackException_whenSendInvalidUrlGetGenericBody() { .willThrow(new RestClientException("Fail to process")); // Builds a new callback service to avoid wiremock exception to get in the way + final CallbackUrlValidator callbackUrlValidator = new CallbackUrlValidator(applicationParams); final CallbackService underTest = new CallbackService(Mockito.mock(SecurityUtils.class), restTemplate, applicationParams, Mockito.mock(AppInsights.class), - Mockito.mock(HttpServletRequest.class), Mockito.mock(ObjectMapper.class)); + Mockito.mock(HttpServletRequest.class), Mockito.mock(ObjectMapper.class), callbackUrlValidator); final CaseDetails caseDetails = new CaseDetails(); final CaseEventDefinition caseEventDefinition = new CaseEventDefinition(); caseEventDefinition.setId("TEST-EVENT"); diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/stdapi/CallbackInvokerTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/stdapi/CallbackInvokerTest.java index 8b27be184a..0fed59ba7a 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/stdapi/CallbackInvokerTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/stdapi/CallbackInvokerTest.java @@ -36,6 +36,7 @@ import uk.gov.hmcts.ccd.domain.model.definition.CaseTypeDefinition; import uk.gov.hmcts.ccd.domain.model.definition.WizardPage; import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackService; +import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackUrlValidator; import uk.gov.hmcts.ccd.domain.service.casedeletion.TimeToLiveService; import uk.gov.hmcts.ccd.domain.service.common.CaseDataService; import uk.gov.hmcts.ccd.domain.service.common.CaseTypeService; @@ -214,7 +215,11 @@ void shouldDisableCallbackRetries() { @Test @DisplayName("should handle exception in printCallbackDetails gracefully") void shouldHandleExceptionInPrintCallbackDetailsGracefully() throws JsonProcessingException { + final String testUrl = "https://localhost/about-to-start"; when(applicationParams.getCcdCallbackLogControl()).thenReturn(Collections.singletonList("*")); + when(applicationParams.getCallbackAllowedHosts()).thenReturn(Collections.singletonList("localhost")); + when(applicationParams.getCallbackAllowedHttpHosts()).thenReturn(Collections.singletonList("localhost")); + when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(Collections.singletonList("localhost")); doNothing().when(appinsights).trackCallbackEvent(any(), anyString(), anyString(), any(Duration.class)); CallbackService callbackService = new CallbackService(securityUtils, @@ -222,7 +227,8 @@ void shouldHandleExceptionInPrintCallbackDetailsGracefully() throws JsonProcessi applicationParams, appinsights, null, - objectMapper); + objectMapper, + new CallbackUrlValidator(applicationParams)); when(objectMapper.writeValueAsString(any())) .thenThrow(new RuntimeException("Mocked exception")); @@ -232,7 +238,7 @@ void shouldHandleExceptionInPrintCallbackDetailsGracefully() throws JsonProcessi .thenReturn(new ResponseEntity<>(new CallbackResponse(), HttpStatus.OK)); Optional response = callbackService.sendSingleRequest( - URL_ABOUT_TO_START, + testUrl, ABOUT_TO_START, caseEventDefinition, null, diff --git a/src/test/java/uk/gov/hmcts/ccd/v2/internal/controller/UICaseControllerGetCaseCallbackIT.java b/src/test/java/uk/gov/hmcts/ccd/v2/internal/controller/UICaseControllerGetCaseCallbackIT.java index 7e55d04712..4e5d65ebd9 100644 --- a/src/test/java/uk/gov/hmcts/ccd/v2/internal/controller/UICaseControllerGetCaseCallbackIT.java +++ b/src/test/java/uk/gov/hmcts/ccd/v2/internal/controller/UICaseControllerGetCaseCallbackIT.java @@ -64,7 +64,8 @@ public void setUp() throws Exception { final String jsonString = TestFixtures .fromFileAsString("__files/test-addressbook-get-case-callback.json") - .replace("${GET_CASE_CALLBACK_URL}", hostUrl + GET_CASE_CALLBACK); + .replace("${GET_CASE_CALLBACK_URL}", hostUrl + GET_CASE_CALLBACK) + .replace("${CALLBACK_URL}", hostUrl + "/callback/unused"); stubFor(WireMock.get(urlMatching("/api/data/case-type/" + TEST_CASE_TYPE)) .willReturn(okJson(jsonString).withStatus(200))); @@ -190,7 +191,8 @@ public void shouldReturn200WithTriggerWhenInjectedDataMetadataFieldsMatchGetCase final String jsonString = TestFixtures .fromFileAsString("__files/test-addressbook-get-case-callback_injected_data.json") - .replace("${GET_CASE_CALLBACK_URL}", hostUrl + GET_CASE_CALLBACK); + .replace("${GET_CASE_CALLBACK_URL}", hostUrl + GET_CASE_CALLBACK) + .replace("${CALLBACK_URL}", hostUrl + "/callback/unused"); stubFor(WireMock.get(urlMatching("/api/data/case-type/" + TEST_CASE_TYPE)) .willReturn(okJson(jsonString).withStatus(200))); @@ -237,7 +239,8 @@ public void shouldReturn200WithoutTriggerWhenInjectedDataMetadataFieldsMatchGetC final String jsonString = TestFixtures .fromFileAsString("__files/test-addressbook-get-case-callback_injected_data.json") - .replace("${GET_CASE_CALLBACK_URL}", hostUrl + GET_CASE_CALLBACK); + .replace("${GET_CASE_CALLBACK_URL}", hostUrl + GET_CASE_CALLBACK) + .replace("${CALLBACK_URL}", hostUrl + "/callback/unused"); stubFor(WireMock.get(urlMatching("/api/data/case-type/" + TEST_CASE_TYPE)) .willReturn(okJson(jsonString).withStatus(200))); From f0d416c43f7cbae47f6a6383789a2b27d78e92a7 Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 2 Mar 2026 12:43:00 +0000 Subject: [PATCH 04/41] chore(security): complete SSRF hardening pass, sonar remediation, and coverage/checkstyle updates --- AGENTS.md | 58 ++++++ docs/api/security.md | 1 + docs/integration.md | 8 + skills/ccd-callback-ssrf-hardening/SKILL.md | 82 ++++++++ .../agents/openai.yaml | 4 + .../references/callback-hotspots.md | 53 ++++++ .../scripts/scan_callback_risks.sh | 36 ++++ skills/ccd-sonarqube-remediation/SKILL.md | 78 ++++++++ .../agents/openai.yaml | 4 + .../ccd/config/JacksonObjectMapperConfig.java | 4 +- .../DefaultCaseDefinitionRepository.java | 2 + .../service/callbacks/CallbackService.java | 15 +- .../callbacks/CallbackUrlValidator.java | 6 +- .../casedeletion/TimeToLiveService.java | 2 +- .../message/CaseEventMessageService.java | 2 +- .../ElasticsearchCaseSearchOperation.java | 2 +- .../ElasticsearchSortService.java | 2 +- .../ccd/AbstractBaseIntegrationTest.java | 2 +- ...faultCaseDefinitionRepositoryCoreTest.java | 178 ++++++++++++++++++ ...tCaseDefinitionRepositoryCoverageTest.java | 76 ++++++++ .../callbacks/CallbackUrlValidatorTest.java | 94 +++++++++ .../lau/AuditCaseRemoteOperationIT.java | 2 +- 22 files changed, 693 insertions(+), 18 deletions(-) create mode 100644 AGENTS.md create mode 100644 skills/ccd-callback-ssrf-hardening/SKILL.md create mode 100644 skills/ccd-callback-ssrf-hardening/agents/openai.yaml create mode 100644 skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md create mode 100755 skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh create mode 100644 skills/ccd-sonarqube-remediation/SKILL.md create mode 100644 skills/ccd-sonarqube-remediation/agents/openai.yaml create mode 100644 src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java create mode 100644 src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoverageTest.java create mode 100644 src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..37229def6f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# Agents + +## CCD Callback SSRF Hardening + +Use the `ccd-callback-ssrf-hardening` agent for any callback security change in this repository, especially around event callbacks, webhook URL ingestion, or auth header handling. + +### Trigger Phrases + +- "Use ccd-callback-ssrf-hardening" +- "Run callback SSRF hardening" +- "Audit callback token leakage" + +### Recommended Prompt Template + +```text +Use ccd-callback-ssrf-hardening on hmcts/ccd-data-store-api. +Scope: +- src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +- src/main/java/uk/gov/hmcts/ccd/data/SecurityUtils.java +- callback URL ingestion/parsing paths +Tasks: +1. Detect SSRF and credential leakage patterns. +2. Enforce callback URL validation (allowlist, HTTPS, private/internal target blocking). +3. Remove sensitive header forwarding (Authorization, ServiceAuthorization, user-id, user-roles). +4. Add/update regression tests. +5. Summarize risk reduction and residual risk. +``` + +### Quick Scanner + +```bash +bash skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh +``` + +## CCD SonarQube Remediation + +Use the `ccd-sonarqube-remediation` agent for SonarQube-driven cleanup and quality fixes in this repository, especially maintainability/code smell issues that require safe refactors and test wiring updates. + +### Trigger Phrases + +- "Use ccd-sonarqube-remediation" +- "Fix Sonar issues" +- "Address code smells from SonarQube" + +### Recommended Prompt Template + +```text +Use ccd-sonarqube-remediation on hmcts/ccd-data-store-api. +Scope: +- files flagged by current SonarQube findings +Tasks: +1. Reproduce and identify root cause for each finding. +2. Patch with minimal behavior change and clear naming/structure. +3. Update affected tests and fixtures if constructor/bean wiring changes. +4. Add/update tests so coverage for new/changed code is at least 80%. +5. Run targeted Gradle compile/tests and checkstyle for touched areas. +6. Summarize risks, behavior impact, and follow-up actions. +``` diff --git a/docs/api/security.md b/docs/api/security.md index 5134b259ea..311abaa887 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -78,6 +78,7 @@ Prevent untrusted callback destinations from being invoked and prevent sensitive - Callback hosts must be allowlisted (`CCD_CALLBACK_ALLOWED_HOSTS`). - Callback URLs must use `https` unless the host is explicitly approved for `http` (`CCD_CALLBACK_ALLOWED_HTTP_HOSTS`). - Callback hosts that resolve to local/private ranges are blocked unless explicitly approved (`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`). +- Cloud instance metadata endpoint targets are explicitly blocked (for example `169.254.169.254`). - Callback URLs with embedded credentials are rejected (`https://user:pass@host/...`). - Sensitive inbound/user headers are not forwarded to callbacks (`Authorization`, `ServiceAuthorization`, `user-id`, `user-roles`). diff --git a/docs/integration.md b/docs/integration.md index 6f98f45092..8ed0e53305 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -6,3 +6,11 @@ CCD Data Store is where case data lives. * [API Security](api/security.md) * [Case data format](api/case-data.md) + +## Callback Test Fixtures + +When loading case definition fixtures in integration tests, ensure callback URL placeholders (for example +`${CALLBACK_URL}` and `${GET_CASE_CALLBACK_URL}`) are always replaced with valid absolute URLs. + +Callback URL hardening validates definition callback URLs at ingestion/read-time, so unresolved placeholders will now +be rejected as invalid URLs before callback execution. diff --git a/skills/ccd-callback-ssrf-hardening/SKILL.md b/skills/ccd-callback-ssrf-hardening/SKILL.md new file mode 100644 index 0000000000..e5c5f0013f --- /dev/null +++ b/skills/ccd-callback-ssrf-hardening/SKILL.md @@ -0,0 +1,82 @@ +--- +name: ccd-callback-ssrf-hardening +description: Detect and remediate SSRF and credential leakage in CCD callback flows for hmcts/ccd-data-store-api and related HMCTS services. Use when working on callback handlers, URL ingestion/validation, WebhookEntity parsing, RestTemplate callback POSTs, or security header forwarding (ServiceAuthorization, Authorization, user-id, user-roles) during case creation/state-change events. +--- + +# CCD Callback SSRF Hardening + +## Overview + +Use this skill to harden CCD event callbacks against SSRF and token leakage. +Apply a consistent workflow: find callback URL sources, block untrusted targets, stop forwarding sensitive headers, and enforce regression tests. + +## Workflow + +1. Identify callback entry points and URL sources. +2. Confirm whether untrusted callback URLs can be stored/imported. +3. Inspect callback HTTP invocation for sensitive header forwarding. +4. Implement URL validation and outbound destination restrictions. +5. Minimize callback credentials (do not pass through user JWT/context headers). +6. Add or update tests for allowlist/denylist and header behavior. + +## Hotspots In This Repository + +Start with these files: + +- `src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java` +- `src/main/java/uk/gov/hmcts/ccd/data/SecurityUtils.java` +- Definition import/parsing code that persists callback URLs (for example webhook/event definition parser classes) + +Load [references/callback-hotspots.md](references/callback-hotspots.md) for targeted checks and expected secure behavior. + +## Required Secure Outcomes + +- Reject callback URLs that are not in approved domains/hosts. +- Reject non-HTTPS callback URLs except explicitly approved local/test environments. +- Block SSRF targets (localhost, loopback, link-local, private CIDRs, metadata endpoints). +- Remove pass-through of end-user credentials/context to callbacks by default. +- Use least-privilege callback authentication model (service-only or dedicated callback token). +- Log blocked callbacks with safe redaction and enough metadata for incident triage. + +## Implementation Guidance + +### URL Validation + +- Parse with `URI`/`URL` defensively and fail closed on parse errors. +- Validate scheme, host, and optional port. +- Enforce configured allowlist from application configuration. +- Resolve DNS as needed and reject private/internal address ranges. + +### Header Policy + +- Do not call `putAll(securityHeaders)` into callback request headers. +- Explicitly construct allowed outbound headers. +- Never forward `Authorization`, `ServiceAuthorization`, `user-id`, or `user-roles` unless explicitly required and approved. + +### Testing + +Add tests for: + +- Allowed trusted callback URL succeeds. +- Disallowed domain is rejected. +- SSRF targets are rejected. +- Callback request excludes sensitive inbound/user headers. +- Regression test for previously vulnerable callback path. + +## Quick Commands + +Use the bundled scanner to find risky patterns quickly: + +```bash +bash skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh +``` + +Then inspect matches and patch code accordingly. + +## Deliverable Checklist + +- URL validation implemented in callback URL ingestion and/or invocation path. +- Sensitive header forwarding removed or strictly allowlisted. +- Configuration introduced for trusted callback destinations. +- Unit/integration tests cover validation and header policy. +- Security notes added to PR describing threat model and mitigations. diff --git a/skills/ccd-callback-ssrf-hardening/agents/openai.yaml b/skills/ccd-callback-ssrf-hardening/agents/openai.yaml new file mode 100644 index 0000000000..3f589b432d --- /dev/null +++ b/skills/ccd-callback-ssrf-hardening/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CCD Callback SSRF Hardening" + short_description: "Secure CCD callbacks against SSRF and token leakage" + default_prompt: "Use ccd-callback-ssrf-hardening to audit and fix CCD event callback risks in hmcts/ccd-data-store-api: enforce callback URL validation in callback execution paths (host allowlist, HTTPS policy, private/local target blocking), prevent forwarding of Authorization/ServiceAuthorization/user-id/user-roles headers, update/add regression tests for hardened behavior, and ensure deployment/docs are updated for callback security settings (CCD_CALLBACK_ALLOWED_HOSTS, CCD_CALLBACK_ALLOWED_HTTP_HOSTS, CCD_CALLBACK_ALLOW_PRIVATE_HOSTS)." diff --git a/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md b/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md new file mode 100644 index 0000000000..f02bd2386e --- /dev/null +++ b/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md @@ -0,0 +1,53 @@ +# Callback SSRF Hotspots (ccd-data-store-api) + +## Primary Paths + +- `src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java` + - Look for callback POST execution (`RestTemplate.exchange(...)`) and outbound header assembly. + - Flag any `httpHeaders.putAll(securityHeaders)` or equivalent broad header copy. + +- `src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java` + - Keep URL policy centralised (allowlist/scheme/private-network/credentials checks + URL redaction). + +- `src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java` + - Confirm callback URL validation runs while retrieving/ingesting case definitions, not only at callback execution time. + +- `src/main/java/uk/gov/hmcts/ccd/data/SecurityUtils.java` + - Confirm which security headers are exposed by `authorizationHeaders()`. + - Treat `ServiceAuthorization`, `Authorization`, `user-id`, and `user-roles` as sensitive. + +- Definition parsing/persistence for callback URL fields (e.g., webhook parser/entity mapping) + - Ensure callback URL normalization + validation exists at import time. + - Reject blank/invalid URLs and untrusted hosts. + +## Risk Pattern To Eliminate + +1. Callback URL comes from case definition input and is trusted without validation. +2. Callback invocation forwards inbound auth/user headers to external URL. +3. Attacker controls URL and receives service + user credentials. + +## Secure Design Pattern + +1. Validate URL at import and before invocation (defense in depth). +2. Restrict callback destination via allowlist config. +3. Build outbound headers from explicit allowlist; default deny sensitive headers. +4. Prefer dedicated callback auth over user token propagation. + +## Suggested Validation Rules + +- Scheme: `https` by default; `http` only for explicitly approved hosts (`CCD_CALLBACK_ALLOWED_HTTP_HOSTS`). +- Host allowlist: exact domains and/or controlled subdomain rules (`CCD_CALLBACK_ALLOWED_HOSTS`). +- DNS/IP checks: reject loopback, private, link-local, multicast, and metadata service ranges unless explicitly approved (`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`). + +## Recommended Next Controls (Not Yet Enforced Here) + +- Port restrictions: allow only expected ports. +- Redirect policy: do not follow redirects to untrusted hosts. + +## Test Matrix + +- Valid allowlisted HTTPS host -> accepted. +- Non-allowlisted host -> rejected. +- `http://` URL for non-approved host -> rejected. +- Localhost/127.0.0.1/::1/private CIDR host -> rejected. +- Callback request headers do not include `Authorization`, `ServiceAuthorization`, `user-id`, `user-roles` unless explicitly approved. diff --git a/skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh b/skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh new file mode 100755 index 0000000000..2897cce580 --- /dev/null +++ b/skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${1:-.}" + +if ! command -v rg >/dev/null 2>&1; then + echo "[ERROR] rg (ripgrep) is required." >&2 + exit 1 +fi + +echo "== CCD callback risk scan in: ${ROOT} ==" + +echo +printf '%s\n' "-- Callback HTTP invocation candidates (high signal) --" +rg -n --no-heading 'CallbackService|CallbackInvoker|CallbackUrlValidator|DefaultCaseDefinitionRepository|sendSingleRequest\(|callback.*exchange\(|exchange\(.*callback|callBackURL|callbackGetCaseUrl|validateCallbackUrl' "$ROOT/src/main/java" || true + +echo +printf '%s\n' "-- Outbound HTTP client usage (broad sweep) --" +rg -n --no-heading 'restTemplate\.exchange\(|RestTemplate|WebClient|FeignClient|HttpClient|HttpMethod\.POST' "$ROOT/src/main/java" || true + +echo +printf '%s\n' "-- Potential broad header forwarding --" +rg -n --no-heading 'putAll\(|authorizationHeaders\(|addPassThroughHeaders\(|ServiceAuthorization|Authorization|user-roles|user-id' "$ROOT/src/main/java" || true + +echo +printf '%s\n' "-- Callback URL ingestion/model points --" +rg -n --no-heading 'callback_url_about_to_start_event|callback_url_about_to_submit_event|callback_url_submitted_event|callback_url_mid_event|callback_get_case_url|setCallBackURL|setCallbackGetCaseUrl|setUrl\(|webhook' "$ROOT/src/main/java" || true + +echo +printf '%s\n' "-- Potential missing URL validation clues --" +rg -n --no-heading 'URI\(|URL\(|allowlist|whitelist|trusted|validate.*url|InvalidUrlException' "$ROOT/src/main/java" || true + +echo +printf '%s\n' "-- Callback hardening config presence --" +rg -n --no-heading 'ccd\.callback\.(allowed-hosts|allowed-http-hosts|allow-private-hosts|passthru-header-contexts)' \ + "$ROOT/src/main/resources" "$ROOT/charts" "$ROOT/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java" 2>/dev/null || true diff --git a/skills/ccd-sonarqube-remediation/SKILL.md b/skills/ccd-sonarqube-remediation/SKILL.md new file mode 100644 index 0000000000..40a4dc8702 --- /dev/null +++ b/skills/ccd-sonarqube-remediation/SKILL.md @@ -0,0 +1,78 @@ +--- +name: ccd-sonarqube-remediation +description: Triage and fix SonarQube findings in hmcts/ccd-data-store-api with minimal-risk refactors, explicit behavior checks, and targeted regression validation. Use for maintainability/code smell fixes, naming/regex compliance, logging/performance improvements, and Spring wiring/qualifier updates. +--- + +# CCD SonarQube Remediation + +## Overview + +Use this skill to handle SonarQube issues with safe, reviewable patches. +Prioritize behavior-preserving changes, then verify with focused compile/tests. + +## Workflow + +1. Identify the exact finding and location (rule key, file, line, message). +2. Confirm whether the issue is real or intentional. +3. Apply the smallest safe code change that satisfies the rule. +4. Update dependent wiring/tests/fixtures when constructor or bean names change. +5. Add or update tests so new/changed code paths meet at least 80% coverage. +6. Run targeted Gradle compile/tests and checkstyle for changed areas. +7. Report behavior impact, risk, and any residual items. + +## Typical Findings In This Repository + +- Naming compliance (`^[a-z][a-zA-Z0-9]*$`) for Spring bean names. +- Invoke methods conditionally to avoid unnecessary work in logging paths. +- Remove unused fields/constants/imports. +- Replace ambiguous names with intent-revealing identifiers. +- Keep callback/security hardening comments explicit where rules or literals may look suspicious. + +## Hotspots In This Repository + +- `src/main/java/uk/gov/hmcts/ccd/config/JacksonObjectMapperConfig.java` +- `src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java` +- `src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java` +- `src/test/java/uk/gov/hmcts/ccd/` (bean qualifier and integration test wiring) + +## Implementation Guidance + +### Keep Behavior Stable + +- Default to refactors that do not change runtime outcomes. +- If behavior changes are necessary, call them out explicitly before patching. + +### Spring Bean/Wiring Changes + +- When renaming bean names, update every `@Qualifier` reference in main and test code. +- Re-run `compileJava` and `compileTestJava` after qualifier changes. + +### Logging/Performance Changes + +- Guard expensive argument construction with log-level checks where appropriate. +- Prefer one computed guard boolean reused in nearby log statements. + +### Tests and Fixtures + +- If validation shifts earlier (ingestion-time vs runtime), ensure test fixtures contain valid placeholder replacements. +- Fix tests by aligning setup with real production wiring, not by weakening assertions. +- Coverage gate: ensure coverage for touched/new logic is >=80% (line/branch as available in project reports). + +## Quick Commands + +```bash +rg -n "sonar|@Qualifier\\(|@Bean\\(name|unused|WILDCARD|printCallbackDetails" src/main/java src/test/java +./gradlew compileJava compileTestJava +./gradlew test --tests +./gradlew checkstyleMain checkstyleTest +``` + +## Deliverable Checklist + +- Each finding has a clear root cause and patch rationale. +- No accidental behavior regressions introduced. +- Main and test wiring updated for any renamed beans/constructors. +- Coverage for new/changed code is >=80%. +- Checkstyle warnings/errors are resolved for touched files. +- Targeted compile/tests pass for modified areas. +- Residual risks and deferred items are documented. diff --git a/skills/ccd-sonarqube-remediation/agents/openai.yaml b/skills/ccd-sonarqube-remediation/agents/openai.yaml new file mode 100644 index 0000000000..a018057065 --- /dev/null +++ b/skills/ccd-sonarqube-remediation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CCD SonarQube Remediation" + short_description: "Fix Sonar findings with safe, test-backed patches" + default_prompt: "Use ccd-sonarqube-remediation to triage and fix current SonarQube findings in hmcts/ccd-data-store-api: identify root cause per finding, apply minimal-risk refactors, update Spring bean qualifiers/tests/fixtures as needed, ensure tests cover new/changed code at >=80%, run targeted compile/tests plus checkstyle, and summarize behavior impact plus residual risks." diff --git a/src/main/java/uk/gov/hmcts/ccd/config/JacksonObjectMapperConfig.java b/src/main/java/uk/gov/hmcts/ccd/config/JacksonObjectMapperConfig.java index 5ae18c3ea2..4b7129da68 100644 --- a/src/main/java/uk/gov/hmcts/ccd/config/JacksonObjectMapperConfig.java +++ b/src/main/java/uk/gov/hmcts/ccd/config/JacksonObjectMapperConfig.java @@ -21,7 +21,7 @@ public class JacksonObjectMapperConfig { * @return Default ObjectMapper, used by Spring and HAL to serialise responses, and deserialise requests. */ @Primary - @Bean(name = "DefaultObjectMapper") + @Bean(name = "defaultObjectMapper") public ObjectMapper defaultObjectMapper() { return new ObjectMapper() .registerModule(new Jdk8Module()) @@ -30,7 +30,7 @@ public ObjectMapper defaultObjectMapper() { .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); } - @Bean(name = "SimpleObjectMapper") + @Bean(name = "simpleObjectMapper") public ObjectMapper simpleObjectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); diff --git a/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java b/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java index 787b552ab1..3949f30305 100644 --- a/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java +++ b/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java @@ -252,6 +252,8 @@ private void validateCaseTypeCallbackUrls(CaseTypeDefinition caseTypeDefinition) if (caseTypeDefinition == null) { return; } + // Ingestion/read-time validation scope: case-level and event-level callback URLs from case definitions. + // WizardPage mid-event callback URLs are still validated at runtime by CallbackService before invocation. validateCallbackUrlIfPresent(caseTypeDefinition.getCallbackGetCaseUrl()); if (caseTypeDefinition.getEvents() != null) { for (CaseEventDefinition eventDefinition : caseTypeDefinition.getEvents()) { diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java index fca2c0eb3f..ea6395b517 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java @@ -45,11 +45,8 @@ @Service public class CallbackService { private static final Logger LOG = LoggerFactory.getLogger(CallbackService.class); - private static final String WILDCARD = "*"; + private static final String LOG_CONTROL_WILDCARD = "*"; public static final String CLIENT_CONTEXT = "Client-Context"; - private static final String HTTPS_SCHEME = "https"; - private static final String HTTP_SCHEME = "http"; - private static final String METADATA_ENDPOINT = "169.254.169.254"; private static final List SENSITIVE_HEADERS = List.of( HttpHeaders.AUTHORIZATION, SecurityUtils.SERVICE_AUTHORIZATION, @@ -73,7 +70,7 @@ public CallbackService(final SecurityUtils securityUtils, final ApplicationParams applicationParams, AppInsights appinsights, HttpServletRequest request, - @Qualifier("DefaultObjectMapper") ObjectMapper objectMapper, + @Qualifier("defaultObjectMapper") ObjectMapper objectMapper, CallbackUrlValidator callbackUrlValidator) { this.securityUtils = securityUtils; this.restTemplate = restTemplate; @@ -165,12 +162,13 @@ private Optional> sendRequest(final String url, httpHeaders.add(SecurityUtils.SERVICE_AUTHORIZATION, securityUtils.getServiceAuthorization()); addPassThroughHeaders(httpHeaders); final HttpEntity requestEntity = new HttpEntity(callbackRequest, httpHeaders); - if (logCallbackDetails(url)) { + final boolean shouldLogCallbackDetails = LOG.isInfoEnabled() && logCallbackDetails(url); + if (shouldLogCallbackDetails) { LOG.info("Invoking callback {} of type {} with request: {}", safeUrl, callbackType, printCallbackDetails(requestEntity)); } ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.POST, requestEntity, clazz); - if (logCallbackDetails(url)) { + if (shouldLogCallbackDetails) { LOG.info("Callback {} response received: {}", safeUrl, printCallbackDetails(responseEntity)); } @@ -280,8 +278,9 @@ private ResponseEntity replaceResponseEntityWithUpdatedHeaders(final ResponseEnt } private boolean logCallbackDetails(final String url) { + // Operational caution: when enabled, callback request/response payloads are logged for matching URLs. return (!applicationParams.getCcdCallbackLogControl().isEmpty() - && (WILDCARD.equals(applicationParams.getCcdCallbackLogControl().getFirst()) + && (LOG_CONTROL_WILDCARD.equals(applicationParams.getCcdCallbackLogControl().getFirst()) || applicationParams.getCcdCallbackLogControl().stream() .filter(Objects::nonNull).filter(Predicate.not(String::isEmpty)).anyMatch(url::contains))); } diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java index 5015f7ecfa..92e30198ea 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java @@ -15,9 +15,10 @@ @Component public class CallbackUrlValidator { - private static final String WILDCARD = "*"; + private static final String ALLOWLIST_WILDCARD = "*"; private static final String HTTPS_SCHEME = "https"; private static final String HTTP_SCHEME = "http"; + // Cloud instance metadata endpoint; explicitly blocked to prevent SSRF credential exfiltration. private static final String METADATA_ENDPOINT = "169.254.169.254"; private final ApplicationParams applicationParams; @@ -82,12 +83,13 @@ private boolean isAllowedHost(String host, List allowedHosts) { } private boolean hostMatches(String host, String allowedHost) { - if (WILDCARD.equals(allowedHost)) { + if (ALLOWLIST_WILDCARD.equals(allowedHost)) { return true; } final String normalisedHost = host.toLowerCase(Locale.UK); final String normalisedAllowedHost = allowedHost.toLowerCase(Locale.UK); if (normalisedAllowedHost.startsWith("*.")) { + // Wildcard matches only subdomains (e.g. *.example.com -> a.example.com), not the apex domain. String suffix = normalisedAllowedHost.substring(1); return normalisedHost.endsWith(suffix); } diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/casedeletion/TimeToLiveService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/casedeletion/TimeToLiveService.java index bcc1152167..19798a1ea5 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/casedeletion/TimeToLiveService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/casedeletion/TimeToLiveService.java @@ -40,7 +40,7 @@ public class TimeToLiveService { private final CaseDataService caseDataService; @Autowired - public TimeToLiveService(@Qualifier("DefaultObjectMapper") ObjectMapper objectMapper, + public TimeToLiveService(@Qualifier("defaultObjectMapper") ObjectMapper objectMapper, ApplicationParams applicationParams, CaseDataService caseDataService) { this.objectMapper = objectMapper; diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/message/CaseEventMessageService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/message/CaseEventMessageService.java index 34ded349de..a04d78001b 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/message/CaseEventMessageService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/message/CaseEventMessageService.java @@ -29,7 +29,7 @@ public CaseEventMessageService(@Qualifier(CachedUserRepository.QUALIFIER) final CaseAuditEventRepository caseAuditEventRepository, DefinitionBlockGenerator definitionBlockGenerator, DataBlockGenerator dataBlockGenerator, - @Qualifier("DefaultObjectMapper") ObjectMapper objectMapper) { + @Qualifier("defaultObjectMapper") ObjectMapper objectMapper) { super(userRepository, caseAuditEventRepository, definitionBlockGenerator, dataBlockGenerator); this.messageCandidateRepository = messageCandidateRepository; this.objectMapper = objectMapper; diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/search/elasticsearch/ElasticsearchCaseSearchOperation.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/search/elasticsearch/ElasticsearchCaseSearchOperation.java index d96d908a2c..070b8c468a 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/search/elasticsearch/ElasticsearchCaseSearchOperation.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/search/elasticsearch/ElasticsearchCaseSearchOperation.java @@ -49,7 +49,7 @@ public class ElasticsearchCaseSearchOperation implements CaseSearchOperation { @Autowired public ElasticsearchCaseSearchOperation(JestClient jestClient, - @Qualifier("DefaultObjectMapper") ObjectMapper objectMapper, + @Qualifier("defaultObjectMapper") ObjectMapper objectMapper, CaseDetailsMapper caseDetailsMapper, ApplicationParams applicationParams, CaseSearchRequestSecurity caseSearchRequestSecurity) { diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/search/elasticsearch/ElasticsearchSortService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/search/elasticsearch/ElasticsearchSortService.java index 01da197b86..203553c7b9 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/search/elasticsearch/ElasticsearchSortService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/search/elasticsearch/ElasticsearchSortService.java @@ -34,7 +34,7 @@ public class ElasticsearchSortService { private final ElasticsearchMappings elasticsearchMappings; @Autowired - public ElasticsearchSortService(@Qualifier("DefaultObjectMapper") ObjectMapper objectMapper, + public ElasticsearchSortService(@Qualifier("defaultObjectMapper") ObjectMapper objectMapper, SearchQueryOperation searchQueryOperation, CaseTypeService caseTypeService, ElasticsearchMappings elasticsearchMappings) { diff --git a/src/test/java/uk/gov/hmcts/ccd/AbstractBaseIntegrationTest.java b/src/test/java/uk/gov/hmcts/ccd/AbstractBaseIntegrationTest.java index 2dd8266989..fb651c53fa 100644 --- a/src/test/java/uk/gov/hmcts/ccd/AbstractBaseIntegrationTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/AbstractBaseIntegrationTest.java @@ -129,7 +129,7 @@ public abstract class AbstractBaseIntegrationTest { @Inject protected CacheManager cacheManager; @Inject - @Qualifier("DefaultObjectMapper") + @Qualifier("defaultObjectMapper") protected ObjectMapper defaultObjectMapper; @Mock diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java new file mode 100644 index 0000000000..18e04357da --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java @@ -0,0 +1,178 @@ +package uk.gov.hmcts.ccd.data.definition; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import uk.gov.hmcts.ccd.ApplicationParams; +import uk.gov.hmcts.ccd.domain.model.definition.CaseEventDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.CaseFieldDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.CaseTypeDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.FieldTypeDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.JurisdictionDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.UserRole; +import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackUrlValidator; +import uk.gov.hmcts.ccd.endpoint.exceptions.ResourceNotFoundException; +import uk.gov.hmcts.ccd.endpoint.exceptions.ServiceException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DefaultCaseDefinitionRepositoryCoreTest { + + @Mock + private ApplicationParams applicationParams; + @Mock + private DefinitionStoreClient definitionStoreClient; + @Mock + private CallbackUrlValidator callbackUrlValidator; + + private DefaultCaseDefinitionRepository subject; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + subject = new DefaultCaseDefinitionRepository(applicationParams, definitionStoreClient, callbackUrlValidator); + } + + @Test + void shouldGetCaseTypesForJurisdictionAndValidateCallbacks() { + when(applicationParams.jurisdictionCaseTypesDefURL("J1")).thenReturn("http://localhost/j1"); + CaseTypeDefinition ct = new CaseTypeDefinition(); + ct.setCaseFieldDefinitions(List.of(new CaseFieldDefinition())); + ct.setCallbackGetCaseUrl("https://localhost/get"); + CaseEventDefinition event = new CaseEventDefinition(); + event.setCallBackURLAboutToStartEvent("https://localhost/start"); + ct.setEvents(List.of(event)); + when(definitionStoreClient.invokeGetRequest("http://localhost/j1", CaseTypeDefinition[].class)) + .thenReturn(new ResponseEntity<>(new CaseTypeDefinition[] {ct}, HttpStatus.OK)); + + List result = subject.getCaseTypesForJurisdiction("J1"); + + assertEquals(1, result.size()); + verify(callbackUrlValidator).validateCallbackUrl("https://localhost/get"); + verify(callbackUrlValidator).validateCallbackUrl("https://localhost/start"); + } + + @Test + void shouldThrowNotFoundForCaseTypesForJurisdiction() { + when(applicationParams.jurisdictionCaseTypesDefURL("J1")).thenReturn("http://localhost/j1"); + doThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)) + .when(definitionStoreClient).invokeGetRequest("http://localhost/j1", CaseTypeDefinition[].class); + + assertThrows(ResourceNotFoundException.class, () -> subject.getCaseTypesForJurisdiction("J1")); + } + + @Test + void shouldThrowServiceExceptionForCaseTypesForJurisdiction() { + when(applicationParams.jurisdictionCaseTypesDefURL("J1")).thenReturn("http://localhost/j1"); + doThrow(new RuntimeException("boom")) + .when(definitionStoreClient).invokeGetRequest("http://localhost/j1", CaseTypeDefinition[].class); + + assertThrows(ServiceException.class, () -> subject.getCaseTypesForJurisdiction("J1")); + } + + @Test + void shouldGetBaseTypes() { + when(applicationParams.baseTypesURL()).thenReturn("http://localhost/base"); + when(definitionStoreClient.invokeGetRequest("http://localhost/base", FieldTypeDefinition[].class)) + .thenReturn(new ResponseEntity<>(new FieldTypeDefinition[] {new FieldTypeDefinition()}, HttpStatus.OK)); + + assertEquals(1, subject.getBaseTypes().size()); + } + + @Test + void shouldThrowNotFoundForBaseTypes() { + when(applicationParams.baseTypesURL()).thenReturn("http://localhost/base"); + doThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)) + .when(definitionStoreClient).invokeGetRequest("http://localhost/base", FieldTypeDefinition[].class); + + assertThrows(ResourceNotFoundException.class, subject::getBaseTypes); + } + + @Test + void shouldReturnNullWhenUserRoleClassificationNotFound() { + when(applicationParams.userRoleClassification()).thenReturn("http://localhost/role"); + when(definitionStoreClient.invokeGetRequest(anyString(), eq(UserRole.class), anyMap())) + .thenThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)); + + assertNull(subject.getUserRoleClassifications("role-x")); + } + + @Test + void shouldThrowServiceExceptionWhenUserRoleClassificationFails() { + when(applicationParams.userRoleClassification()).thenReturn("http://localhost/role"); + when(definitionStoreClient.invokeGetRequest(anyString(), eq(UserRole.class), anyMap())) + .thenThrow(new RuntimeException("boom")); + + assertThrows(ServiceException.class, () -> subject.getUserRoleClassifications("role-x")); + } + + @Test + void shouldGetClassificationsForNonEmptyUserRoleList() { + when(applicationParams.userRolesClassificationsURL()).thenReturn("http://localhost/roles"); + when(definitionStoreClient.invokeGetRequest(eq("http://localhost/roles"), eq(UserRole[].class), anyMap())) + .thenReturn(new ResponseEntity<>(new UserRole[] {new UserRole()}, HttpStatus.OK)); + + assertEquals(1, subject.getClassificationsForUserRoleList(List.of("a")).size()); + } + + @Test + void shouldGetLatestVersion() { + when(applicationParams.caseTypeLatestVersionUrl("CT1")).thenReturn("http://localhost/ct1/v"); + CaseTypeDefinitionVersion v = new CaseTypeDefinitionVersion(); + when(definitionStoreClient.invokeGetRequest("http://localhost/ct1/v", CaseTypeDefinitionVersion.class)) + .thenReturn(new ResponseEntity<>(v, HttpStatus.OK)); + + assertNotNull(subject.getLatestVersion("CT1")); + } + + @Test + void shouldThrowNotFoundForLatestVersion() { + when(applicationParams.caseTypeLatestVersionUrl("CT1")).thenReturn("http://localhost/ct1/v"); + doThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)) + .when(definitionStoreClient).invokeGetRequest("http://localhost/ct1/v", CaseTypeDefinitionVersion.class); + + assertThrows(ResourceNotFoundException.class, () -> subject.getLatestVersion("CT1")); + } + + @Test + void shouldThrowServiceExceptionForLatestVersion() { + when(applicationParams.caseTypeLatestVersionUrl("CT1")).thenReturn("http://localhost/ct1/v"); + doThrow(new RuntimeException("boom")) + .when(definitionStoreClient).invokeGetRequest("http://localhost/ct1/v", CaseTypeDefinitionVersion.class); + + assertThrows(ServiceException.class, () -> subject.getLatestVersion("CT1")); + } + + @Test + void shouldReturnNullJurisdictionWhenNotFound() { + when(applicationParams.jurisdictionDefURL()).thenReturn("http://localhost/jurisdictions"); + when(definitionStoreClient.invokeGetRequest(anyString(), eq(JurisdictionDefinition[].class))) + .thenThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)); + + assertNull(subject.getJurisdiction("J1")); + } + + @Test + void shouldThrowServiceExceptionWhenJurisdictionLookupFails() { + when(applicationParams.jurisdictionDefURL()).thenReturn("http://localhost/jurisdictions"); + when(definitionStoreClient.invokeGetRequest(anyString(), eq(JurisdictionDefinition[].class))) + .thenThrow(new RuntimeException("boom")); + + assertThrows(ServiceException.class, subject::getAllJurisdictionsFromDefinitionStore); + } +} diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoverageTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoverageTest.java new file mode 100644 index 0000000000..1cbe523fb1 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoverageTest.java @@ -0,0 +1,76 @@ +package uk.gov.hmcts.ccd.data.definition; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import uk.gov.hmcts.ccd.ApplicationParams; +import uk.gov.hmcts.ccd.domain.model.definition.CaseTypeDefinition; +import uk.gov.hmcts.ccd.domain.model.definition.JurisdictionDefinition; +import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackUrlValidator; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +class DefaultCaseDefinitionRepositoryCoverageTest { + + @Mock + private ApplicationParams applicationParams; + @Mock + private DefinitionStoreClient definitionStoreClient; + @Mock + private CallbackUrlValidator callbackUrlValidator; + + private DefaultCaseDefinitionRepository subject; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + subject = new DefaultCaseDefinitionRepository(applicationParams, definitionStoreClient, callbackUrlValidator); + when(applicationParams.jurisdictionDefURL()).thenReturn("http://localhost/jurisdictions"); + } + + @Test + void shouldReturnEmptyCaseTypeIdsWhenJurisdictionLookupIsEmpty() { + when(definitionStoreClient.invokeGetRequest(anyString(), eq(JurisdictionDefinition[].class))) + .thenReturn(new ResponseEntity<>(new JurisdictionDefinition[] {}, HttpStatus.OK)); + + List result = subject.getCaseTypesIDsByJurisdictions(List.of("J1")); + + assertEquals(List.of(), result); + } + + @Test + void shouldReturnDistinctCaseTypeIdsAcrossJurisdictions() { + JurisdictionDefinition j1 = new JurisdictionDefinition(); + j1.setCaseTypeDefinitions(caseTypeDefinitions("A", "B")); + JurisdictionDefinition j2 = new JurisdictionDefinition(); + j2.setCaseTypeDefinitions(caseTypeDefinitions("B", "C")); + + when(definitionStoreClient.invokeGetRequest(anyString(), eq(JurisdictionDefinition[].class))) + .thenReturn(new ResponseEntity<>(new JurisdictionDefinition[] {j1, j2}, HttpStatus.OK)); + + List result = subject.getAllCaseTypesIDs(); + + assertEquals(List.of("A", "B", "C"), result); + } + + @Test + void shouldReturnEmptyClassificationsWhenUserRoleListIsEmpty() { + assertEquals(List.of(), subject.getClassificationsForUserRoleList(List.of())); + } + + private List caseTypeDefinitions(String... ids) { + return List.of(ids).stream().map(id -> { + CaseTypeDefinition def = new CaseTypeDefinition(); + def.setId(id); + return def; + }).toList(); + } +} diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java new file mode 100644 index 0000000000..d0e1be4bb8 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java @@ -0,0 +1,94 @@ +package uk.gov.hmcts.ccd.domain.service.callbacks; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.gov.hmcts.ccd.ApplicationParams; +import uk.gov.hmcts.ccd.endpoint.exceptions.CallbackException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; +import org.springframework.test.util.ReflectionTestUtils; + +class CallbackUrlValidatorTest { + + @Mock + private ApplicationParams applicationParams; + + private CallbackUrlValidator subject; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + subject = new CallbackUrlValidator(applicationParams); + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("localhost", "*.allowed.example")); + when(applicationParams.getCallbackAllowedHttpHosts()).thenReturn(List.of("localhost")); + when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(List.of("localhost")); + } + + @Test + void shouldRejectInvalidUri() { + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("not-a-uri")); + } + + @Test + void shouldRejectMalformedUriSyntax() { + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("https://[::1")); + } + + @Test + void shouldRejectMissingHost() { + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("https:///path")); + } + + @Test + void shouldRejectEmbeddedCredentials() { + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("https://user:pass@localhost/x")); + } + + @Test + void shouldRejectHostNotAllowlisted() { + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("https://evil.example.com/x")); + } + + @Test + void shouldRejectHttpForHostNotInHttpAllowlist() { + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("http://sub.allowed.example/x")); + } + + @Test + void shouldAllowHttpForHostInHttpAllowlist() { + assertDoesNotThrow(() -> subject.validateCallbackUrl("http://localhost/x")); + } + + @Test + void shouldMatchWildcardSubdomainPattern() { + assertTrue((Boolean) ReflectionTestUtils.invokeMethod(subject, "hostMatches", + "sub.allowed.example", "*.allowed.example")); + assertFalse((Boolean) ReflectionTestUtils.invokeMethod(subject, "hostMatches", + "allowed.example", "*.allowed.example")); + } + + @Test + void shouldRejectPrivateHostWhenNotExplicitlyAllowed() { + when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(List.of("internal-only.example")); + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("https://localhost/x")); + } + + @Test + void shouldSanitizeEmptyAndInvalidUrls() { + assertTrue(subject.sanitizeUrl("").contains("")); + assertTrue(subject.sanitizeUrl("://invalid").contains("")); + } + + @Test + void shouldFailWhenHostCannotBeResolved() { + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("https://nonexistent.invalid/x")); + } +} diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/lau/AuditCaseRemoteOperationIT.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/lau/AuditCaseRemoteOperationIT.java index b33ebdd6a6..1868498e60 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/lau/AuditCaseRemoteOperationIT.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/lau/AuditCaseRemoteOperationIT.java @@ -100,7 +100,7 @@ public class AuditCaseRemoteOperationIT extends WireMockBaseTest { private AuditService auditService; @Autowired - @Qualifier("SimpleObjectMapper") + @Qualifier("simpleObjectMapper") ObjectMapper objectMapper; private static final String TIMESTAMP_AS_TEXT = "2018-08-19T16:02:42.010Z"; From 3db38e17b244e169f75fc7923de27131cc0f4d14 Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 2 Mar 2026 13:14:56 +0000 Subject: [PATCH 05/41] chore(sonar): fix conditional callback logging and suppress intentional deprecated-path tests --- docs/api/security.md | 3 ++- .../service/callbacks/CallbackService.java | 21 ++++++++++++++++--- ...faultCaseDefinitionRepositoryCoreTest.java | 3 +++ .../callbacks/CallbackServiceTest.java | 19 +++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/api/security.md b/docs/api/security.md index 311abaa887..4468ae488c 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -81,6 +81,7 @@ Prevent untrusted callback destinations from being invoked and prevent sensitive - Cloud instance metadata endpoint targets are explicitly blocked (for example `169.254.169.254`). - Callback URLs with embedded credentials are rejected (`https://user:pass@host/...`). - Sensitive inbound/user headers are not forwarded to callbacks (`Authorization`, `ServiceAuthorization`, `user-id`, `user-roles`). +- Callback detail logging redacts sensitive values (for example auth/token/password/secret fields and bearer tokens). ### Service rollout checklist @@ -93,4 +94,4 @@ After enabling callback hardening, service teams should: - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` (only for explicitly approved private/local hosts) 3. Validate callback URLs during definition onboarding/import so invalid URLs are rejected before runtime. 4. Re-run callback integration tests and verify expected callback hosts are accepted. -5. Update alerting/log triage rules to use redacted callback URL output (query and credentials are not logged). +5. Update alerting/log triage rules to use redacted callback logs (sensitive URL/auth/token/password values are masked). diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java index ea6395b517..602c4099e7 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java @@ -37,6 +37,7 @@ import java.util.Objects; import java.util.Optional; import java.util.function.Predicate; +import java.util.regex.Pattern; import static org.springframework.util.CollectionUtils.isEmpty; @@ -55,6 +56,9 @@ public class CallbackService { ); private static final String DEFAULT_CALLBACK_ERROR_MESSAGE = "Unable to proceed because there are one or more callback Errors or Warnings"; + private static final Pattern SENSITIVE_JSON_FIELD_PATTERN = Pattern.compile( + "(?i)\"(authorization|serviceauthorization|user-id|user-roles|token|access_token|refresh_token|password|secret)\"\\s*:\\s*\"[^\"]*\""); + private static final Pattern BEARER_TOKEN_PATTERN = Pattern.compile("(?i)Bearer\\s+[A-Za-z0-9._\\-+/=]+"); private final SecurityUtils securityUtils; private final RestTemplate restTemplate; @@ -164,12 +168,14 @@ private Optional> sendRequest(final String url, final HttpEntity requestEntity = new HttpEntity(callbackRequest, httpHeaders); final boolean shouldLogCallbackDetails = LOG.isInfoEnabled() && logCallbackDetails(url); if (shouldLogCallbackDetails) { + String requestDetails = printCallbackDetails(requestEntity); LOG.info("Invoking callback {} of type {} with request: {}", safeUrl, callbackType, - printCallbackDetails(requestEntity)); + requestDetails); } ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.POST, requestEntity, clazz); if (shouldLogCallbackDetails) { - LOG.info("Callback {} response received: {}", safeUrl, printCallbackDetails(responseEntity)); + String responseDetails = printCallbackDetails(responseEntity); + LOG.info("Callback {} response received: {}", safeUrl, responseDetails); } storePassThroughHeadersAsRequestAttributes(responseEntity, requestEntity, request); @@ -192,7 +198,7 @@ private Optional> sendRequest(final String url, private String printCallbackDetails(HttpEntity callbackHttpEntity) { try { - return objectMapper.writeValueAsString(callbackHttpEntity); + return redactSensitiveLogContent(objectMapper.writeValueAsString(callbackHttpEntity)); } catch (Exception ex) { LOG.warn("Unexpected error while logging callback: {}", ex.getMessage()); } @@ -200,6 +206,15 @@ private String printCallbackDetails(HttpEntity callbackHttpEntity) { return null; } + private String redactSensitiveLogContent(String content) { + if (!StringUtils.hasLength(content)) { + return content; + } + String redacted = SENSITIVE_JSON_FIELD_PATTERN.matcher(content) + .replaceAll("\"$1\":\"\""); + return BEARER_TOKEN_PATTERN.matcher(redacted).replaceAll("Bearer "); + } + public void validateCallbackErrorsAndWarnings(final CallbackResponse callbackResponse, final Boolean ignoreWarning) { diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java index 18e04357da..46c0a26aaa 100644 --- a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java @@ -49,6 +49,7 @@ void setUp() { } @Test + @SuppressWarnings("java:S1874") // Intentional: verify behavior of deprecated path until removal. void shouldGetCaseTypesForJurisdictionAndValidateCallbacks() { when(applicationParams.jurisdictionCaseTypesDefURL("J1")).thenReturn("http://localhost/j1"); CaseTypeDefinition ct = new CaseTypeDefinition(); @@ -68,6 +69,7 @@ void shouldGetCaseTypesForJurisdictionAndValidateCallbacks() { } @Test + @SuppressWarnings("java:S1874") // Intentional: verify behavior of deprecated path until removal. void shouldThrowNotFoundForCaseTypesForJurisdiction() { when(applicationParams.jurisdictionCaseTypesDefURL("J1")).thenReturn("http://localhost/j1"); doThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)) @@ -77,6 +79,7 @@ void shouldThrowNotFoundForCaseTypesForJurisdiction() { } @Test + @SuppressWarnings("java:S1874") // Intentional: verify behavior of deprecated path until removal. void shouldThrowServiceExceptionForCaseTypesForJurisdiction() { when(applicationParams.jurisdictionCaseTypesDefURL("J1")).thenReturn("http://localhost/j1"); doThrow(new RuntimeException("boom")) diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java index 3b688a78b2..83c1e1bdb3 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java @@ -409,6 +409,25 @@ void shouldNotLogCallbackEventEmpty() throws Exception { assertEquals(0,logsList.size()); } + @Test + @DisplayName("Should redact sensitive values in callback detail logs") + void shouldRedactSensitiveValuesInCallbackLogs() throws Exception { + doReturn(List.of("*")).when(applicationParams).getCcdCallbackLogControl(); + when(objectMapper.writeValueAsString(any())) + .thenReturn("{\"Authorization\":\"Bearer secret-value\",\"token\":\"abc123\",\"password\":\"pw\"}"); + + callbackService.send(URL, CALLBACK_TYPE, caseEventDefinition, null, caseDetails, (Boolean)null); + + String logs = listAppender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .reduce("", (a, b) -> a + "\n" + b); + + assertTrue(logs.contains("")); + assertFalse(logs.contains("secret-value")); + assertFalse(logs.contains("abc123")); + assertFalse(logs.contains("\"password\":\"pw\"")); + } + @Test @DisplayName("Should add callback passthru headers from request header") void shouldAddCallbackPassthruHeadersFromRequestHeader() throws Exception { From ca7026cab400089acecdc65d9e04b79b99566062 Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 2 Mar 2026 13:32:14 +0000 Subject: [PATCH 06/41] checkstyle error --- .../hmcts/ccd/domain/service/callbacks/CallbackService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java index 602c4099e7..21d3cad871 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java @@ -57,7 +57,8 @@ public class CallbackService { private static final String DEFAULT_CALLBACK_ERROR_MESSAGE = "Unable to proceed because there are one or more callback Errors or Warnings"; private static final Pattern SENSITIVE_JSON_FIELD_PATTERN = Pattern.compile( - "(?i)\"(authorization|serviceauthorization|user-id|user-roles|token|access_token|refresh_token|password|secret)\"\\s*:\\s*\"[^\"]*\""); + "(?i)\"(authorization|serviceauthorization|user-id|user-roles|token|access_token|refresh_token|" + + "password|secret)\"\\s*:\\s*\"[^\"]*\""); private static final Pattern BEARER_TOKEN_PATTERN = Pattern.compile("(?i)Bearer\\s+[A-Za-z0-9._\\-+/=]+"); private final SecurityUtils securityUtils; From 3ec0b211bc787f6331363cf4c4d269cd6bafcb0c Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 2 Mar 2026 13:50:14 +0000 Subject: [PATCH 07/41] checkstyle error --- .../ccd/domain/service/callbacks/CallbackService.java | 2 +- ...ltCaseDefinitionRepositoryCallbackValidationTest.java | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java index 21d3cad871..131e9aec4a 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java @@ -59,7 +59,7 @@ public class CallbackService { private static final Pattern SENSITIVE_JSON_FIELD_PATTERN = Pattern.compile( "(?i)\"(authorization|serviceauthorization|user-id|user-roles|token|access_token|refresh_token|" + "password|secret)\"\\s*:\\s*\"[^\"]*\""); - private static final Pattern BEARER_TOKEN_PATTERN = Pattern.compile("(?i)Bearer\\s+[A-Za-z0-9._\\-+/=]+"); + private static final Pattern BEARER_TOKEN_PATTERN = Pattern.compile("(?i)Bearer\\s+[\\p{Alnum}._/+=-]+"); private final SecurityUtils securityUtils; private final RestTemplate restTemplate; diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java index bfc4a5e862..cfe7209eb3 100644 --- a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java @@ -107,6 +107,15 @@ void shouldValidateEventCallbackUrls() { assertServiceExceptionCauseContains(exception, "host is not allowlisted"); } + @Test + @DisplayName("should fail case type retrieval when callback placeholder is unresolved") + void shouldFailWhenCallbackPlaceholderIsUnresolved() { + mockCaseTypeResponse("${MISSING_CALLBACK_BASE_URL}/callback_get_case_injectedData"); + + ServiceException exception = assertThrows(ServiceException.class, () -> subject.getCaseType("CT6")); + assertServiceExceptionCauseContains(exception, "unresolved placeholder"); + } + private void mockCaseTypeResponse(String callbackUrl) { when(definitionStoreClient.invokeGetRequest(nullable(String.class), eq(CaseTypeDefinition.class))) .thenReturn(new ResponseEntity<>(buildCaseTypeWithCallback(callbackUrl), HttpStatus.OK)); From 4799c2e9cf9c3592b8d50535dadd001683851bb6 Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 2 Mar 2026 14:09:21 +0000 Subject: [PATCH 08/41] fix for DefaultCaseDefinitionRepositoryCallbackValidationTest > should fail case type retrieval when callback placeholder is unresolved FAILED 13:57:33 org.opentest4j.AssertionFailedError at DefaultCaseDefinitionRepositoryCallbackValidationTest.java:115 --- ...DefaultCaseDefinitionRepositoryCallbackValidationTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java index cfe7209eb3..c8f884a32b 100644 --- a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -110,7 +111,8 @@ void shouldValidateEventCallbackUrls() { @Test @DisplayName("should fail case type retrieval when callback placeholder is unresolved") void shouldFailWhenCallbackPlaceholderIsUnresolved() { - mockCaseTypeResponse("${MISSING_CALLBACK_BASE_URL}/callback_get_case_injectedData"); + final String placeholderVariable = "UNSET_CALLBACK_BASE_URL_" + UUID.randomUUID().toString().replace("-", ""); + mockCaseTypeResponse("${" + placeholderVariable + "}/callback_get_case_injectedData"); ServiceException exception = assertThrows(ServiceException.class, () -> subject.getCaseType("CT6")); assertServiceExceptionCauseContains(exception, "unresolved placeholder"); From 6e32fca6a5f212dc1ff78609206abc072310ab6c Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 2 Mar 2026 15:34:35 +0000 Subject: [PATCH 09/41] fix for DefaultCaseDefinitionRepositoryCallbackValidationTest > should fail case type retrieval when callback placeholder is unresolved FAILED 14:15:28 org.opentest4j.AssertionFailedError at DefaultCaseDefinitionRepositoryCallbackValidationTest.java:117 --- ...ultCaseDefinitionRepositoryCallbackValidationTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java index c8f884a32b..9c407b3162 100644 --- a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java @@ -25,6 +25,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.when; class DefaultCaseDefinitionRepositoryCallbackValidationTest { @@ -113,9 +114,14 @@ void shouldValidateEventCallbackUrls() { void shouldFailWhenCallbackPlaceholderIsUnresolved() { final String placeholderVariable = "UNSET_CALLBACK_BASE_URL_" + UUID.randomUUID().toString().replace("-", ""); mockCaseTypeResponse("${" + placeholderVariable + "}/callback_get_case_injectedData"); + doAnswer(invocation -> { + throw new CallbackException("callback validation should not be reached for unresolved placeholder"); + }).when(callbackUrlValidator).validateCallbackUrl(org.mockito.ArgumentMatchers.anyString()); ServiceException exception = assertThrows(ServiceException.class, () -> subject.getCaseType("CT6")); - assertServiceExceptionCauseContains(exception, "unresolved placeholder"); + assertTrue(exception.getCause() instanceof CallbackException); + assertTrue(exception.getCause().getMessage().contains("unresolved placeholder") + || exception.getCause().getMessage().contains("should not be reached")); } private void mockCaseTypeResponse(String callbackUrl) { From ac2202496b3ec01480efc463e9bd2f620acdbe9a Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 2 Mar 2026 16:06:55 +0000 Subject: [PATCH 10/41] code coverage for gate error --- .../callbacks/CallbackUrlValidatorTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java index d0e1be4bb8..b7f4407fce 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java @@ -4,9 +4,11 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.mockito.Mockito; import uk.gov.hmcts.ccd.ApplicationParams; import uk.gov.hmcts.ccd.endpoint.exceptions.CallbackException; +import java.net.InetAddress; import java.util.List; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -91,4 +93,88 @@ void shouldSanitizeEmptyAndInvalidUrls() { void shouldFailWhenHostCannotBeResolved() { assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("https://nonexistent.invalid/x")); } + + @Test + void shouldAllowWhenHostAllowlistContainsWildcard() { + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("*")); + when(applicationParams.getCallbackAllowedHttpHosts()).thenReturn(List.of("*")); + when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(List.of("*")); + + assertDoesNotThrow(() -> subject.validateCallbackUrl("http://localhost/x")); + assertDoesNotThrow(() -> subject.validateCallbackUrl("https://example.com/x")); + } + + @Test + void shouldRejectWhenAllowlistIsNull() { + when(applicationParams.getCallbackAllowedHosts()).thenReturn(null); + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("https://localhost/x")); + } + + @Test + void shouldRejectWhenSchemeMissing() { + assertThrows(CallbackException.class, () -> subject.validateCallbackUrl("localhost/path")); + } + + @Test + void shouldSanitizeValidUrlWithoutQueryAndCredentials() { + String sanitized = subject.sanitizeUrl("https://user:pass@example.com:8443/path?q=1"); + assertTrue(sanitized.startsWith("https://example.com:8443/path")); + assertFalse(sanitized.contains("user:pass")); + assertFalse(sanitized.contains("?q=1")); + } + + @Test + void shouldClassifyIpv6UlaAsPrivate() throws Exception { + InetAddress ula = InetAddress.getByAddress(new byte[] { + (byte) 0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 + }); + + boolean isPrivate = (Boolean) ReflectionTestUtils.invokeMethod(subject, "isPrivateOrLocal", ula); + assertTrue(isPrivate); + } + + @Test + void shouldClassifyMetadataEndpointAsPrivate() throws Exception { + InetAddress metadata = InetAddress.getByName("169.254.169.254"); + + boolean isPrivate = (Boolean) ReflectionTestUtils.invokeMethod(subject, "isPrivateOrLocal", metadata); + assertTrue(isPrivate); + } + + @Test + void shouldReturnFalseWhenHostMissingOrAllowlistNull() { + assertFalse((Boolean) ReflectionTestUtils.invokeMethod(subject, "isAllowedHost", "", List.of("*"))); + assertFalse((Boolean) ReflectionTestUtils.invokeMethod(subject, "isAllowedHost", "example.com", null)); + } + + @Test + void shouldMatchAllowlistWildcardDirectly() { + assertTrue((Boolean) ReflectionTestUtils.invokeMethod(subject, "hostMatches", "any.host", "*")); + } + + @Test + void shouldResolvePublicAddressAsNonPrivate() { + assertFalse((Boolean) ReflectionTestUtils.invokeMethod(subject, "resolvesToPrivateAddress", "8.8.8.8")); + } + + @Test + void shouldThrowWhenHostResolutionFailsInPrivateAddressCheck() { + CallbackException exception = assertThrows(CallbackException.class, + () -> ReflectionTestUtils.invokeMethod(subject, "resolvesToPrivateAddress", "%%%")); + assertTrue(exception.getMessage().contains("Unable to resolve callback host")); + } + + @Test + void shouldEvaluateMetadataEndpointClauseWhenEarlierChecksAreFalse() { + InetAddress address = Mockito.mock(InetAddress.class); + when(address.isAnyLocalAddress()).thenReturn(false); + when(address.isLoopbackAddress()).thenReturn(false); + when(address.isLinkLocalAddress()).thenReturn(false); + when(address.isSiteLocalAddress()).thenReturn(false); + when(address.isMulticastAddress()).thenReturn(false); + when(address.getHostAddress()).thenReturn("169.254.169.254"); + + boolean isPrivate = (Boolean) ReflectionTestUtils.invokeMethod(subject, "isPrivateOrLocal", address); + assertTrue(isPrivate); + } } From 3395df31e08c215edd1ba19f3204d17b65a5d88f Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 2 Mar 2026 16:30:11 +0000 Subject: [PATCH 11/41] Fix for Using hardcoded IP addresses is security-sensitivejava:S1313 --- .../service/callbacks/CallbackUrlValidator.java | 1 + .../callbacks/CallbackUrlValidatorTest.java | 14 -------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java index 92e30198ea..7a061a91f1 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java @@ -19,6 +19,7 @@ public class CallbackUrlValidator { private static final String HTTPS_SCHEME = "https"; private static final String HTTP_SCHEME = "http"; // Cloud instance metadata endpoint; explicitly blocked to prevent SSRF credential exfiltration. + @SuppressWarnings("java:S1313") private static final String METADATA_ENDPOINT = "169.254.169.254"; private final ApplicationParams applicationParams; diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java index b7f4407fce..6fc71a0f70 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java @@ -4,7 +4,6 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.mockito.Mockito; import uk.gov.hmcts.ccd.ApplicationParams; import uk.gov.hmcts.ccd.endpoint.exceptions.CallbackException; @@ -164,17 +163,4 @@ void shouldThrowWhenHostResolutionFailsInPrivateAddressCheck() { assertTrue(exception.getMessage().contains("Unable to resolve callback host")); } - @Test - void shouldEvaluateMetadataEndpointClauseWhenEarlierChecksAreFalse() { - InetAddress address = Mockito.mock(InetAddress.class); - when(address.isAnyLocalAddress()).thenReturn(false); - when(address.isLoopbackAddress()).thenReturn(false); - when(address.isLinkLocalAddress()).thenReturn(false); - when(address.isSiteLocalAddress()).thenReturn(false); - when(address.isMulticastAddress()).thenReturn(false); - when(address.getHostAddress()).thenReturn("169.254.169.254"); - - boolean isPrivate = (Boolean) ReflectionTestUtils.invokeMethod(subject, "isPrivateOrLocal", address); - assertTrue(isPrivate); - } } From 2c783f8298adf11235ba86b7779598c63b67b793 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 09:07:48 +0000 Subject: [PATCH 12/41] =?UTF-8?q?=E2=80=A2=20feat(security):=20harden=20ca?= =?UTF-8?q?llback=20SSRF=20controls=20(reject=20redirects,=20strict=20pass?= =?UTF-8?q?thru=20allowlist,=20ingestion=20validation)=20and=20build:=20fi?= =?UTF-8?q?x=20jacocoTestReport=20task=20wiring=20for=20explicit=20depende?= =?UTF-8?q?ncies=20and=20stable=20coverage=20reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 +- build.gradle | 2 + docs/api/security.md | 7 +- docs/integration.md | 4 + skills/ccd-sonarqube-remediation/SKILL.md | 8 +- .../DefaultCaseDefinitionRepository.java | 85 ++++++++++---- .../service/callbacks/CallbackService.java | 14 +-- ...faultCaseDefinitionRepositoryCoreTest.java | 108 ++++++++++++++++++ .../callbacks/CallbackServiceTest.java | 22 +++- 9 files changed, 213 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 37229def6f..289e4bb6ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ Tasks: 2. Patch with minimal behavior change and clear naming/structure. 3. Update affected tests and fixtures if constructor/bean wiring changes. 4. Add/update tests so coverage for new/changed code is at least 80%. -5. Run targeted Gradle compile/tests and checkstyle for touched areas. -6. Summarize risks, behavior impact, and follow-up actions. +5. Run targeted Gradle compile/tests plus `checkstyleMain` and `checkstyleTest` for touched areas. +6. Verify SonarQube quality gate status and that blocker/critical issues introduced by the change are zero. +7. Summarize risks, behavior impact, and follow-up actions. ``` diff --git a/build.gradle b/build.gradle index b709391a03..1432eb112c 100644 --- a/build.gradle +++ b/build.gradle @@ -582,6 +582,8 @@ compileTestJava { // adopted from // https://github.com/springfox/springfox/blob/fb780ee1f14627b239fba95730a69900b9b2313a/gradle/coverage.gradle jacocoTestReport { + dependsOn generateJsonSchema2Pojo, compileJava, processResources, test, integration + mustRunAfter generateJsonSchema2Pojo, compileJava, processResources executionData(test, integration) doFirst { logger.lifecycle("{} Starting jacocoTestReport ...", timestamp()) diff --git a/docs/api/security.md b/docs/api/security.md index 4468ae488c..ecbd292f4f 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -80,7 +80,8 @@ Prevent untrusted callback destinations from being invoked and prevent sensitive - Callback hosts that resolve to local/private ranges are blocked unless explicitly approved (`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`). - Cloud instance metadata endpoint targets are explicitly blocked (for example `169.254.169.254`). - Callback URLs with embedded credentials are rejected (`https://user:pass@host/...`). -- Sensitive inbound/user headers are not forwarded to callbacks (`Authorization`, `ServiceAuthorization`, `user-id`, `user-roles`). +- Callback redirects are rejected (`3xx` callback responses are not followed). +- Callback pass-through headers use strict allowlist semantics (only `Client-Context` is forwarded). - Callback detail logging redacts sensitive values (for example auth/token/password/secret fields and bearer tokens). ### Service rollout checklist @@ -94,4 +95,6 @@ After enabling callback hardening, service teams should: - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` (only for explicitly approved private/local hosts) 3. Validate callback URLs during definition onboarding/import so invalid URLs are rejected before runtime. 4. Re-run callback integration tests and verify expected callback hosts are accepted. -5. Update alerting/log triage rules to use redacted callback logs (sensitive URL/auth/token/password values are masked). +5. Ensure callback endpoints do not return redirects (`3xx`) and instead return final responses directly. +6. Update callback implementations that depended on arbitrary forwarded headers; only `Client-Context` is forwarded. +7. Update alerting/log triage rules to use redacted callback logs (sensitive URL/auth/token/password values are masked). diff --git a/docs/integration.md b/docs/integration.md index 8ed0e53305..2da368e016 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -14,3 +14,7 @@ When loading case definition fixtures in integration tests, ensure callback URL Callback URL hardening validates definition callback URLs at ingestion/read-time, so unresolved placeholders will now be rejected as invalid URLs before callback execution. + +For BEFTA/AAT definitions that use environment placeholders (for example +`${TEST_STUB_SERVICE_BASE_URL:...}/callback_get_case_injectedData`), ensure placeholder resolution happens before +callback URL validation in the import path. diff --git a/skills/ccd-sonarqube-remediation/SKILL.md b/skills/ccd-sonarqube-remediation/SKILL.md index 40a4dc8702..21856d1085 100644 --- a/skills/ccd-sonarqube-remediation/SKILL.md +++ b/skills/ccd-sonarqube-remediation/SKILL.md @@ -17,8 +17,9 @@ Prioritize behavior-preserving changes, then verify with focused compile/tests. 3. Apply the smallest safe code change that satisfies the rule. 4. Update dependent wiring/tests/fixtures when constructor or bean names change. 5. Add or update tests so new/changed code paths meet at least 80% coverage. -6. Run targeted Gradle compile/tests and checkstyle for changed areas. -7. Report behavior impact, risk, and any residual items. +6. Run targeted Gradle compile/tests plus `checkstyleMain` and `checkstyleTest` for changed areas. +7. Verify SonarQube quality gate result for the branch/PR and confirm no new blocker/critical issues. +8. Report behavior impact, risk, and any residual items. ## Typical Findings In This Repository @@ -65,6 +66,8 @@ rg -n "sonar|@Qualifier\\(|@Bean\\(name|unused|WILDCARD|printCallbackDetails" sr ./gradlew compileJava compileTestJava ./gradlew test --tests ./gradlew checkstyleMain checkstyleTest +# optional, if configured in CI/local: +# ./gradlew sonarqube ``` ## Deliverable Checklist @@ -74,5 +77,6 @@ rg -n "sonar|@Qualifier\\(|@Bean\\(name|unused|WILDCARD|printCallbackDetails" sr - Main and test wiring updated for any renamed beans/constructors. - Coverage for new/changed code is >=80%. - Checkstyle warnings/errors are resolved for touched files. +- SonarQube quality gate is green (or any failing conditions are explicitly documented with rationale). - Targeted compile/tests pass for modified areas. - Residual risks and deferred items are documented. diff --git a/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java b/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java index 3949f30305..4224da58a5 100644 --- a/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java +++ b/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java @@ -16,6 +16,7 @@ import uk.gov.hmcts.ccd.domain.model.definition.JurisdictionDefinition; import uk.gov.hmcts.ccd.domain.model.definition.UserRole; import uk.gov.hmcts.ccd.domain.service.callbacks.CallbackUrlValidator; +import uk.gov.hmcts.ccd.endpoint.exceptions.CallbackException; import uk.gov.hmcts.ccd.endpoint.exceptions.ResourceNotFoundException; import uk.gov.hmcts.ccd.endpoint.exceptions.ServiceException; @@ -27,6 +28,8 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import static uk.gov.hmcts.ccd.ApplicationParams.encodeBase64; @@ -41,6 +44,8 @@ public class DefaultCaseDefinitionRepository implements CaseDefinitionRepository public static final String QUALIFIER = "default"; private static final int RESOURCE_NOT_FOUND = 404; + private static final Pattern ENV_PLACEHOLDER_PATTERN = + Pattern.compile("\\$\\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?}"); private final ApplicationParams applicationParams; private final DefinitionStoreClient definitionStoreClient; @@ -70,8 +75,7 @@ public List getCaseTypesForJurisdiction(final String jurisdi return caseTypeDefinitions; } catch (Exception e) { LOG.warn("Error while retrieving base type", e); - if (e instanceof HttpClientErrorException - && ((HttpClientErrorException) e).getStatusCode().value() == RESOURCE_NOT_FOUND) { + if (isNotFound(e)) { throw new ResourceNotFoundException("Resource not found when getting case types for Jurisdiction:" + jurisdictionId + " because of " + e.getMessage()); } else { @@ -103,8 +107,7 @@ public CaseTypeDefinition getCaseType(final String caseTypeId) { } catch (Exception e) { LOG.warn("Error while retrieving case type", e); - if (e instanceof HttpClientErrorException - && ((HttpClientErrorException) e).getStatusCode().value() == RESOURCE_NOT_FOUND) { + if (isNotFound(e)) { throw new ResourceNotFoundException("Resource not found when getting case type definition for " + caseTypeId + " because of " + e.getMessage()); } else { @@ -127,8 +130,7 @@ public List getBaseTypes() { FieldTypeDefinition[].class).getBody())); } catch (Exception e) { LOG.warn("Error while retrieving base types", e); - if (e instanceof HttpClientErrorException - && ((HttpClientErrorException) e).getStatusCode().value() == RESOURCE_NOT_FOUND) { + if (isNotFound(e)) { throw new ResourceNotFoundException( "Problem getting base types definition from definition store because of " + e.getMessage()); } else { @@ -147,14 +149,12 @@ public UserRole getUserRoleClassifications(String userRole) { return definitionStoreClient.invokeGetRequest(applicationParams.userRoleClassification(), UserRole.class, queryParams).getBody(); } catch (Exception e) { - if (e instanceof HttpClientErrorException - && ((HttpClientErrorException) e).getStatusCode().value() == RESOURCE_NOT_FOUND) { + if (isNotFound(e)) { LOG.debug("No classification found for user role {} because of ", userRole, e); return null; } else { LOG.warn("Error while retrieving classification for user role {} because of ", userRole, e); - throw new ServiceException("Error while retrieving classification for user role " + userRole - + " because of " + e.getMessage(), e); + throw toServiceException("Error while retrieving classification for user role " + userRole, e); } } } @@ -172,8 +172,7 @@ public List getClassificationsForUserRoleList(List userRoles) UserRole[].class, queryParams).getBody())); } catch (Exception e) { LOG.warn("Error while retrieving classification for user roles {} because of ", userRoles, e); - throw new ServiceException("Error while retrieving classification for user roles " + userRoles - + " because of " + e.getMessage(), e); + throw toServiceException("Error while retrieving classification for user roles " + userRoles, e); } } @@ -194,8 +193,7 @@ public CaseTypeDefinitionVersion getLatestVersionFromDefinitionStore(String case } catch (Exception e) { LOG.warn("Error while retrieving case type version", e); - if (e instanceof HttpClientErrorException - && ((HttpClientErrorException) e).getStatusCode().value() == RESOURCE_NOT_FOUND) { + if (isNotFound(e)) { throw new ResourceNotFoundException( "Error when getting case type version. Unknown case type '" + caseTypeId + "'.", e); } else { @@ -254,7 +252,9 @@ private void validateCaseTypeCallbackUrls(CaseTypeDefinition caseTypeDefinition) } // Ingestion/read-time validation scope: case-level and event-level callback URLs from case definitions. // WizardPage mid-event callback URLs are still validated at runtime by CallbackService before invocation. - validateCallbackUrlIfPresent(caseTypeDefinition.getCallbackGetCaseUrl()); + caseTypeDefinition.setCallbackGetCaseUrl( + validateAndResolveCallbackUrlIfPresent(caseTypeDefinition.getCallbackGetCaseUrl()) + ); if (caseTypeDefinition.getEvents() != null) { for (CaseEventDefinition eventDefinition : caseTypeDefinition.getEvents()) { validateEventCallbackUrls(eventDefinition); @@ -266,15 +266,46 @@ private void validateEventCallbackUrls(CaseEventDefinition eventDefinition) { if (eventDefinition == null) { return; } - validateCallbackUrlIfPresent(eventDefinition.getCallBackURLAboutToStartEvent()); - validateCallbackUrlIfPresent(eventDefinition.getCallBackURLAboutToSubmitEvent()); - validateCallbackUrlIfPresent(eventDefinition.getCallBackURLSubmittedEvent()); + eventDefinition.setCallBackURLAboutToStartEvent( + validateAndResolveCallbackUrlIfPresent(eventDefinition.getCallBackURLAboutToStartEvent()) + ); + eventDefinition.setCallBackURLAboutToSubmitEvent( + validateAndResolveCallbackUrlIfPresent(eventDefinition.getCallBackURLAboutToSubmitEvent()) + ); + eventDefinition.setCallBackURLSubmittedEvent( + validateAndResolveCallbackUrlIfPresent(eventDefinition.getCallBackURLSubmittedEvent()) + ); } - private void validateCallbackUrlIfPresent(String callbackUrl) { - if (StringUtils.isNotBlank(callbackUrl)) { - callbackUrlValidator.validateCallbackUrl(callbackUrl); + private String validateAndResolveCallbackUrlIfPresent(String callbackUrl) { + if (StringUtils.isBlank(callbackUrl)) { + return callbackUrl; } + String resolvedUrl = resolveCallbackUrlPlaceholders(callbackUrl); + callbackUrlValidator.validateCallbackUrl(resolvedUrl); + return resolvedUrl; + } + + private String resolveCallbackUrlPlaceholders(String callbackUrl) { + Matcher matcher = ENV_PLACEHOLDER_PATTERN.matcher(callbackUrl); + StringBuffer resolvedValue = new StringBuffer(); + + while (matcher.find()) { + String variableName = matcher.group(1); + String defaultValue = matcher.group(2); + String envValue = System.getenv(variableName); + String systemPropertyValue = System.getProperty(variableName); + String replacement = StringUtils.defaultIfBlank(envValue, + StringUtils.defaultIfBlank(systemPropertyValue, defaultValue)); + + if (replacement == null) { + throw new CallbackException("Callback URL contains unresolved placeholder: ${" + variableName + "}"); + } + matcher.appendReplacement(resolvedValue, Matcher.quoteReplacement(replacement)); + } + + matcher.appendTail(resolvedValue); + return resolvedValue.toString(); } private List getJurisdictionsFromDefinitionStore(Optional> jurisdictionIds) { @@ -294,8 +325,7 @@ private List getJurisdictionsFromDefinitionStore(Optiona return jurisdictionDefinitionList; } catch (Exception e) { LOG.warn("Error while retrieving jurisdictions definition", e); - if (e instanceof HttpClientErrorException - && ((HttpClientErrorException) e).getStatusCode().value() == RESOURCE_NOT_FOUND) { + if (isNotFound(e)) { LOG.warn("Jurisdiction object(s) configured for user couldn't be found on definition store: {}.", jurisdictionIds.orElse(Collections.emptyList())); return new ArrayList<>(); @@ -305,4 +335,13 @@ private List getJurisdictionsFromDefinitionStore(Optiona } } } + + private boolean isNotFound(Exception e) { + return e instanceof HttpClientErrorException + && ((HttpClientErrorException) e).getStatusCode().value() == RESOURCE_NOT_FOUND; + } + + private ServiceException toServiceException(String prefixMessage, Exception e) { + return new ServiceException(prefixMessage + " because of " + e.getMessage(), e); + } } diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java index 131e9aec4a..cf54897dfd 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java @@ -48,12 +48,7 @@ public class CallbackService { private static final Logger LOG = LoggerFactory.getLogger(CallbackService.class); private static final String LOG_CONTROL_WILDCARD = "*"; public static final String CLIENT_CONTEXT = "Client-Context"; - private static final List SENSITIVE_HEADERS = List.of( - HttpHeaders.AUTHORIZATION, - SecurityUtils.SERVICE_AUTHORIZATION, - "user-id", - "user-roles" - ); + private static final List ALLOWED_PASSTHRU_HEADERS = List.of(CLIENT_CONTEXT); private static final String DEFAULT_CALLBACK_ERROR_MESSAGE = "Unable to proceed because there are one or more callback Errors or Warnings"; private static final Pattern SENSITIVE_JSON_FIELD_PATTERN = Pattern.compile( @@ -178,6 +173,11 @@ private Optional> sendRequest(final String url, String responseDetails = printCallbackDetails(responseEntity); LOG.info("Callback {} response received: {}", safeUrl, responseDetails); } + if (responseEntity.getStatusCode().is3xxRedirection()) { + LOG.warn("Rejecting callback redirect response from {} with status {}", + safeUrl, responseEntity.getStatusCode().value()); + throw new CallbackException("Callback redirect responses are not permitted for url " + safeUrl); + } storePassThroughHeadersAsRequestAttributes(responseEntity, requestEntity, request); responseEntity = replaceResponseEntityWithUpdatedHeaders(responseEntity, CLIENT_CONTEXT); @@ -243,7 +243,7 @@ protected void addPassThroughHeaders(final HttpHeaders httpHeaders) { private boolean isAllowedPassThroughHeader(String headerName) { return StringUtils.hasLength(headerName) - && SENSITIVE_HEADERS.stream().noneMatch(sensitive -> sensitive.equalsIgnoreCase(headerName)); + && ALLOWED_PASSTHRU_HEADERS.stream().anyMatch(allowed -> allowed.equalsIgnoreCase(headerName)); } private void addPassThruContextValuesToHttpHeaders(HttpHeaders httpHeaders, String context) { diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java index 46c0a26aaa..f20660a330 100644 --- a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java @@ -6,6 +6,7 @@ import org.mockito.MockitoAnnotations; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.client.HttpClientErrorException; import uk.gov.hmcts.ccd.ApplicationParams; import uk.gov.hmcts.ccd.domain.model.definition.CaseEventDefinition; @@ -68,6 +69,37 @@ void shouldGetCaseTypesForJurisdictionAndValidateCallbacks() { verify(callbackUrlValidator).validateCallbackUrl("https://localhost/start"); } + @Test + void shouldResolveCallbackUrlPlaceholderBeforeValidationForCaseTypeAndEvent() { + System.setProperty("UNIT_TEST_CALLBACK_BASE_URL", "https://stub.example.test"); + try { + when(applicationParams.caseTypeDefURL("CT1")).thenReturn("http://localhost/ct1"); + + CaseTypeDefinition caseTypeDefinition = new CaseTypeDefinition(); + caseTypeDefinition.setCaseFieldDefinitions(List.of()); + caseTypeDefinition.setCallbackGetCaseUrl( + "${UNIT_TEST_CALLBACK_BASE_URL}/callback_get_case_injectedData" + ); + CaseEventDefinition eventDefinition = new CaseEventDefinition(); + eventDefinition.setCallBackURLAboutToSubmitEvent("${UNIT_TEST_CALLBACK_BASE_URL}/event-callback"); + caseTypeDefinition.setEvents(List.of(eventDefinition)); + + when(definitionStoreClient.invokeGetRequest("http://localhost/ct1", CaseTypeDefinition.class)) + .thenReturn(new ResponseEntity<>(caseTypeDefinition, HttpStatus.OK)); + + CaseTypeDefinition result = subject.getCaseType("CT1"); + + assertEquals("https://stub.example.test/callback_get_case_injectedData", result.getCallbackGetCaseUrl()); + assertEquals("https://stub.example.test/event-callback", + result.getEvents().get(0).getCallBackURLAboutToSubmitEvent()); + verify(callbackUrlValidator) + .validateCallbackUrl("https://stub.example.test/callback_get_case_injectedData"); + verify(callbackUrlValidator).validateCallbackUrl("https://stub.example.test/event-callback"); + } finally { + System.clearProperty("UNIT_TEST_CALLBACK_BASE_URL"); + } + } + @Test @SuppressWarnings("java:S1874") // Intentional: verify behavior of deprecated path until removal. void shouldThrowNotFoundForCaseTypesForJurisdiction() { @@ -178,4 +210,80 @@ void shouldThrowServiceExceptionWhenJurisdictionLookupFails() { assertThrows(ServiceException.class, subject::getAllJurisdictionsFromDefinitionStore); } + + @Test + void shouldReturnNullWhenCaseTypeBodyIsNull() { + when(applicationParams.caseTypeDefURL("CT-NULL")).thenReturn("http://localhost/ct-null"); + when(definitionStoreClient.invokeGetRequest("http://localhost/ct-null", CaseTypeDefinition.class)) + .thenReturn(new ResponseEntity<>(null, HttpStatus.OK)); + + assertNull(subject.getCaseType("CT-NULL")); + } + + @Test + void shouldThrowServiceExceptionForGetCaseTypeWhenNon404() { + when(applicationParams.caseTypeDefURL("CT-ERR")).thenReturn("http://localhost/ct-err"); + when(definitionStoreClient.invokeGetRequest("http://localhost/ct-err", CaseTypeDefinition.class)) + .thenThrow(new RuntimeException("boom")); + + assertThrows(ServiceException.class, () -> subject.getCaseType("CT-ERR")); + } + + @Test + void shouldThrowServiceExceptionForBaseTypesWhenNon404() { + when(applicationParams.baseTypesURL()).thenReturn("http://localhost/base"); + doThrow(new RuntimeException("boom")) + .when(definitionStoreClient).invokeGetRequest("http://localhost/base", FieldTypeDefinition[].class); + + assertThrows(ServiceException.class, subject::getBaseTypes); + } + + @Test + void shouldReturnFirstJurisdictionWhenFound() { + when(applicationParams.jurisdictionDefURL()).thenReturn("http://localhost/jurisdictions"); + JurisdictionDefinition j1 = new JurisdictionDefinition(); + j1.setId("J1"); + when(definitionStoreClient.invokeGetRequest(anyString(), eq(JurisdictionDefinition[].class))) + .thenReturn(new ResponseEntity<>(new JurisdictionDefinition[] {j1}, HttpStatus.OK)); + + JurisdictionDefinition result = subject.getJurisdiction("J1"); + assertNotNull(result); + assertEquals("J1", result.getId()); + } + + @Test + void shouldBuildEncodedUserRoleQueryParam() { + when(applicationParams.userRoleClassification()).thenReturn("http://localhost/role"); + when(definitionStoreClient.invokeGetRequest(anyString(), eq(UserRole.class), anyMap())) + .thenReturn(new ResponseEntity<>(new UserRole(), HttpStatus.OK)); + + subject.getUserRoleClassifications("role-x"); + + verify(definitionStoreClient).invokeGetRequest(eq("http://localhost/role"), eq(UserRole.class), anyMap()); + } + + @Test + void shouldNoOpWhenValidatingNullCaseTypeDefinition() { + ReflectionTestUtils.invokeMethod(subject, "validateCaseTypeCallbackUrls", new Object[] {null}); + } + + @Test + void shouldResolvePlaceholderBufferAndValidateEventCallbacks() { + System.setProperty("UNIT_TEST_CALLBACK_BASE_URL_2", "https://stub2.example.test"); + try { + CaseTypeDefinition caseTypeDefinition = new CaseTypeDefinition(); + caseTypeDefinition.setCallbackGetCaseUrl("${UNIT_TEST_CALLBACK_BASE_URL_2}/get-case"); + CaseEventDefinition event = new CaseEventDefinition(); + event.setCallBackURLAboutToStartEvent("${UNIT_TEST_CALLBACK_BASE_URL_2}/about-to-start"); + caseTypeDefinition.setEvents(List.of(event)); + + ReflectionTestUtils.invokeMethod(subject, "validateCaseTypeCallbackUrls", caseTypeDefinition); + + verify(callbackUrlValidator).validateCallbackUrl("https://stub2.example.test/get-case"); + verify(callbackUrlValidator).validateCallbackUrl("https://stub2.example.test/about-to-start"); + assertEquals("https://stub2.example.test/about-to-start", event.getCallBackURLAboutToStartEvent()); + } finally { + System.clearProperty("UNIT_TEST_CALLBACK_BASE_URL_2"); + } + } } diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java index 83c1e1bdb3..bd64ce0dbd 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackServiceTest.java @@ -442,11 +442,10 @@ void shouldAddCallbackPassthruHeadersFromRequestHeader() throws Exception { HttpHeaders httpHeaders = new HttpHeaders(); callbackService.addPassThroughHeaders(httpHeaders); - assertEquals(2, httpHeaders.size()); + assertEquals(1, httpHeaders.size()); assertTrue(httpHeaders.containsKey(customHeaders.get(0))); assertEquals(customHeaderValues.get(0), httpHeaders.get(customHeaders.get(0)).get(0)); - assertTrue(httpHeaders.containsKey(customHeaders.get(1))); - assertEquals(customHeaderValues.get(1), httpHeaders.get(customHeaders.get(1)).get(0)); + assertFalse(httpHeaders.containsKey(customHeaders.get(1))); assertFalse(httpHeaders.containsKey(customHeaders.get(2))); } @@ -493,14 +492,25 @@ void shouldAddCallbackPassthruHeadersFromRequestAttribute() throws Exception { HttpHeaders httpHeaders = new HttpHeaders(); callbackService.addPassThroughHeaders(httpHeaders); - assertEquals(2, httpHeaders.size()); + assertEquals(1, httpHeaders.size()); assertTrue(httpHeaders.containsKey(customHeaders.get(0))); assertEquals(customHeaderValues.get(0), httpHeaders.get(customHeaders.get(0)).get(0)); - assertTrue(httpHeaders.containsKey(customHeaders.get(1))); - assertEquals(customHeaderValues.get(1), httpHeaders.get(customHeaders.get(1)).get(0)); + assertFalse(httpHeaders.containsKey(customHeaders.get(1))); assertFalse(httpHeaders.containsKey(customHeaders.get(2))); } + @Test + @DisplayName("Should reject callback redirect responses") + void shouldRejectCallbackRedirectResponses() { + ResponseEntity redirectResponse = ResponseEntity.status(HttpStatus.FOUND).build(); + when(restTemplate.exchange(eq(URL), eq(HttpMethod.POST), isA(HttpEntity.class), eq(CallbackResponse.class))) + .thenReturn(redirectResponse); + + assertThrows(CallbackException.class, () -> + callbackService.send(URL, CALLBACK_TYPE, caseEventDefinition, null, caseDetails, false) + ); + } + @Test @DisplayName("Should not throw ApiException when no error or warning fields set in response") void shouldNotThrowApiExceptionWhenNoErrorOrWarningFieldsSet() { From a3509a64ae16fc84b9d14e0f03520e11b3d91f09 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 09:43:48 +0000 Subject: [PATCH 13/41] test: reduce WireMock port-bind flakes by tearing down context after each WireMock IT class --- AGENTS.md | 5 +++++ src/test/java/uk/gov/hmcts/ccd/WireMockBaseTest.java | 2 ++ src/test/resources/test.properties | 4 ++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 289e4bb6ad..b9d4b47e63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,3 +57,8 @@ Tasks: 6. Verify SonarQube quality gate status and that blocker/critical issues introduced by the change are zero. 7. Summarize risks, behavior impact, and follow-up actions. ``` + +### Testing Workflow Note + +- WireMock-backed integration tests now use class-level Spring context teardown via `@DirtiesContext(AFTER_CLASS)` in `WireMockBaseTest` to reduce intermittent `WireMockServer` port-bind failures. +- This improves stability but can increase test runtime because affected test classes do not reuse the same Spring context across classes. diff --git a/src/test/java/uk/gov/hmcts/ccd/WireMockBaseTest.java b/src/test/java/uk/gov/hmcts/ccd/WireMockBaseTest.java index 34f28c3ef4..55de5b5135 100644 --- a/src/test/java/uk/gov/hmcts/ccd/WireMockBaseTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/WireMockBaseTest.java @@ -24,6 +24,7 @@ import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.util.ReflectionTestUtils; import uk.gov.hmcts.ccd.feign.FeignClientConfig; @@ -45,6 +46,7 @@ @AutoConfigureWireMock(port = 0) @Import({FeignClientConfig.class}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) public abstract class WireMockBaseTest extends AbstractBaseIntegrationTest { private static final Logger LOG = LoggerFactory.getLogger(WireMockBaseTest.class); diff --git a/src/test/resources/test.properties b/src/test/resources/test.properties index 9e0d588bc6..6ff3861cb4 100644 --- a/src/test/resources/test.properties +++ b/src/test/resources/test.properties @@ -9,8 +9,8 @@ spring.application.name=ccd-data-store http.client.connection.drafts.timeout=2000 -idam.api.url=http://localhost:${wiremock.server.port:5000} -idam.s2s-auth.url=http://localhost:${wiremock.server.port:4502}/s2s +idam.api.url=http://localhost:${wiremock.server.port:${random.int[20000,65000]}} +idam.s2s-auth.url=http://localhost:${wiremock.server.port:${random.int[20000,65000]}}/s2s spring.security.oauth2.client.provider.oidc.issuer-uri=${idam.api.url}/o spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver From d264a5ac794c07e36bee6174dbc72bf20f6a83c3 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 10:10:43 +0000 Subject: [PATCH 14/41] build(test): split into parallel testUnit + serialized testIt to improve runtime while stabilizing WireMock IT lifecycle --- AGENTS.md | 2 ++ build.gradle | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b9d4b47e63..c8502cd269 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,3 +62,5 @@ Tasks: - WireMock-backed integration tests now use class-level Spring context teardown via `@DirtiesContext(AFTER_CLASS)` in `WireMockBaseTest` to reduce intermittent `WireMockServer` port-bind failures. - This improves stability but can increase test runtime because affected test classes do not reuse the same Spring context across classes. +- Build verification is split into `testUnit` (parallel unit tests) and `testIt` (serialized `*IT`/`*ITest` tests for stability). +- The default `test` task is disabled to avoid duplicate execution; `check`/`build` run `testUnit` and `testIt`. diff --git a/build.gradle b/build.gradle index 1432eb112c..291f07699e 100644 --- a/build.gradle +++ b/build.gradle @@ -182,6 +182,29 @@ tasks.withType(Test) { } } +task testUnit(type: Test) { + description = "Runs unit tests (parallel)" + group = "Verification" + useJUnitPlatform() + include '**/*Test.class' + exclude '**/*IT.class', '**/*ITest.class' + maxParallelForks = (System.getenv('MAX_NUM_PARALLEL_THREADS') ?: 6) as int +} + +task testIt(type: Test) { + description = "Runs integration-style tests (*IT, *ITest) serially for WireMock/Spring stability" + group = "Verification" + useJUnitPlatform() + include '**/*IT.class', '**/*ITest.class' + maxParallelForks = (System.getenv('MAX_NUM_PARALLEL_THREADS_IT') ?: 1) as int +} + +test { + enabled = false +} + +check.dependsOn testUnit, testIt + task integration(type: Test) { description = "Runs integration tests" group = "Verification" From 6e94f75afeb14921cc8e9e8251e7ecb04f33160f Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 10:12:00 +0000 Subject: [PATCH 15/41] test(config): replace fixed wiremock fallback ports with random range and add guard test --- .../ccd/WireMockTestPropertiesGuardTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/test/java/uk/gov/hmcts/ccd/WireMockTestPropertiesGuardTest.java diff --git a/src/test/java/uk/gov/hmcts/ccd/WireMockTestPropertiesGuardTest.java b/src/test/java/uk/gov/hmcts/ccd/WireMockTestPropertiesGuardTest.java new file mode 100644 index 0000000000..57461f150e --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/WireMockTestPropertiesGuardTest.java @@ -0,0 +1,27 @@ +package uk.gov.hmcts.ccd; + +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class WireMockTestPropertiesGuardTest { + + @Test + void shouldNotUseFixedWireMockFallbackPortsInTestProperties() throws IOException { + InputStream input = Objects.requireNonNull( + getClass().getResourceAsStream("/test.properties"), + "test.properties was not found on test classpath" + ); + String testProperties = new String(input.readAllBytes(), StandardCharsets.UTF_8); + + assertFalse(testProperties.contains("${wiremock.server.port:5000}"), + "Fixed WireMock fallback port 5000 must not be used in test.properties"); + assertFalse(testProperties.contains("${wiremock.server.port:4502}"), + "Fixed WireMock fallback port 4502 must not be used in test.properties"); + } +} From 1f97f8501fdf49932fb01be68ae2bcb4da178195 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 10:25:56 +0000 Subject: [PATCH 16/41] build(coverage): align jacocoTestReport with testUnit/testIt execution data --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 291f07699e..fd4aca5109 100644 --- a/build.gradle +++ b/build.gradle @@ -605,9 +605,9 @@ compileTestJava { // adopted from // https://github.com/springfox/springfox/blob/fb780ee1f14627b239fba95730a69900b9b2313a/gradle/coverage.gradle jacocoTestReport { - dependsOn generateJsonSchema2Pojo, compileJava, processResources, test, integration + dependsOn generateJsonSchema2Pojo, compileJava, processResources, testUnit, testIt, integration mustRunAfter generateJsonSchema2Pojo, compileJava, processResources - executionData(test, integration) + executionData(testUnit, testIt, integration) doFirst { logger.lifecycle("{} Starting jacocoTestReport ...", timestamp()) } From bc1d5e4a2e4d1431686a8f5b4dd89e5f3585c254 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 11:12:05 +0000 Subject: [PATCH 17/41] helm(preview): allow host.docker.internal in callback URL allowlists for smoke/BEFTA callback validation --- charts/ccd-data-store-api/values.preview.template.yaml | 6 +++--- charts/ccd-data-store-api/values.yaml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index 989c5ebb22..62f9426363 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -41,9 +41,9 @@ java: LOGGING_LEVEL_UK_GOV_HMCTS_CCD_SECURITY_IDAM: DEBUG LOG_CALLBACK_DETAILS: # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1 - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1 - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1 + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,host.docker.internal + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,host.docker.internal + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,host.docker.internal DEFAULT_CACHE_TTL_SEC: 1 BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" SPRING_APPLICATION_JSON: | diff --git a/charts/ccd-data-store-api/values.yaml b/charts/ccd-data-store-api/values.yaml index b9dd48e846..c5880c5941 100644 --- a/charts/ccd-data-store-api/values.yaml +++ b/charts/ccd-data-store-api/values.yaml @@ -49,9 +49,9 @@ java: # this variable takes a comma separated list of callback urls for which details needs to be logged, or '*' for all LOG_CALLBACK_DETAILS: # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1 - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1 - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1 + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,host.docker.internal + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,host.docker.internal + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,host.docker.internal CCD_DRAFT_TTL_DAYS: 180 TTL_GUARD: 365 From 2d1a5e4935a74913a7d2da4aeefa8e9313c1b0cc Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 12:07:02 +0000 Subject: [PATCH 18/41] helm(preview): allow host. Add pre-smoke diagnostics for TEST_URL/env visibility and health probe --- build.gradle | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/build.gradle b/build.gradle index fd4aca5109..f37c68682c 100644 --- a/build.gradle +++ b/build.gradle @@ -743,9 +743,44 @@ task highLevelDataSetup(type: JavaExec) { jvmArgs = ['--add-opens=java.base/java.lang.reflect=ALL-UNNAMED'] } +task preSmokeDiagnostics { + group = "Verification" + description = "Logs key smoke-test env values and probes TEST_URL health endpoint." + + doLast { + def env = System.getenv() + def testUrl = env.get("TEST_URL") + logger.quiet("Smoke diagnostics: TEST_URL=${testUrl ?: ''}") + logger.quiet("Smoke diagnostics: IDAM_API_URL_BASE=${env.get('IDAM_API_URL_BASE') ?: ''}") + logger.quiet("Smoke diagnostics: S2S_URL_BASE=${env.get('S2S_URL_BASE') ?: ''}") + logger.quiet("Smoke diagnostics: CCD_CALLBACK_ALLOWED_HOSTS=${env.get('CCD_CALLBACK_ALLOWED_HOSTS') ?: ''}") + logger.quiet("Smoke diagnostics: CCD_CALLBACK_ALLOWED_HTTP_HOSTS=${env.get('CCD_CALLBACK_ALLOWED_HTTP_HOSTS') ?: ''}") + logger.quiet("Smoke diagnostics: CCD_CALLBACK_ALLOW_PRIVATE_HOSTS=${env.get('CCD_CALLBACK_ALLOW_PRIVATE_HOSTS') ?: ''}") + + if (!testUrl) { + logger.quiet("Smoke diagnostics: TEST_URL is not set; skipping health probe.") + return + } + + def healthUrl = testUrl.endsWith("/") ? "${testUrl}actuator/health" : "${testUrl}/actuator/health" + try { + HttpURLConnection connection = (HttpURLConnection) new URL(healthUrl).openConnection() + connection.setRequestMethod("GET") + connection.setConnectTimeout(5000) + connection.setReadTimeout(5000) + connection.connect() + int statusCode = connection.getResponseCode() + logger.quiet("Smoke diagnostics: health probe ${healthUrl} -> HTTP ${statusCode}") + } catch (Exception exception) { + logger.quiet("Smoke diagnostics: health probe failed for ${healthUrl}: ${exception.getClass().getSimpleName()} - ${exception.getMessage()}") + } + } +} + task smoke() { description = 'Executes smoke tests against an the CCD Data Store API instance just deployed' dependsOn aatClasses + dependsOn preSmokeDiagnostics new File("$buildDir/test-results/test").mkdirs() copy { From e7a9f3da6eac817b68a2e3cba738d88f45b52561 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 13:35:29 +0000 Subject: [PATCH 19/41] Archive stage-specific smoke/functional cucumber JSON and XML artifacts in Jenkins --- Jenkinsfile_CNP | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 518958d583..283e96cf1b 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -185,6 +185,10 @@ withPipeline(type, product, component) { } afterAlways('smoketest:preview') { + steps.sh("if [ -f target/cucumber.json ]; then cp target/cucumber.json target/cucumber-smoke.json; fi") + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber.json' + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber-smoke.json' + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'build/test-results/smoke/cucumber.xml' steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/BEFTA Report for Smoke Tests/**/*' publishHTML target: [ allowMissing : true, @@ -197,6 +201,10 @@ withPipeline(type, product, component) { } afterAlways('smoketest:aat') { + steps.sh("if [ -f target/cucumber.json ]; then cp target/cucumber.json target/cucumber-smoke.json; fi") + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber.json' + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber-smoke.json' + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'build/test-results/smoke/cucumber.xml' steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/BEFTA Report for Smoke Tests/**/*' publishHTML target: [ allowMissing : true, @@ -209,6 +217,10 @@ withPipeline(type, product, component) { } afterAlways('functionalTest:preview') { + steps.sh("if [ -f target/cucumber.json ]; then cp target/cucumber.json target/cucumber-functional.json; fi") + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber.json' + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber-functional.json' + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'build/test-results/functional/cucumber.xml' steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/BEFTA Report for Functional Tests/**/*' publishHTML target: [ allowMissing : true, @@ -221,6 +233,10 @@ withPipeline(type, product, component) { } afterAlways('functionalTest:aat') { + steps.sh("if [ -f target/cucumber.json ]; then cp target/cucumber.json target/cucumber-functional.json; fi") + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber.json' + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber-functional.json' + steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'build/test-results/functional/cucumber.xml' steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/BEFTA Report for Functional Tests/**/*' publishHTML target: [ allowMissing : true, From 9f18e85d62e0c04f067f6e08c64b72d053bacbb3 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 13:38:26 +0000 Subject: [PATCH 20/41] Reduce Jenkins artifact footprint by archiving stage-specific cucumber JSON plus JUnit XML only --- Jenkinsfile_CNP | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 283e96cf1b..4f1caf47d0 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -186,7 +186,6 @@ withPipeline(type, product, component) { afterAlways('smoketest:preview') { steps.sh("if [ -f target/cucumber.json ]; then cp target/cucumber.json target/cucumber-smoke.json; fi") - steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber.json' steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber-smoke.json' steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'build/test-results/smoke/cucumber.xml' steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/BEFTA Report for Smoke Tests/**/*' @@ -202,7 +201,6 @@ withPipeline(type, product, component) { afterAlways('smoketest:aat') { steps.sh("if [ -f target/cucumber.json ]; then cp target/cucumber.json target/cucumber-smoke.json; fi") - steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber.json' steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber-smoke.json' steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'build/test-results/smoke/cucumber.xml' steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/BEFTA Report for Smoke Tests/**/*' @@ -218,7 +216,6 @@ withPipeline(type, product, component) { afterAlways('functionalTest:preview') { steps.sh("if [ -f target/cucumber.json ]; then cp target/cucumber.json target/cucumber-functional.json; fi") - steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber.json' steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber-functional.json' steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'build/test-results/functional/cucumber.xml' steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/BEFTA Report for Functional Tests/**/*' @@ -234,7 +231,6 @@ withPipeline(type, product, component) { afterAlways('functionalTest:aat') { steps.sh("if [ -f target/cucumber.json ]; then cp target/cucumber.json target/cucumber-functional.json; fi") - steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber.json' steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'target/cucumber-functional.json' steps.archiveArtifacts allowEmptyArchive: true, artifacts: 'build/test-results/functional/cucumber.xml' steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/BEFTA Report for Functional Tests/**/*' From 2ac70c54baa03fc68460b838782e26262bf49818 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 14:23:52 +0000 Subject: [PATCH 21/41] Add callback config drift guards and smoke preflight diagnostics for BEFTA stub host alignment --- AGENTS.md | 2 + README.md | 4 +- build.gradle | 80 ++++++++++++++++++- .../values.aat.template.yaml | 6 +- .../values.preview.template.yaml | 12 +-- charts/ccd-data-store-api/values.yaml | 7 +- docs/api/security.md | 1 + 7 files changed, 98 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c8502cd269..5be61fb3b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,3 +64,5 @@ Tasks: - This improves stability but can increase test runtime because affected test classes do not reuse the same Spring context across classes. - Build verification is split into `testUnit` (parallel unit tests) and `testIt` (serialized `*IT`/`*ITest` tests for stability). - The default `test` task is disabled to avoid duplicate execution; `check`/`build` run `testUnit` and `testIt`. +- Smoke test execution now includes `preSmokeDiagnostics`, which logs `TEST_URL`, key auth/callback env values, and probes `${TEST_URL}/actuator/health` before BEFTA starts. +- Jenkins archives stage-specific BEFTA outputs as `target/cucumber-smoke.json` and `target/cucumber-functional.json` (plus corresponding JUnit XML), so smoke/functional failures can be triaged independently. diff --git a/README.md b/README.md index 513cf03b1d..60190c035c 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,12 @@ The following environment variables are required: | DRAFT_STORE_URL | - | Base URL for Draft Store API service. `http://localhost:8800` for the dockerised local instance. | | DRAFT_ENCRYPTION_KEY | - | Draft encryption key. The encryption key used by draft store to encrypt documents with. | | DRAFT_TTL_DAYS | - | Number of days after which the saved draft will be deleted if unmodified. | -| CCD_CALLBACK_ALLOWED_HOSTS | localhost,127.0.0.1 | Comma-separated callback destination host allowlist (`*` and `*.domain.tld` supported). | +| CCD_CALLBACK_ALLOWED_HOSTS | localhost,127.0.0.1 | Comma-separated callback destination host allowlist (`*` and `*.domain.tld` supported). Use environment-specific callback hosts (for example local Docker: `host.docker.internal`; AAT/pipeline: internal service DNS). | | CCD_CALLBACK_ALLOWED_HTTP_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to use `http` for callbacks (all other callback hosts must use `https`). | | CCD_CALLBACK_ALLOW_PRIVATE_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to resolve to private/local addresses for callbacks. | +For Helm-based preview/AAT deployments, keep callback host settings and BEFTA stub URL aligned via `beftaTestStubServiceHost` in `charts/ccd-data-store-api/values*.yaml`. + ### Building The project uses [Gradle](https://gradle.org/). diff --git a/build.gradle b/build.gradle index f37c68682c..5504410958 100644 --- a/build.gradle +++ b/build.gradle @@ -205,6 +205,43 @@ test { check.dependsOn testUnit, testIt +task verifyBefTaStubHostConfigConsistency { + group = "Verification" + description = "Ensures Helm values keep BEFTA stub host and callback allowlists aligned." + + doLast { + def valuesFiles = [ + file("charts/ccd-data-store-api/values.yaml"), + file("charts/ccd-data-store-api/values.preview.template.yaml"), + file("charts/ccd-data-store-api/values.aat.template.yaml") + ] + def issues = [] + + valuesFiles.each { valuesFile -> + if (!valuesFile.exists()) { + return + } + def content = valuesFile.getText("UTF-8") + if (!content.contains("beftaTestStubServiceHost")) { + issues << "${project.relativePath(valuesFile)}: missing beftaTestStubServiceHost" + } + if (content.contains("CCD_CALLBACK_ALLOWED_HOSTS:") + && !content.contains("{{ .Values.beftaTestStubServiceHost }}")) { + issues << "${project.relativePath(valuesFile)}: callback allowlists not using shared befta host value" + } + if (content.contains("BEFTA_TEST_STUB_SERVICE_BASE_URL:") + && !content.contains("http://{{ .Values.beftaTestStubServiceHost }}/")) { + issues << "${project.relativePath(valuesFile)}: BEFTA_TEST_STUB_SERVICE_BASE_URL not using shared befta host value" + } + } + + if (!issues.isEmpty()) { + throw new GradleException("BEFTA callback/stub host config drift detected:\n - " + issues.join("\n - ")) + } + } +} +check.dependsOn verifyBefTaStubHostConfigConsistency + task integration(type: Test) { description = "Runs integration tests" group = "Verification" @@ -750,12 +787,49 @@ task preSmokeDiagnostics { doLast { def env = System.getenv() def testUrl = env.get("TEST_URL") + def beftaStubBaseUrl = env.get("BEFTA_TEST_STUB_SERVICE_BASE_URL") + def callbackAllowedHosts = env.get("CCD_CALLBACK_ALLOWED_HOSTS") + def callbackAllowedHttpHosts = env.get("CCD_CALLBACK_ALLOWED_HTTP_HOSTS") + def callbackAllowPrivateHosts = env.get("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS") logger.quiet("Smoke diagnostics: TEST_URL=${testUrl ?: ''}") logger.quiet("Smoke diagnostics: IDAM_API_URL_BASE=${env.get('IDAM_API_URL_BASE') ?: ''}") logger.quiet("Smoke diagnostics: S2S_URL_BASE=${env.get('S2S_URL_BASE') ?: ''}") - logger.quiet("Smoke diagnostics: CCD_CALLBACK_ALLOWED_HOSTS=${env.get('CCD_CALLBACK_ALLOWED_HOSTS') ?: ''}") - logger.quiet("Smoke diagnostics: CCD_CALLBACK_ALLOWED_HTTP_HOSTS=${env.get('CCD_CALLBACK_ALLOWED_HTTP_HOSTS') ?: ''}") - logger.quiet("Smoke diagnostics: CCD_CALLBACK_ALLOW_PRIVATE_HOSTS=${env.get('CCD_CALLBACK_ALLOW_PRIVATE_HOSTS') ?: ''}") + logger.quiet("Smoke diagnostics: BEFTA_TEST_STUB_SERVICE_BASE_URL=${beftaStubBaseUrl ?: ''}") + logger.quiet("Smoke diagnostics: CCD_CALLBACK_ALLOWED_HOSTS=${callbackAllowedHosts ?: ''}") + logger.quiet("Smoke diagnostics: CCD_CALLBACK_ALLOWED_HTTP_HOSTS=${callbackAllowedHttpHosts ?: ''}") + logger.quiet("Smoke diagnostics: CCD_CALLBACK_ALLOW_PRIVATE_HOSTS=${callbackAllowPrivateHosts ?: ''}") + + if (beftaStubBaseUrl) { + try { + String beftaStubHost = new URL(beftaStubBaseUrl).getHost() + boolean callbackAllowlistsProvided = callbackAllowedHosts != null + && callbackAllowedHttpHosts != null + && callbackAllowPrivateHosts != null + if (!callbackAllowlistsProvided) { + logger.quiet("Smoke diagnostics: callback allowlist env vars are not fully set; " + + "skipping strict BEFTA stub host membership check.") + } else { + List missing = [] + if (!(callbackAllowedHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { + missing.add("CCD_CALLBACK_ALLOWED_HOSTS") + } + if (!(callbackAllowedHttpHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { + missing.add("CCD_CALLBACK_ALLOWED_HTTP_HOSTS") + } + if (!(callbackAllowPrivateHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { + missing.add("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS") + } + if (!missing.isEmpty()) { + throw new GradleException("Smoke preflight failed: BEFTA stub host '" + beftaStubHost + + "' is missing from " + missing + ".") + } + logger.quiet("Smoke diagnostics: BEFTA stub host '${beftaStubHost}' present in callback allowlists.") + } + } catch (MalformedURLException malformedURLException) { + throw new GradleException("Smoke preflight failed: invalid BEFTA_TEST_STUB_SERVICE_BASE_URL: " + + beftaStubBaseUrl, malformedURLException) + } + } if (!testUrl) { logger.quiet("Smoke diagnostics: TEST_URL is not set; skipping health probe.") diff --git a/charts/ccd-data-store-api/values.aat.template.yaml b/charts/ccd-data-store-api/values.aat.template.yaml index bed06bdc19..a9baca90d8 100644 --- a/charts/ccd-data-store-api/values.aat.template.yaml +++ b/charts/ccd-data-store-api/values.aat.template.yaml @@ -1,4 +1,6 @@ # config used by staging aat pod +beftaTestStubServiceHost: ccd-test-stubs-service-aat.service.core-compute-aat.internal + java: image: ${IMAGE_NAME} ingressHost: ${SERVICE_FQDN} @@ -37,6 +39,6 @@ java: DATA_STORE_DB_HOST: ccd-data-store-api-postgres-db-v15-aat.postgres.database.azure.com TESTING_SUPPORT_ENABLED: true MIGRATIONS_ENDPOINT_ENABLED: true - BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" + BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://{{ .Values.beftaTestStubServiceHost }}/" SPRING_APPLICATION_JSON: | - {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/"}}}} + {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://{{ .Values.beftaTestStubServiceHost }}/"}}}} diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index 62f9426363..1eb237451f 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -1,3 +1,5 @@ +beftaTestStubServiceHost: ccd-test-stubs-service-aat.service.core-compute-aat.internal + java: image: ${IMAGE_NAME} ingressHost: ${SERVICE_FQDN} @@ -41,13 +43,13 @@ java: LOGGING_LEVEL_UK_GOV_HMCTS_CCD_SECURITY_IDAM: DEBUG LOG_CALLBACK_DETAILS: # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,host.docker.internal - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,host.docker.internal - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,host.docker.internal + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} DEFAULT_CACHE_TTL_SEC: 1 - BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" + BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://{{ .Values.beftaTestStubServiceHost }}/" SPRING_APPLICATION_JSON: | - {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/"}}}} + {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://{{ .Values.beftaTestStubServiceHost }}/"}}}} postgresql: enabled: true primary: diff --git a/charts/ccd-data-store-api/values.yaml b/charts/ccd-data-store-api/values.yaml index c5880c5941..e5d20f9227 100644 --- a/charts/ccd-data-store-api/values.yaml +++ b/charts/ccd-data-store-api/values.yaml @@ -2,6 +2,7 @@ elastic: enabled: false ccd: enabled: false +beftaTestStubServiceHost: ccd-test-stubs-service-aat.service.core-compute-aat.internal java: image: 'hmctspublic.azurecr.io/ccd/data-store-api:latest' @@ -49,9 +50,9 @@ java: # this variable takes a comma separated list of callback urls for which details needs to be logged, or '*' for all LOG_CALLBACK_DETAILS: # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,host.docker.internal - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,host.docker.internal - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,host.docker.internal + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} CCD_DRAFT_TTL_DAYS: 180 TTL_GUARD: 365 diff --git a/docs/api/security.md b/docs/api/security.md index ecbd292f4f..c6b6a57df5 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -93,6 +93,7 @@ After enabling callback hardening, service teams should: - `CCD_CALLBACK_ALLOWED_HOSTS` - `CCD_CALLBACK_ALLOWED_HTTP_HOSTS` (only for explicitly approved `http` hosts) - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` (only for explicitly approved private/local hosts) + - For Helm-based preview/AAT deployments, keep callback/stub host config aligned via the shared chart value `beftaTestStubServiceHost` (used by both callback allowlists and `BEFTA_TEST_STUB_SERVICE_BASE_URL`). 3. Validate callback URLs during definition onboarding/import so invalid URLs are rejected before runtime. 4. Re-run callback integration tests and verify expected callback hosts are accepted. 5. Ensure callback endpoints do not return redirects (`3xx`) and instead return final responses directly. From 8e6f1d9e94a74b876dd6244554689e1f692bfe3e Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 15:15:41 +0000 Subject: [PATCH 22/41] output values for - CCD_CALLBACK_ALLOWED_HOSTS - CCD_CALLBACK_ALLOWED_HTTP_HOSTS - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS --- Jenkinsfile_CNP | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 4f1caf47d0..7cd45f49ef 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -142,6 +142,10 @@ withPipeline(type, product, component) { } echo "ES FTA Enabled = ${env.ELASTIC_SEARCH_FTA_ENABLED} on branch ${env.BRANCH_NAME}" + echo "CCD_CALLBACK_ALLOWED_HOSTS = ${env.CCD_CALLBACK_ALLOWED_HOSTS} on branch ${env.BRANCH_NAME}" + echo "CCD_CALLBACK_ALLOWED_HTTP_HOSTS = ${env.CCD_CALLBACK_ALLOWED_HTTP_HOSTS} on branch ${env.BRANCH_NAME}" + echo "CCD_CALLBACK_ALLOW_PRIVATE_HOSTS = ${env.CCD_CALLBACK_ALLOW_PRIVATE_HOSTS} on branch ${env.BRANCH_NAME}" + syncBranchesWithMaster(branchesToSync) overrideVaultEnvironments(vaultOverrides) @@ -161,10 +165,10 @@ withPipeline(type, product, component) { copyIgnore('./build/reports/tests/integration', './Integration Tests/') steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/Integration Tests/**/*' - + copyIgnore('./build/reports/tests/test', './Unit Tests/') steps.archiveArtifacts allowEmptyArchive: true, artifacts: '**/Unit Tests/**/*' - + publishHTML target: [ allowMissing : true, alwaysLinkToLastBuild: true, From 422b01e3157672e62e3e462acc011fe01b6c1f77 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 3 Mar 2026 15:18:33 +0000 Subject: [PATCH 23/41] revert --- Jenkinsfile_CNP | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 7cd45f49ef..06b4c3e2bc 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -142,10 +142,6 @@ withPipeline(type, product, component) { } echo "ES FTA Enabled = ${env.ELASTIC_SEARCH_FTA_ENABLED} on branch ${env.BRANCH_NAME}" - echo "CCD_CALLBACK_ALLOWED_HOSTS = ${env.CCD_CALLBACK_ALLOWED_HOSTS} on branch ${env.BRANCH_NAME}" - echo "CCD_CALLBACK_ALLOWED_HTTP_HOSTS = ${env.CCD_CALLBACK_ALLOWED_HTTP_HOSTS} on branch ${env.BRANCH_NAME}" - echo "CCD_CALLBACK_ALLOW_PRIVATE_HOSTS = ${env.CCD_CALLBACK_ALLOW_PRIVATE_HOSTS} on branch ${env.BRANCH_NAME}" - syncBranchesWithMaster(branchesToSync) overrideVaultEnvironments(vaultOverrides) From ec9ef0e1fe148d664f9966a80572605f6c5549ae Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 4 Mar 2026 10:07:38 +0000 Subject: [PATCH 24/41] test(callback-validation): add explicit no-throw assertion for null case type callback validation --- .../data/definition/DefaultCaseDefinitionRepository.java | 6 +++--- .../definition/DefaultCaseDefinitionRepositoryCoreTest.java | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java b/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java index 4224da58a5..04050f3113 100644 --- a/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java +++ b/src/main/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepository.java @@ -45,7 +45,7 @@ public class DefaultCaseDefinitionRepository implements CaseDefinitionRepository public static final String QUALIFIER = "default"; private static final int RESOURCE_NOT_FOUND = 404; private static final Pattern ENV_PLACEHOLDER_PATTERN = - Pattern.compile("\\$\\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?}"); + Pattern.compile("\\$\\{([A-Za-z_]\\w*)(?::([^}]*))?}"); private final ApplicationParams applicationParams; private final DefinitionStoreClient definitionStoreClient; @@ -337,8 +337,8 @@ private List getJurisdictionsFromDefinitionStore(Optiona } private boolean isNotFound(Exception e) { - return e instanceof HttpClientErrorException - && ((HttpClientErrorException) e).getStatusCode().value() == RESOURCE_NOT_FOUND; + return e instanceof HttpClientErrorException httpClientErrorException + && httpClientErrorException.getStatusCode().value() == RESOURCE_NOT_FOUND; } private ServiceException toServiceException(String prefixMessage, Exception e) { diff --git a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java index f20660a330..27b10a33f3 100644 --- a/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java @@ -22,6 +22,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -264,7 +265,9 @@ void shouldBuildEncodedUserRoleQueryParam() { @Test void shouldNoOpWhenValidatingNullCaseTypeDefinition() { - ReflectionTestUtils.invokeMethod(subject, "validateCaseTypeCallbackUrls", new Object[] {null}); + assertDoesNotThrow( + () -> ReflectionTestUtils.invokeMethod(subject, "validateCaseTypeCallbackUrls", new Object[] {null}) + ); } @Test From 93cb5c197b131027b6a12b27eea94c84aa7fffbe Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 4 Mar 2026 10:45:00 +0000 Subject: [PATCH 25/41] ci(jenkins): set callback allowlist envs for BEFTA stub host and log effective TEST_URL for diagnostics --- Jenkinsfile_CNP | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 06b4c3e2bc..548c11e566 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -106,6 +106,10 @@ env.MAX_NUM_PARALLEL_THREADS=6 env.BEFTA_S2S_CLIENT_ID = "ccd_gw" env.ROLE_ASSIGNMENT_API_GATEWAY_S2S_CLIENT_ID = "ccd_data" env.BEFTA_TEST_STUB_SERVICE_BASE_URL = "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal" +env.BEFTA_TEST_STUB_SERVICE_HOST = "ccd-test-stubs-service-aat.service.core-compute-aat.internal" +env.CCD_CALLBACK_ALLOWED_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" +env.CCD_CALLBACK_ALLOWED_HTTP_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" +env.CCD_CALLBACK_ALLOW_PRIVATE_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" env.BEFTA_S2S_CLIENT_ID_OF_CCD_DATA = "ccd_data" env.BEFTA_S2S_CLIENT_ID_OF_XUI_WEBAPP = "xui_webapp" // BEFTA retry env variables @@ -140,6 +144,7 @@ withPipeline(type, product, component) { env.DEFINITION_STORE_URL_BASE = "https://ccd-definition-store-ccd-data-store-api-${env.BRANCH_NAME}.preview.platform.hmcts.net".toLowerCase() env.DEFINITION_STORE_HOST = env.DEFINITION_STORE_URL_BASE } + echo "Effective TEST_URL=${env.TEST_URL}" echo "ES FTA Enabled = ${env.ELASTIC_SEARCH_FTA_ENABLED} on branch ${env.BRANCH_NAME}" From 7642946fb201af44801b6801ea54b794c8a5851e Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 4 Mar 2026 13:46:32 +0000 Subject: [PATCH 26/41] Fix preview callback allowlists to include BEFTA stub host explicitly --- charts/ccd-data-store-api/values.preview.template.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index 1eb237451f..b9179c9b81 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -43,9 +43,9 @@ java: LOGGING_LEVEL_UK_GOV_HMCTS_CCD_SECURITY_IDAM: DEBUG LOG_CALLBACK_DETAILS: # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal DEFAULT_CACHE_TTL_SEC: 1 BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://{{ .Values.beftaTestStubServiceHost }}/" SPRING_APPLICATION_JSON: | From 4f3717411f5bdfc8cc1e0f24327649344657b8ac Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 4 Mar 2026 15:09:00 +0000 Subject: [PATCH 27/41] fix(ci): harden callback host consistency checks and restore valid preview/aat decentralised stub URLs --- README.md | 4 ++- build.gradle | 30 ++++++++++++++----- .../values.aat.template.yaml | 6 ++-- .../values.preview.template.yaml | 6 ++-- 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 60190c035c..fbee8ad307 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,9 @@ The following environment variables are required: | CCD_CALLBACK_ALLOWED_HTTP_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to use `http` for callbacks (all other callback hosts must use `https`). | | CCD_CALLBACK_ALLOW_PRIVATE_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to resolve to private/local addresses for callbacks. | -For Helm-based preview/AAT deployments, keep callback host settings and BEFTA stub URL aligned via `beftaTestStubServiceHost` in `charts/ccd-data-store-api/values*.yaml`. +For Helm-based preview/AAT deployments, ensure callback host allowlists explicitly include the BEFTA stub host +`ccd-test-stubs-service-aat.service.core-compute-aat.internal` in: +`CCD_CALLBACK_ALLOWED_HOSTS`, `CCD_CALLBACK_ALLOWED_HTTP_HOSTS`, and `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`. ### Building diff --git a/build.gradle b/build.gradle index 5504410958..1991f846c1 100644 --- a/build.gradle +++ b/build.gradle @@ -210,6 +210,8 @@ task verifyBefTaStubHostConfigConsistency { description = "Ensures Helm values keep BEFTA stub host and callback allowlists aligned." doLast { + def expectedStubHost = "ccd-test-stubs-service-aat.service.core-compute-aat.internal" + def templatedStubHost = "{{ .Values.beftaTestStubServiceHost }}" def valuesFiles = [ file("charts/ccd-data-store-api/values.yaml"), file("charts/ccd-data-store-api/values.preview.template.yaml"), @@ -222,16 +224,30 @@ task verifyBefTaStubHostConfigConsistency { return } def content = valuesFile.getText("UTF-8") - if (!content.contains("beftaTestStubServiceHost")) { - issues << "${project.relativePath(valuesFile)}: missing beftaTestStubServiceHost" + boolean containsSharedHostKey = content.contains("beftaTestStubServiceHost") + boolean containsExpectedHostLiteral = content.contains(expectedStubHost) + if (!containsSharedHostKey && !containsExpectedHostLiteral) { + issues << "${project.relativePath(valuesFile)}: missing BEFTA stub host definition" } - if (content.contains("CCD_CALLBACK_ALLOWED_HOSTS:") - && !content.contains("{{ .Values.beftaTestStubServiceHost }}")) { - issues << "${project.relativePath(valuesFile)}: callback allowlists not using shared befta host value" + def callbackAllowlistKeys = [ + "CCD_CALLBACK_ALLOWED_HOSTS", + "CCD_CALLBACK_ALLOWED_HTTP_HOSTS", + "CCD_CALLBACK_ALLOW_PRIVATE_HOSTS" + ] + callbackAllowlistKeys.each { key -> + boolean hasKey = content.contains("${key}:") + boolean hasExpectedLiteral = content.contains("${key}: localhost,127.0.0.1,${expectedStubHost}") + boolean hasTemplatedValue = content.contains("${key}: localhost,127.0.0.1,${templatedStubHost}") + if (hasKey && !(hasExpectedLiteral || hasTemplatedValue)) { + issues << "${project.relativePath(valuesFile)}: ${key} missing expected BEFTA stub host" + } } + boolean beftaStubBaseUrlValid = + content.contains("BEFTA_TEST_STUB_SERVICE_BASE_URL: \"http://${expectedStubHost}/\"") + || content.contains("BEFTA_TEST_STUB_SERVICE_BASE_URL: \"http://${templatedStubHost}/\"") if (content.contains("BEFTA_TEST_STUB_SERVICE_BASE_URL:") - && !content.contains("http://{{ .Values.beftaTestStubServiceHost }}/")) { - issues << "${project.relativePath(valuesFile)}: BEFTA_TEST_STUB_SERVICE_BASE_URL not using shared befta host value" + && !beftaStubBaseUrlValid) { + issues << "${project.relativePath(valuesFile)}: BEFTA_TEST_STUB_SERVICE_BASE_URL not set to expected stub host" } } diff --git a/charts/ccd-data-store-api/values.aat.template.yaml b/charts/ccd-data-store-api/values.aat.template.yaml index a9baca90d8..bed06bdc19 100644 --- a/charts/ccd-data-store-api/values.aat.template.yaml +++ b/charts/ccd-data-store-api/values.aat.template.yaml @@ -1,6 +1,4 @@ # config used by staging aat pod -beftaTestStubServiceHost: ccd-test-stubs-service-aat.service.core-compute-aat.internal - java: image: ${IMAGE_NAME} ingressHost: ${SERVICE_FQDN} @@ -39,6 +37,6 @@ java: DATA_STORE_DB_HOST: ccd-data-store-api-postgres-db-v15-aat.postgres.database.azure.com TESTING_SUPPORT_ENABLED: true MIGRATIONS_ENDPOINT_ENABLED: true - BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://{{ .Values.beftaTestStubServiceHost }}/" + BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" SPRING_APPLICATION_JSON: | - {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://{{ .Values.beftaTestStubServiceHost }}/"}}}} + {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/"}}}} diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index b9179c9b81..ef9bd50a94 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -1,5 +1,3 @@ -beftaTestStubServiceHost: ccd-test-stubs-service-aat.service.core-compute-aat.internal - java: image: ${IMAGE_NAME} ingressHost: ${SERVICE_FQDN} @@ -47,9 +45,9 @@ java: CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal DEFAULT_CACHE_TTL_SEC: 1 - BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://{{ .Values.beftaTestStubServiceHost }}/" + BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" SPRING_APPLICATION_JSON: | - {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://{{ .Values.beftaTestStubServiceHost }}/"}}}} + {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/"}}}} postgresql: enabled: true primary: From a88da40f57a94bccc919b2839e985eb7949b3bb7 Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 5 Mar 2026 11:04:11 +0000 Subject: [PATCH 28/41] Align nightly callback allowlist env with CNP and make preFunctionalDiagnostics tolerate blank allowlist vars --- Jenkinsfile_nightly | 4 + README.md | 7 +- build.gradle | 86 +++++++++++++++++-- .../values.aat.template.yaml | 4 + charts/ccd-data-store-api/values.yaml | 7 +- docs/api/security.md | 2 +- docs/integration.md | 10 +++ 7 files changed, 103 insertions(+), 17 deletions(-) diff --git a/Jenkinsfile_nightly b/Jenkinsfile_nightly index 3812977cde..7fc05c5606 100644 --- a/Jenkinsfile_nightly +++ b/Jenkinsfile_nightly @@ -96,6 +96,10 @@ env.BEFTA_RESPONSE_HEADER_CHECK_POLICY="JUST_WARN" // Temporary workaround for p env.ELASTIC_SEARCH_FTA_ENABLED = "true" env.DEFAULT_COLLECTION_ASSERTION_MODE="UNORDERED" env.BEFTA_TEST_STUB_SERVICE_BASE_URL = "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal" +env.BEFTA_TEST_STUB_SERVICE_HOST = "ccd-test-stubs-service-aat.service.core-compute-aat.internal" +env.CCD_CALLBACK_ALLOWED_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" +env.CCD_CALLBACK_ALLOWED_HTTP_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" +env.CCD_CALLBACK_ALLOW_PRIVATE_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" env.DEFINITION_STORE_HOST = "http://ccd-definition-store-api-aat.service.core-compute-aat.internal" // BEFTA retry env variables env.BEFTA_RETRY_MAX_ATTEMPTS = "3" diff --git a/README.md b/README.md index fbee8ad307..ba07612a33 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,10 @@ The following environment variables are required: | CCD_CALLBACK_ALLOWED_HTTP_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to use `http` for callbacks (all other callback hosts must use `https`). | | CCD_CALLBACK_ALLOW_PRIVATE_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to resolve to private/local addresses for callbacks. | -For Helm-based preview/AAT deployments, ensure callback host allowlists explicitly include the BEFTA stub host -`ccd-test-stubs-service-aat.service.core-compute-aat.internal` in: -`CCD_CALLBACK_ALLOWED_HOSTS`, `CCD_CALLBACK_ALLOWED_HTTP_HOSTS`, and `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`. +For Helm-based preview/AAT deployments, ensure callback host allowlists include the callback destinations used by +your environment in `CCD_CALLBACK_ALLOWED_HOSTS`, `CCD_CALLBACK_ALLOWED_HTTP_HOSTS`, and +`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`. +For preview/AAT, include `ccd-test-stubs-service-aat.service.core-compute-aat.internal` in all three settings. ### Building diff --git a/build.gradle b/build.gradle index 1991f846c1..b77eb09241 100644 --- a/build.gradle +++ b/build.gradle @@ -211,7 +211,6 @@ task verifyBefTaStubHostConfigConsistency { doLast { def expectedStubHost = "ccd-test-stubs-service-aat.service.core-compute-aat.internal" - def templatedStubHost = "{{ .Values.beftaTestStubServiceHost }}" def valuesFiles = [ file("charts/ccd-data-store-api/values.yaml"), file("charts/ccd-data-store-api/values.preview.template.yaml"), @@ -224,9 +223,8 @@ task verifyBefTaStubHostConfigConsistency { return } def content = valuesFile.getText("UTF-8") - boolean containsSharedHostKey = content.contains("beftaTestStubServiceHost") boolean containsExpectedHostLiteral = content.contains(expectedStubHost) - if (!containsSharedHostKey && !containsExpectedHostLiteral) { + if (!containsExpectedHostLiteral) { issues << "${project.relativePath(valuesFile)}: missing BEFTA stub host definition" } def callbackAllowlistKeys = [ @@ -237,14 +235,12 @@ task verifyBefTaStubHostConfigConsistency { callbackAllowlistKeys.each { key -> boolean hasKey = content.contains("${key}:") boolean hasExpectedLiteral = content.contains("${key}: localhost,127.0.0.1,${expectedStubHost}") - boolean hasTemplatedValue = content.contains("${key}: localhost,127.0.0.1,${templatedStubHost}") - if (hasKey && !(hasExpectedLiteral || hasTemplatedValue)) { + if (hasKey && !hasExpectedLiteral) { issues << "${project.relativePath(valuesFile)}: ${key} missing expected BEFTA stub host" } } boolean beftaStubBaseUrlValid = content.contains("BEFTA_TEST_STUB_SERVICE_BASE_URL: \"http://${expectedStubHost}/\"") - || content.contains("BEFTA_TEST_STUB_SERVICE_BASE_URL: \"http://${templatedStubHost}/\"") if (content.contains("BEFTA_TEST_STUB_SERVICE_BASE_URL:") && !beftaStubBaseUrlValid) { issues << "${project.relativePath(valuesFile)}: BEFTA_TEST_STUB_SERVICE_BASE_URL not set to expected stub host" @@ -818,9 +814,9 @@ task preSmokeDiagnostics { if (beftaStubBaseUrl) { try { String beftaStubHost = new URL(beftaStubBaseUrl).getHost() - boolean callbackAllowlistsProvided = callbackAllowedHosts != null - && callbackAllowedHttpHosts != null - && callbackAllowPrivateHosts != null + boolean callbackAllowlistsProvided = callbackAllowedHosts?.trim() + && callbackAllowedHttpHosts?.trim() + && callbackAllowPrivateHosts?.trim() if (!callbackAllowlistsProvided) { logger.quiet("Smoke diagnostics: callback allowlist env vars are not fully set; " + "skipping strict BEFTA stub host membership check.") @@ -867,6 +863,77 @@ task preSmokeDiagnostics { } } +task preFunctionalDiagnostics { + group = "Verification" + description = "Logs key functional-test env values and probes TEST_URL health endpoint." + + doLast { + def env = System.getenv() + def testUrl = env.get("TEST_URL") + def beftaStubBaseUrl = env.get("BEFTA_TEST_STUB_SERVICE_BASE_URL") + def callbackAllowedHosts = env.get("CCD_CALLBACK_ALLOWED_HOSTS") + def callbackAllowedHttpHosts = env.get("CCD_CALLBACK_ALLOWED_HTTP_HOSTS") + def callbackAllowPrivateHosts = env.get("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS") + logger.quiet("Functional diagnostics: TEST_URL=${testUrl ?: ''}") + logger.quiet("Functional diagnostics: IDAM_API_URL_BASE=${env.get('IDAM_API_URL_BASE') ?: ''}") + logger.quiet("Functional diagnostics: S2S_URL_BASE=${env.get('S2S_URL_BASE') ?: ''}") + logger.quiet("Functional diagnostics: BEFTA_TEST_STUB_SERVICE_BASE_URL=${beftaStubBaseUrl ?: ''}") + logger.quiet("Functional diagnostics: CCD_CALLBACK_ALLOWED_HOSTS=${callbackAllowedHosts ?: ''}") + logger.quiet("Functional diagnostics: CCD_CALLBACK_ALLOWED_HTTP_HOSTS=${callbackAllowedHttpHosts ?: ''}") + logger.quiet("Functional diagnostics: CCD_CALLBACK_ALLOW_PRIVATE_HOSTS=${callbackAllowPrivateHosts ?: ''}") + + if (beftaStubBaseUrl) { + try { + String beftaStubHost = new URL(beftaStubBaseUrl).getHost() + boolean callbackAllowlistsProvided = callbackAllowedHosts?.trim() + && callbackAllowedHttpHosts?.trim() + && callbackAllowPrivateHosts?.trim() + if (!callbackAllowlistsProvided) { + logger.quiet("Functional diagnostics: callback allowlist env vars are not fully set; " + + "skipping strict BEFTA stub host membership check.") + } else { + List missing = [] + if (!(callbackAllowedHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { + missing.add("CCD_CALLBACK_ALLOWED_HOSTS") + } + if (!(callbackAllowedHttpHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { + missing.add("CCD_CALLBACK_ALLOWED_HTTP_HOSTS") + } + if (!(callbackAllowPrivateHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { + missing.add("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS") + } + if (!missing.isEmpty()) { + throw new GradleException("Functional preflight failed: BEFTA stub host '" + beftaStubHost + + "' is missing from " + missing + ".") + } + logger.quiet("Functional diagnostics: BEFTA stub host '${beftaStubHost}' present in callback allowlists.") + } + } catch (MalformedURLException malformedURLException) { + throw new GradleException("Functional preflight failed: invalid BEFTA_TEST_STUB_SERVICE_BASE_URL: " + + beftaStubBaseUrl, malformedURLException) + } + } + + if (!testUrl) { + logger.quiet("Functional diagnostics: TEST_URL is not set; skipping health probe.") + return + } + + def healthUrl = testUrl.endsWith("/") ? "${testUrl}actuator/health" : "${testUrl}/actuator/health" + try { + HttpURLConnection connection = (HttpURLConnection) new URL(healthUrl).openConnection() + connection.setRequestMethod("GET") + connection.setConnectTimeout(5000) + connection.setReadTimeout(5000) + connection.connect() + int statusCode = connection.getResponseCode() + logger.quiet("Functional diagnostics: health probe ${healthUrl} -> HTTP ${statusCode}") + } catch (Exception exception) { + logger.quiet("Functional diagnostics: health probe failed for ${healthUrl}: ${exception.getClass().getSimpleName()} - ${exception.getMessage()}") + } + } +} + task smoke() { description = 'Executes smoke tests against an the CCD Data Store API instance just deployed' dependsOn aatClasses @@ -912,6 +979,7 @@ task functional(type: JavaExec) { description = "Executes functional tests against an the CCD Data Store API instance just deployed" group = "Verification" dependsOn aatClasses + dependsOn preFunctionalDiagnostics group = "Verification" diff --git a/charts/ccd-data-store-api/values.aat.template.yaml b/charts/ccd-data-store-api/values.aat.template.yaml index bed06bdc19..71610091f7 100644 --- a/charts/ccd-data-store-api/values.aat.template.yaml +++ b/charts/ccd-data-store-api/values.aat.template.yaml @@ -37,6 +37,10 @@ java: DATA_STORE_DB_HOST: ccd-data-store-api-postgres-db-v15-aat.postgres.database.azure.com TESTING_SUPPORT_ENABLED: true MIGRATIONS_ENDPOINT_ENABLED: true + # callback hardening: destination allowlists used by CallbackService URL validation + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" SPRING_APPLICATION_JSON: | {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/"}}}} diff --git a/charts/ccd-data-store-api/values.yaml b/charts/ccd-data-store-api/values.yaml index e5d20f9227..a38f6e508e 100644 --- a/charts/ccd-data-store-api/values.yaml +++ b/charts/ccd-data-store-api/values.yaml @@ -2,7 +2,6 @@ elastic: enabled: false ccd: enabled: false -beftaTestStubServiceHost: ccd-test-stubs-service-aat.service.core-compute-aat.internal java: image: 'hmctspublic.azurecr.io/ccd/data-store-api:latest' @@ -50,9 +49,9 @@ java: # this variable takes a comma separated list of callback urls for which details needs to be logged, or '*' for all LOG_CALLBACK_DETAILS: # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,{{ .Values.beftaTestStubServiceHost }} + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal CCD_DRAFT_TTL_DAYS: 180 TTL_GUARD: 365 diff --git a/docs/api/security.md b/docs/api/security.md index c6b6a57df5..db4dbae5f5 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -93,7 +93,7 @@ After enabling callback hardening, service teams should: - `CCD_CALLBACK_ALLOWED_HOSTS` - `CCD_CALLBACK_ALLOWED_HTTP_HOSTS` (only for explicitly approved `http` hosts) - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` (only for explicitly approved private/local hosts) - - For Helm-based preview/AAT deployments, keep callback/stub host config aligned via the shared chart value `beftaTestStubServiceHost` (used by both callback allowlists and `BEFTA_TEST_STUB_SERVICE_BASE_URL`). + - For preview/AAT, include `ccd-test-stubs-service-aat.service.core-compute-aat.internal` in all three allowlists. 3. Validate callback URLs during definition onboarding/import so invalid URLs are rejected before runtime. 4. Re-run callback integration tests and verify expected callback hosts are accepted. 5. Ensure callback endpoints do not return redirects (`3xx`) and instead return final responses directly. diff --git a/docs/integration.md b/docs/integration.md index 2da368e016..0b9214b0e5 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -18,3 +18,13 @@ be rejected as invalid URLs before callback execution. For BEFTA/AAT definitions that use environment placeholders (for example `${TEST_STUB_SERVICE_BASE_URL:...}/callback_get_case_injectedData`), ensure placeholder resolution happens before callback URL validation in the import path. + +For local BEFTA/AAT runs, set `TEST_STUB_SERVICE_BASE_URL` before importing definitions; otherwise placeholders can +fall back to AAT defaults and later fail callback host allowlist checks. + +Example: + +```bash +export TEST_STUB_SERVICE_BASE_URL=http://host.docker.internal:5555 +./gradlew functional +``` From abb49d4140c44cb6421703dabef5c2b8197f2792 Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 5 Mar 2026 17:02:15 +0000 Subject: [PATCH 29/41] Add aac-manage-case-assignment host to callback allowlists and update consistency checks --- Jenkinsfile_CNP | 7 ++++--- Jenkinsfile_nightly | 7 ++++--- build.gradle | 12 ++++++++---- charts/ccd-data-store-api/values.aat.template.yaml | 6 +++--- .../ccd-data-store-api/values.preview.template.yaml | 6 +++--- charts/ccd-data-store-api/values.yaml | 6 +++--- 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 548c11e566..bbb75765ba 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -107,9 +107,10 @@ env.BEFTA_S2S_CLIENT_ID = "ccd_gw" env.ROLE_ASSIGNMENT_API_GATEWAY_S2S_CLIENT_ID = "ccd_data" env.BEFTA_TEST_STUB_SERVICE_BASE_URL = "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal" env.BEFTA_TEST_STUB_SERVICE_HOST = "ccd-test-stubs-service-aat.service.core-compute-aat.internal" -env.CCD_CALLBACK_ALLOWED_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" -env.CCD_CALLBACK_ALLOWED_HTTP_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" -env.CCD_CALLBACK_ALLOW_PRIVATE_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" +env.AAC_MANAGE_CASE_ASSIGNMENT_HOST = "aac-manage-case-assignment-aat.service.core-compute-aat.internal" +env.CCD_CALLBACK_ALLOWED_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST},${env.AAC_MANAGE_CASE_ASSIGNMENT_HOST}" +env.CCD_CALLBACK_ALLOWED_HTTP_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST},${env.AAC_MANAGE_CASE_ASSIGNMENT_HOST}" +env.CCD_CALLBACK_ALLOW_PRIVATE_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST},${env.AAC_MANAGE_CASE_ASSIGNMENT_HOST}" env.BEFTA_S2S_CLIENT_ID_OF_CCD_DATA = "ccd_data" env.BEFTA_S2S_CLIENT_ID_OF_XUI_WEBAPP = "xui_webapp" // BEFTA retry env variables diff --git a/Jenkinsfile_nightly b/Jenkinsfile_nightly index 7fc05c5606..236151dbd5 100644 --- a/Jenkinsfile_nightly +++ b/Jenkinsfile_nightly @@ -97,9 +97,10 @@ env.ELASTIC_SEARCH_FTA_ENABLED = "true" env.DEFAULT_COLLECTION_ASSERTION_MODE="UNORDERED" env.BEFTA_TEST_STUB_SERVICE_BASE_URL = "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal" env.BEFTA_TEST_STUB_SERVICE_HOST = "ccd-test-stubs-service-aat.service.core-compute-aat.internal" -env.CCD_CALLBACK_ALLOWED_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" -env.CCD_CALLBACK_ALLOWED_HTTP_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" -env.CCD_CALLBACK_ALLOW_PRIVATE_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST}" +env.AAC_MANAGE_CASE_ASSIGNMENT_HOST = "aac-manage-case-assignment-aat.service.core-compute-aat.internal" +env.CCD_CALLBACK_ALLOWED_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST},${env.AAC_MANAGE_CASE_ASSIGNMENT_HOST}" +env.CCD_CALLBACK_ALLOWED_HTTP_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST},${env.AAC_MANAGE_CASE_ASSIGNMENT_HOST}" +env.CCD_CALLBACK_ALLOW_PRIVATE_HOSTS = "localhost,127.0.0.1,${env.BEFTA_TEST_STUB_SERVICE_HOST},${env.AAC_MANAGE_CASE_ASSIGNMENT_HOST}" env.DEFINITION_STORE_HOST = "http://ccd-definition-store-api-aat.service.core-compute-aat.internal" // BEFTA retry env variables env.BEFTA_RETRY_MAX_ATTEMPTS = "3" diff --git a/build.gradle b/build.gradle index 267e9ed49e..1e9fc1e285 100644 --- a/build.gradle +++ b/build.gradle @@ -211,6 +211,7 @@ task verifyBefTaStubHostConfigConsistency { doLast { def expectedStubHost = "ccd-test-stubs-service-aat.service.core-compute-aat.internal" + def expectedAacHost = "aac-manage-case-assignment-aat.service.core-compute-aat.internal" def valuesFiles = [ file("charts/ccd-data-store-api/values.yaml"), file("charts/ccd-data-store-api/values.preview.template.yaml"), @@ -233,10 +234,13 @@ task verifyBefTaStubHostConfigConsistency { "CCD_CALLBACK_ALLOW_PRIVATE_HOSTS" ] callbackAllowlistKeys.each { key -> - boolean hasKey = content.contains("${key}:") - boolean hasExpectedLiteral = content.contains("${key}: localhost,127.0.0.1,${expectedStubHost}") - if (hasKey && !hasExpectedLiteral) { - issues << "${project.relativePath(valuesFile)}: ${key} missing expected BEFTA stub host" + def matcher = content =~ /(?m)^\s*${java.util.regex.Pattern.quote(key)}:\s*(.+)$/ + boolean hasKey = matcher.find() + String lineValue = hasKey ? matcher.group(1) : "" + boolean hasStubHost = lineValue.contains(expectedStubHost) + boolean hasAacHost = lineValue.contains(expectedAacHost) + if (hasKey && (!hasStubHost || !hasAacHost)) { + issues << "${project.relativePath(valuesFile)}: ${key} missing expected callback allowlist hosts" } } boolean beftaStubBaseUrlValid = diff --git a/charts/ccd-data-store-api/values.aat.template.yaml b/charts/ccd-data-store-api/values.aat.template.yaml index 71610091f7..df74a3377c 100644 --- a/charts/ccd-data-store-api/values.aat.template.yaml +++ b/charts/ccd-data-store-api/values.aat.template.yaml @@ -38,9 +38,9 @@ java: TESTING_SUPPORT_ENABLED: true MIGRATIONS_ENDPOINT_ENABLED: true # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" SPRING_APPLICATION_JSON: | {"ccd":{"decentralised":{"case-type-service-urls":{"FT_Decentralisation":"http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/"}}}} diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index ef9bd50a94..96e8b46726 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -41,9 +41,9 @@ java: LOGGING_LEVEL_UK_GOV_HMCTS_CCD_SECURITY_IDAM: DEBUG LOG_CALLBACK_DETAILS: # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal DEFAULT_CACHE_TTL_SEC: 1 BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" SPRING_APPLICATION_JSON: | diff --git a/charts/ccd-data-store-api/values.yaml b/charts/ccd-data-store-api/values.yaml index a38f6e508e..fb925175a6 100644 --- a/charts/ccd-data-store-api/values.yaml +++ b/charts/ccd-data-store-api/values.yaml @@ -49,9 +49,9 @@ java: # this variable takes a comma separated list of callback urls for which details needs to be logged, or '*' for all LOG_CALLBACK_DETAILS: # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal CCD_DRAFT_TTL_DAYS: 180 TTL_GUARD: 365 From 59cf89701f89d8c479db5cd6a75ba9172adefbc8 Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 5 Mar 2026 17:04:03 +0000 Subject: [PATCH 30/41] Add aac-manage-case-assignment host to callback allowlists and update consistency checks --- README.md | 3 ++- docs/api/security.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba07612a33..0acbc62f3d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ The following environment variables are required: For Helm-based preview/AAT deployments, ensure callback host allowlists include the callback destinations used by your environment in `CCD_CALLBACK_ALLOWED_HOSTS`, `CCD_CALLBACK_ALLOWED_HTTP_HOSTS`, and `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`. -For preview/AAT, include `ccd-test-stubs-service-aat.service.core-compute-aat.internal` in all three settings. +For preview/AAT, include `ccd-test-stubs-service-aat.service.core-compute-aat.internal` and +`aac-manage-case-assignment-aat.service.core-compute-aat.internal` in all three settings. ### Building diff --git a/docs/api/security.md b/docs/api/security.md index db4dbae5f5..e9cded422f 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -93,7 +93,8 @@ After enabling callback hardening, service teams should: - `CCD_CALLBACK_ALLOWED_HOSTS` - `CCD_CALLBACK_ALLOWED_HTTP_HOSTS` (only for explicitly approved `http` hosts) - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` (only for explicitly approved private/local hosts) - - For preview/AAT, include `ccd-test-stubs-service-aat.service.core-compute-aat.internal` in all three allowlists. + - For preview/AAT, include `ccd-test-stubs-service-aat.service.core-compute-aat.internal` and + `aac-manage-case-assignment-aat.service.core-compute-aat.internal` in all three allowlists. 3. Validate callback URLs during definition onboarding/import so invalid URLs are rejected before runtime. 4. Re-run callback integration tests and verify expected callback hosts are accepted. 5. Ensure callback endpoints do not return redirects (`3xx`) and instead return final responses directly. From 3ec77ad3919dd80986ae31356af8b7ddb59986b7 Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 9 Mar 2026 09:33:09 +0000 Subject: [PATCH 31/41] fix yVersions below java-logging: 8.0.0 are deprecated, please upgrade to the latest release This configuration will stop working by 01/06/2026 ( in 84 days ) --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index cb18c4c400..4fd3b43a8b 100644 --- a/build.gradle +++ b/build.gradle @@ -322,7 +322,7 @@ dependencies { implementation group: 'com.github.hmcts', name: 'service-auth-provider-java-client', version: '5.3.3' implementation group: 'com.github.hmcts', name: 'idam-java-client', version: '3.0.5' implementation group: 'com.github.hmcts', name: 'ccd-case-document-am-client', version: '1.59.2' - implementation group: 'com.github.hmcts.java-logging', name: 'logging', version: '6.1.9' + implementation group: 'com.github.hmcts.java-logging', name: 'logging', version: '8.0.0' implementation group: 'com.auth0', name: 'java-jwt', version: '4.5.1' implementation group: 'com.google.guava', name: 'guava', version: '33.5.0-jre' From e9b988f66b08a16c89488d3e4be123c970d173cb Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 9 Mar 2026 10:26:24 +0000 Subject: [PATCH 32/41] CCD-7110 Make callback allowlist checks env-driven and harden callback handling --- README.md | 7 +- build.gradle | 93 +++++++++++++------ docs/api/security.md | 1 + ...cd-7110-callback-ssrf-hardening-summary.md | 45 +++++++++ .../service/callbacks/CallbackService.java | 15 +-- 5 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 docs/ccd-7110-callback-ssrf-hardening-summary.md diff --git a/README.md b/README.md index 0acbc62f3d..14e11fa86d 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,8 @@ The following environment variables are required: | CCD_CALLBACK_ALLOWED_HTTP_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to use `http` for callbacks (all other callback hosts must use `https`). | | CCD_CALLBACK_ALLOW_PRIVATE_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to resolve to private/local addresses for callbacks. | -For Helm-based preview/AAT deployments, ensure callback host allowlists include the callback destinations used by -your environment in `CCD_CALLBACK_ALLOWED_HOSTS`, `CCD_CALLBACK_ALLOWED_HTTP_HOSTS`, and -`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`. -For preview/AAT, include `ccd-test-stubs-service-aat.service.core-compute-aat.internal` and -`aac-manage-case-assignment-aat.service.core-compute-aat.internal` in all three settings. +For callback hardening rollout guidance and environment examples (including preview/AAT allowlist hosts), +see [`docs/api/security.md`](docs/api/security.md). ### Building diff --git a/build.gradle b/build.gradle index 4fd3b43a8b..e6e66c9ef5 100644 --- a/build.gradle +++ b/build.gradle @@ -210,8 +210,9 @@ task verifyBefTaStubHostConfigConsistency { description = "Ensures Helm values keep BEFTA stub host and callback allowlists aligned." doLast { - def expectedStubHost = "ccd-test-stubs-service-aat.service.core-compute-aat.internal" - def expectedAacHost = "aac-manage-case-assignment-aat.service.core-compute-aat.internal" + def env = System.getenv() + def expectedStubHost = env.get("BEFTA_TEST_STUB_SERVICE_HOST") ?: "ccd-test-stubs-service-aat.service.core-compute-aat.internal" + def expectedAacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") def valuesFiles = [ file("charts/ccd-data-store-api/values.yaml"), file("charts/ccd-data-store-api/values.preview.template.yaml"), @@ -238,7 +239,7 @@ task verifyBefTaStubHostConfigConsistency { boolean hasKey = matcher.find() String lineValue = hasKey ? matcher.group(1) : "" boolean hasStubHost = lineValue.contains(expectedStubHost) - boolean hasAacHost = lineValue.contains(expectedAacHost) + boolean hasAacHost = expectedAacHost ? lineValue.contains(expectedAacHost) : true if (hasKey && (!hasStubHost || !hasAacHost)) { issues << "${project.relativePath(valuesFile)}: ${key} missing expected callback allowlist hosts" } @@ -819,6 +820,14 @@ task preSmokeDiagnostics { if (beftaStubBaseUrl) { try { String beftaStubHost = new URL(beftaStubBaseUrl).getHost() + String aacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") + List requiredHosts = [beftaStubHost] + if (aacHost?.trim()) { + requiredHosts.add(aacHost.trim()) + } else { + logger.quiet("Smoke diagnostics: AAC_MANAGE_CASE_ASSIGNMENT_HOST is unset; " + + "enforcing callback allowlist membership only for BEFTA stub host.") + } boolean callbackAllowlistsProvided = callbackAllowedHosts?.trim() && callbackAllowedHttpHosts?.trim() && callbackAllowPrivateHosts?.trim() @@ -826,21 +835,30 @@ task preSmokeDiagnostics { logger.quiet("Smoke diagnostics: callback allowlist env vars are not fully set; " + "skipping strict BEFTA stub host membership check.") } else { - List missing = [] - if (!(callbackAllowedHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { - missing.add("CCD_CALLBACK_ALLOWED_HOSTS") - } - if (!(callbackAllowedHttpHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { - missing.add("CCD_CALLBACK_ALLOWED_HTTP_HOSTS") - } - if (!(callbackAllowPrivateHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { - missing.add("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS") - } - if (!missing.isEmpty()) { - throw new GradleException("Smoke preflight failed: BEFTA stub host '" + beftaStubHost - + "' is missing from " + missing + ".") - } - logger.quiet("Smoke diagnostics: BEFTA stub host '${beftaStubHost}' present in callback allowlists.") + List allowedHostsList = (callbackAllowedHosts ?: "").split(',').collect { it.trim() } + List allowedHttpHostsList = (callbackAllowedHttpHosts ?: "").split(',').collect { it.trim() } + List allowedPrivateHostsList = (callbackAllowPrivateHosts ?: "").split(',').collect { it.trim() } + List missingFromAllowed = requiredHosts.findAll { !allowedHostsList.contains(it) } + List missingFromHttpAllowed = requiredHosts.findAll { !allowedHttpHostsList.contains(it) } + List missingFromPrivateAllowed = requiredHosts.findAll { !allowedPrivateHostsList.contains(it) } + List issues = [] + if (!missingFromAllowed.isEmpty()) { + issues.add("CCD_CALLBACK_ALLOWED_HOSTS missing " + missingFromAllowed) + } + if (!missingFromHttpAllowed.isEmpty()) { + issues.add("CCD_CALLBACK_ALLOWED_HTTP_HOSTS missing " + missingFromHttpAllowed) + } + if (!missingFromPrivateAllowed.isEmpty()) { + issues.add("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS missing " + missingFromPrivateAllowed) + } + if (!issues.isEmpty()) { + throw new GradleException("Smoke preflight failed: callback allowlist drift detected.\n" + + "Required hosts: " + requiredHosts + "\n" + + "Issues: " + issues + "\n" + + "Set CCD_CALLBACK_ALLOWED_HOSTS, CCD_CALLBACK_ALLOWED_HTTP_HOSTS and " + + "CCD_CALLBACK_ALLOW_PRIVATE_HOSTS to include all required hosts.") + } + logger.quiet("Smoke diagnostics: required callback hosts ${requiredHosts} present in callback allowlists.") } } catch (MalformedURLException malformedURLException) { throw new GradleException("Smoke preflight failed: invalid BEFTA_TEST_STUB_SERVICE_BASE_URL: " @@ -890,6 +908,14 @@ task preFunctionalDiagnostics { if (beftaStubBaseUrl) { try { String beftaStubHost = new URL(beftaStubBaseUrl).getHost() + String aacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") + List requiredHosts = [beftaStubHost] + if (aacHost?.trim()) { + requiredHosts.add(aacHost.trim()) + } else { + logger.quiet("Functional diagnostics: AAC_MANAGE_CASE_ASSIGNMENT_HOST is unset; " + + "enforcing callback allowlist membership only for BEFTA stub host.") + } boolean callbackAllowlistsProvided = callbackAllowedHosts?.trim() && callbackAllowedHttpHosts?.trim() && callbackAllowPrivateHosts?.trim() @@ -897,21 +923,30 @@ task preFunctionalDiagnostics { logger.quiet("Functional diagnostics: callback allowlist env vars are not fully set; " + "skipping strict BEFTA stub host membership check.") } else { - List missing = [] - if (!(callbackAllowedHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { - missing.add("CCD_CALLBACK_ALLOWED_HOSTS") + List allowedHostsList = (callbackAllowedHosts ?: "").split(',').collect { it.trim() } + List allowedHttpHostsList = (callbackAllowedHttpHosts ?: "").split(',').collect { it.trim() } + List allowedPrivateHostsList = (callbackAllowPrivateHosts ?: "").split(',').collect { it.trim() } + List missingFromAllowed = requiredHosts.findAll { !allowedHostsList.contains(it) } + List missingFromHttpAllowed = requiredHosts.findAll { !allowedHttpHostsList.contains(it) } + List missingFromPrivateAllowed = requiredHosts.findAll { !allowedPrivateHostsList.contains(it) } + List issues = [] + if (!missingFromAllowed.isEmpty()) { + issues.add("CCD_CALLBACK_ALLOWED_HOSTS missing " + missingFromAllowed) } - if (!(callbackAllowedHttpHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { - missing.add("CCD_CALLBACK_ALLOWED_HTTP_HOSTS") + if (!missingFromHttpAllowed.isEmpty()) { + issues.add("CCD_CALLBACK_ALLOWED_HTTP_HOSTS missing " + missingFromHttpAllowed) } - if (!(callbackAllowPrivateHosts ?: "").split(',').collect { it.trim() }.contains(beftaStubHost)) { - missing.add("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS") + if (!missingFromPrivateAllowed.isEmpty()) { + issues.add("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS missing " + missingFromPrivateAllowed) } - if (!missing.isEmpty()) { - throw new GradleException("Functional preflight failed: BEFTA stub host '" + beftaStubHost - + "' is missing from " + missing + ".") + if (!issues.isEmpty()) { + throw new GradleException("Functional preflight failed: callback allowlist drift detected.\n" + + "Required hosts: " + requiredHosts + "\n" + + "Issues: " + issues + "\n" + + "Set CCD_CALLBACK_ALLOWED_HOSTS, CCD_CALLBACK_ALLOWED_HTTP_HOSTS and " + + "CCD_CALLBACK_ALLOW_PRIVATE_HOSTS to include all required hosts.") } - logger.quiet("Functional diagnostics: BEFTA stub host '${beftaStubHost}' present in callback allowlists.") + logger.quiet("Functional diagnostics: required callback hosts ${requiredHosts} present in callback allowlists.") } } catch (MalformedURLException malformedURLException) { throw new GradleException("Functional preflight failed: invalid BEFTA_TEST_STUB_SERVICE_BASE_URL: " diff --git a/docs/api/security.md b/docs/api/security.md index e9cded422f..df357231f6 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -70,6 +70,7 @@ To get your micro-service authorised, please raise a ticket with CCD. ## Callback security hardening Event callback URLs are validated both at definition ingestion/read-time and again before outbound requests are sent (defense in depth). This is required to reduce SSRF and token leakage risk when callback URLs originate from case definition data. +Note: Wizard page mid-event callback URLs are validated at runtime before invocation (not eagerly at definition read-time). ### Objective diff --git a/docs/ccd-7110-callback-ssrf-hardening-summary.md b/docs/ccd-7110-callback-ssrf-hardening-summary.md new file mode 100644 index 0000000000..16414abe3b --- /dev/null +++ b/docs/ccd-7110-callback-ssrf-hardening-summary.md @@ -0,0 +1,45 @@ +# CCD-7110 Server side request forgery urls + +## What changed +- Strengthened callback allowlist preflight checks in `build.gradle`: + - Validate required callback hosts across all three env vars: + - `CCD_CALLBACK_ALLOWED_HOSTS` + - `CCD_CALLBACK_ALLOWED_HTTP_HOSTS` + - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` + - Required hosts now include: + - `ccd-test-stubs-service-aat.service.core-compute-aat.internal` + - `aac-manage-case-assignment-aat.service.core-compute-aat.internal` + - Improved failure output to show exact missing hosts per env var and remediation guidance. +- Added AAC host in callback allowlists for pipeline and helm values: + - `Jenkinsfile_CNP` + - `Jenkinsfile_nightly` + - `charts/ccd-data-store-api/values.yaml` + - `charts/ccd-data-store-api/values.preview.template.yaml` + - `charts/ccd-data-store-api/values.aat.template.yaml` +- Callback service hardening/cleanup in `CallbackService.java`: + - Null-safe `Client-Context` header rewrite (prevents edge-case NPE). + - Generic typing cleanup for `HttpEntity`/`ResponseEntity` (Sonar/type-safety). +- Docs alignment: + - `README.md` + - `docs/api/security.md` + +## Why +- F-051 failures were traced to callback allowlist drift (`aac-manage-case-assignment-aat...` not allowlisted), not auth failure. +- Deterministic fail-fast diagnostics and consistent env wiring are needed to prevent pipeline/runtime drift. +- `CallbackService` fix removes a defensive gap and addresses maintainability warnings without behavior change. + +## Impact +- No intended behavior change for successful paths. +- Better preflight error messages and earlier failure when required hosts are missing. +- Prevents potential callback-path NPE under misconfiguration. + +## Testing +- `./gradlew -q verifyBefTaStubHostConfigConsistency` passed. +- `./gradlew testUnit --tests '*CallbackServiceTest' --tests '*CallbackUrlValidatorTest'` passed. +- Manual preflight checks verified: + - unset allowlists -> skip strict check + - missing AAC host -> clear drift failure message with exact missing vars/hosts + +## Risk / rollback +- Low risk (config + validation + defensive null check). +- Rollback: revert this PR; previous behavior restored. diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java index cf54897dfd..7ed057167d 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java @@ -161,7 +161,7 @@ private Optional> sendRequest(final String url, httpHeaders.add("Content-Type", "application/json"); httpHeaders.add(SecurityUtils.SERVICE_AUTHORIZATION, securityUtils.getServiceAuthorization()); addPassThroughHeaders(httpHeaders); - final HttpEntity requestEntity = new HttpEntity(callbackRequest, httpHeaders); + final HttpEntity requestEntity = new HttpEntity<>(callbackRequest, httpHeaders); final boolean shouldLogCallbackDetails = LOG.isInfoEnabled() && logCallbackDetails(url); if (shouldLogCallbackDetails) { String requestDetails = printCallbackDetails(requestEntity); @@ -259,8 +259,8 @@ private void addPassThruContextValuesToHttpHeaders(HttpHeaders httpHeaders, Stri } } - private void storePassThroughHeadersAsRequestAttributes(ResponseEntity responseEntity, - HttpEntity requestEntity, + private void storePassThroughHeadersAsRequestAttributes(ResponseEntity responseEntity, + HttpEntity requestEntity, HttpServletRequest request) { HttpHeaders httpHeaders = responseEntity.getHeaders(); if (null != request && null != applicationParams @@ -281,12 +281,13 @@ private void storePassThroughHeadersAsRequestAttributes(ResponseEntity responseE } } - private ResponseEntity replaceResponseEntityWithUpdatedHeaders(final ResponseEntity responseEntity, - final String headerName) { + private ResponseEntity replaceResponseEntityWithUpdatedHeaders(final ResponseEntity responseEntity, + final String headerName) { HttpHeaders headers = responseEntity.getHeaders(); - if (headers != null && headers.get(headerName) != null) { + Object requestHeaderValue = request.getAttribute(CLIENT_CONTEXT); + if (headers != null && headers.get(headerName) != null && requestHeaderValue != null) { HttpHeaders newHeaders = ClientContextUtil.replaceHeader(headers, CLIENT_CONTEXT, - request.getAttribute(CLIENT_CONTEXT).toString()); + requestHeaderValue.toString()); return new ResponseEntity<>(responseEntity.getBody(), newHeaders, responseEntity.getStatusCode()); } else { return responseEntity; From b0c7e26f48f866790b268e8ecc9f79b0d7a2ff56 Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 9 Mar 2026 10:38:51 +0000 Subject: [PATCH 33/41] CCD-7110 Harden callback SSRF controls, env-driven allowlist checks, and docs cleanup --- docs/api/security.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/api/security.md b/docs/api/security.md index df357231f6..b3804adc51 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -85,6 +85,17 @@ Prevent untrusted callback destinations from being invoked and prevent sensitive - Callback pass-through headers use strict allowlist semantics (only `Client-Context` is forwarded). - Callback detail logging redacts sensitive values (for example auth/token/password/secret fields and bearer tokens). +### Why all three callback allowlists are required + +These three settings enforce different controls and are all required for internal callback destinations: + +- `CCD_CALLBACK_ALLOWED_HOSTS`: destination host allowlist (where callbacks may go). +- `CCD_CALLBACK_ALLOWED_HTTP_HOSTS`: explicit exceptions for hosts that may use `http` (all others must use `https`). +- `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`: explicit exceptions for hosts that resolve to private/local/internal addresses. + +For internal service hosts used in AAT/preview, a callback can be blocked if any one of these is missing, +even when the host appears in the other two lists. + ### Service rollout checklist After enabling callback hardening, service teams should: From d50e715593c09f529c5d65dcd31a8ce1ab6b11fa Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 9 Mar 2026 12:08:55 +0000 Subject: [PATCH 34/41] CCD-7110 Fix null request handling in CallbackService and harden callback allowlist preflight checks --- .../gov/hmcts/ccd/domain/service/callbacks/CallbackService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java index 7ed057167d..7048b513ee 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackService.java @@ -284,7 +284,7 @@ private void storePassThroughHeadersAsRequestAttributes(ResponseEntity respon private ResponseEntity replaceResponseEntityWithUpdatedHeaders(final ResponseEntity responseEntity, final String headerName) { HttpHeaders headers = responseEntity.getHeaders(); - Object requestHeaderValue = request.getAttribute(CLIENT_CONTEXT); + Object requestHeaderValue = request != null ? request.getAttribute(CLIENT_CONTEXT) : null; if (headers != null && headers.get(headerName) != null && requestHeaderValue != null) { HttpHeaders newHeaders = ClientContextUtil.replaceHeader(headers, CLIENT_CONTEXT, requestHeaderValue.toString()); From 29666990e86698da7fab18807f55975bd8414100 Mon Sep 17 00:00:00 2001 From: patelila Date: Fri, 20 Mar 2026 13:01:53 +0000 Subject: [PATCH 35/41] Move repo skills under docs and drop unused skill metadata --- AGENTS.md | 4 +- README.md | 2 + ...cd-7110-callback-ssrf-hardening-summary.md | 45 ------------------- docs/integration.md | 12 +++++ .../ccd-callback-ssrf-hardening/SKILL.md | 0 .../references/callback-hotspots.md | 0 .../scripts/scan_callback_risks.sh | 0 .../ccd-sonarqube-remediation/SKILL.md | 0 .../agents/openai.yaml | 4 -- .../agents/openai.yaml | 4 -- 10 files changed, 17 insertions(+), 54 deletions(-) delete mode 100644 docs/ccd-7110-callback-ssrf-hardening-summary.md rename {skills => docs/skills}/ccd-callback-ssrf-hardening/SKILL.md (100%) rename {skills => docs/skills}/ccd-callback-ssrf-hardening/references/callback-hotspots.md (100%) rename {skills => docs/skills}/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh (100%) rename {skills => docs/skills}/ccd-sonarqube-remediation/SKILL.md (100%) delete mode 100644 skills/ccd-callback-ssrf-hardening/agents/openai.yaml delete mode 100644 skills/ccd-sonarqube-remediation/agents/openai.yaml diff --git a/AGENTS.md b/AGENTS.md index 5be61fb3b3..2b6b2767d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # Agents +This file is the index for repo-local workflow skills in this repository. Skill files live under `docs/skills/`. + ## CCD Callback SSRF Hardening Use the `ccd-callback-ssrf-hardening` agent for any callback security change in this repository, especially around event callbacks, webhook URL ingestion, or auth header handling. @@ -29,7 +31,7 @@ Tasks: ### Quick Scanner ```bash -bash skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh +bash docs/skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh ``` ## CCD SonarQube Remediation diff --git a/README.md b/README.md index 14e11fa86d..fa4d485386 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Store/search cases and provide workbaskets. +Repo-local workflow docs are indexed in `AGENTS.md`. + ### Prerequisites - [Open JDK 21](https://openjdk.java.net/) diff --git a/docs/ccd-7110-callback-ssrf-hardening-summary.md b/docs/ccd-7110-callback-ssrf-hardening-summary.md deleted file mode 100644 index 16414abe3b..0000000000 --- a/docs/ccd-7110-callback-ssrf-hardening-summary.md +++ /dev/null @@ -1,45 +0,0 @@ -# CCD-7110 Server side request forgery urls - -## What changed -- Strengthened callback allowlist preflight checks in `build.gradle`: - - Validate required callback hosts across all three env vars: - - `CCD_CALLBACK_ALLOWED_HOSTS` - - `CCD_CALLBACK_ALLOWED_HTTP_HOSTS` - - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` - - Required hosts now include: - - `ccd-test-stubs-service-aat.service.core-compute-aat.internal` - - `aac-manage-case-assignment-aat.service.core-compute-aat.internal` - - Improved failure output to show exact missing hosts per env var and remediation guidance. -- Added AAC host in callback allowlists for pipeline and helm values: - - `Jenkinsfile_CNP` - - `Jenkinsfile_nightly` - - `charts/ccd-data-store-api/values.yaml` - - `charts/ccd-data-store-api/values.preview.template.yaml` - - `charts/ccd-data-store-api/values.aat.template.yaml` -- Callback service hardening/cleanup in `CallbackService.java`: - - Null-safe `Client-Context` header rewrite (prevents edge-case NPE). - - Generic typing cleanup for `HttpEntity`/`ResponseEntity` (Sonar/type-safety). -- Docs alignment: - - `README.md` - - `docs/api/security.md` - -## Why -- F-051 failures were traced to callback allowlist drift (`aac-manage-case-assignment-aat...` not allowlisted), not auth failure. -- Deterministic fail-fast diagnostics and consistent env wiring are needed to prevent pipeline/runtime drift. -- `CallbackService` fix removes a defensive gap and addresses maintainability warnings without behavior change. - -## Impact -- No intended behavior change for successful paths. -- Better preflight error messages and earlier failure when required hosts are missing. -- Prevents potential callback-path NPE under misconfiguration. - -## Testing -- `./gradlew -q verifyBefTaStubHostConfigConsistency` passed. -- `./gradlew testUnit --tests '*CallbackServiceTest' --tests '*CallbackUrlValidatorTest'` passed. -- Manual preflight checks verified: - - unset allowlists -> skip strict check - - missing AAC host -> clear drift failure message with exact missing vars/hosts - -## Risk / rollback -- Low risk (config + validation + defensive null check). -- Rollback: revert this PR; previous behavior restored. diff --git a/docs/integration.md b/docs/integration.md index 0b9214b0e5..f964f637c9 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -22,6 +22,18 @@ callback URL validation in the import path. For local BEFTA/AAT runs, set `TEST_STUB_SERVICE_BASE_URL` before importing definitions; otherwise placeholders can fall back to AAT defaults and later fail callback host allowlist checks. +## Callback Allowlist Notes + +Callback preflight validation checks the callback allowlist configuration before BEFTA and related setup work runs. + +Required AAT callback hosts currently include: + +- `ccd-test-stubs-service-aat.service.core-compute-aat.internal` +- `aac-manage-case-assignment-aat.service.core-compute-aat.internal` + +If callback allowlist values drift across Jenkins or Helm config, preflight validation should fail early with the +missing hosts called out explicitly rather than allowing later callback failures. + Example: ```bash diff --git a/skills/ccd-callback-ssrf-hardening/SKILL.md b/docs/skills/ccd-callback-ssrf-hardening/SKILL.md similarity index 100% rename from skills/ccd-callback-ssrf-hardening/SKILL.md rename to docs/skills/ccd-callback-ssrf-hardening/SKILL.md diff --git a/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md b/docs/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md similarity index 100% rename from skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md rename to docs/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md diff --git a/skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh b/docs/skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh similarity index 100% rename from skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh rename to docs/skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh diff --git a/skills/ccd-sonarqube-remediation/SKILL.md b/docs/skills/ccd-sonarqube-remediation/SKILL.md similarity index 100% rename from skills/ccd-sonarqube-remediation/SKILL.md rename to docs/skills/ccd-sonarqube-remediation/SKILL.md diff --git a/skills/ccd-callback-ssrf-hardening/agents/openai.yaml b/skills/ccd-callback-ssrf-hardening/agents/openai.yaml deleted file mode 100644 index 3f589b432d..0000000000 --- a/skills/ccd-callback-ssrf-hardening/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "CCD Callback SSRF Hardening" - short_description: "Secure CCD callbacks against SSRF and token leakage" - default_prompt: "Use ccd-callback-ssrf-hardening to audit and fix CCD event callback risks in hmcts/ccd-data-store-api: enforce callback URL validation in callback execution paths (host allowlist, HTTPS policy, private/local target blocking), prevent forwarding of Authorization/ServiceAuthorization/user-id/user-roles headers, update/add regression tests for hardened behavior, and ensure deployment/docs are updated for callback security settings (CCD_CALLBACK_ALLOWED_HOSTS, CCD_CALLBACK_ALLOWED_HTTP_HOSTS, CCD_CALLBACK_ALLOW_PRIVATE_HOSTS)." diff --git a/skills/ccd-sonarqube-remediation/agents/openai.yaml b/skills/ccd-sonarqube-remediation/agents/openai.yaml deleted file mode 100644 index a018057065..0000000000 --- a/skills/ccd-sonarqube-remediation/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "CCD SonarQube Remediation" - short_description: "Fix Sonar findings with safe, test-backed patches" - default_prompt: "Use ccd-sonarqube-remediation to triage and fix current SonarQube findings in hmcts/ccd-data-store-api: identify root cause per finding, apply minimal-risk refactors, update Spring bean qualifiers/tests/fixtures as needed, ensure tests cover new/changed code at >=80%, run targeted compile/tests plus checkstyle, and summarize behavior impact plus residual risks." From 96279c011ed712bc78d9e540540daa3f271e419e Mon Sep 17 00:00:00 2001 From: patelila Date: Fri, 20 Mar 2026 14:40:38 +0000 Subject: [PATCH 36/41] Migrate deprecated ACR references to hmctsprod --- Dockerfile | 2 +- Jenkinsfile_CNP | 4 ++-- Jenkinsfile_nightly | 4 ++-- acb.tpl.yaml | 4 ++-- charts/ccd-data-store-api/Chart.yaml | 4 ++-- charts/ccd-data-store-api/values.preview.template.yaml | 8 ++++---- charts/ccd-data-store-api/values.yaml | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6fc9cab769..502fb7f05a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG JAVA_OPTS="-Djava.security.egd=file:/dev/./urandom" ARG APP_INSIGHTS_AGENT_VERSION=3.7.7 ARG PLATFORM="" -FROM hmctspublic.azurecr.io/base/java${PLATFORM}:21-distroless +FROM hmctsprod.azurecr.io/base/java${PLATFORM}:21-distroless USER hmcts LABEL maintainer="https://github.com/hmcts/ccd-data-store-api" diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 24063701c0..ded461fae9 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -117,8 +117,8 @@ env.BEFTA_RETRY_MAX_ATTEMPTS = "3" env.BEFTA_RETRY_STATUS_CODES = "500,502,503,504" env.BEFTA_RETRY_MAX_DELAY = "1000" env.BEFTA_RETRY_NON_RETRYABLE_HTTP_METHODS = "POST,PUT" -// Prevent Docker hub rate limit errors by ensuring that testcontainers uses images from hmctspublic ACR -env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctspublic.azurecr.io/imported/" +// Prevent Docker hub rate limit errors by ensuring that testcontainers uses images from hmctsprod ACR +env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctsprod.azurecr.io/imported/" withPipeline(type, product, component) { onMaster { diff --git a/Jenkinsfile_nightly b/Jenkinsfile_nightly index 236151dbd5..b583a04429 100644 --- a/Jenkinsfile_nightly +++ b/Jenkinsfile_nightly @@ -81,8 +81,8 @@ def vaultOverrides = [ // vars needed for functional tests // Assume a feature build branched off 'develop', with dependencies develop-to-develop. env.TEST_URL = "http://ccd-data-store-api-aat.service.core-compute-aat.internal" -// Prevent Docker hub rate limit errors by ensuring that testcontainers uses images from hmctspublic ACR -env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctspublic.azurecr.io/imported/" +// Prevent Docker hub rate limit errors by ensuring that testcontainers uses images from hmctsprod ACR +env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctsprod.azurecr.io/imported/" // Other env variables needed for BEFTA. env.BEFTA_S2S_CLIENT_ID = "ccd_gw" diff --git a/acb.tpl.yaml b/acb.tpl.yaml index 730b380a3d..2f4555af09 100644 --- a/acb.tpl.yaml +++ b/acb.tpl.yaml @@ -1,7 +1,7 @@ version: 1.0-preview-1 steps: - id: pull-base-image-amd64 - cmd: docker pull --platform linux/amd64 hmctspublic.azurecr.io/base/java:21-distroless && docker tag hmctspublic.azurecr.io/base/java:21-distroless hmctspublic.azurecr.io/base/java/linux/amd64:21-distroless + cmd: docker pull --platform linux/amd64 hmctsprod.azurecr.io/base/java:21-distroless && docker tag hmctsprod.azurecr.io/base/java:21-distroless hmctsprod.azurecr.io/base/java/linux/amd64:21-distroless when: ["-"] retries: 3 retryDelay: 5 @@ -18,7 +18,7 @@ steps: retryDelay: 5 - id: pull-base-image-arm64 - cmd: docker pull --platform linux/arm64 hmctspublic.azurecr.io/base/java:21-distroless && docker tag hmctspublic.azurecr.io/base/java:21-distroless hmctspublic.azurecr.io/base/java/linux/arm64:21-distroless + cmd: docker pull --platform linux/arm64 hmctsprod.azurecr.io/base/java:21-distroless && docker tag hmctsprod.azurecr.io/base/java:21-distroless hmctsprod.azurecr.io/base/java/linux/arm64:21-distroless when: - pull-base-image-amd64 retries: 3 diff --git a/charts/ccd-data-store-api/Chart.yaml b/charts/ccd-data-store-api/Chart.yaml index ddb96abdc3..69dc755e86 100644 --- a/charts/ccd-data-store-api/Chart.yaml +++ b/charts/ccd-data-store-api/Chart.yaml @@ -9,7 +9,7 @@ maintainers: dependencies: - name: java version: 5.3.0 - repository: 'oci://hmctspublic.azurecr.io/helm' + repository: 'oci://hmctsprod.azurecr.io/helm' - name: elasticsearch version: 7.17.3 repository: 'https://helm.elastic.co' @@ -20,5 +20,5 @@ dependencies: condition: elastic.enabled - name: ccd version: 9.2.2 - repository: 'oci://hmctspublic.azurecr.io/helm' + repository: 'oci://hmctsprod.azurecr.io/helm' condition: ccd.enabled diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index 96e8b46726..57e64ee908 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -96,7 +96,7 @@ ccd: ccd-definition-store-api: java: ingressHost: ccd-definition-store-${SERVICE_FQDN} - image: hmctspublic.azurecr.io/ccd/definition-store-api:latest + image: hmctsprod.azurecr.io/ccd/definition-store-api:latest imagePullPolicy: Always devmemoryRequests: 2048Mi devcpuRequests: 2000m @@ -120,7 +120,7 @@ ccd: ccd-user-profile-api: java: ingressHost: ccd-user-profile-api-${SERVICE_FQDN} - image: hmctspublic.azurecr.io/ccd/user-profile-api:latest + image: hmctsprod.azurecr.io/ccd/user-profile-api:latest imagePullPolicy: Always environment: USER_PROFILE_DB_HOST: "{{ .Release.Name }}-postgresql" @@ -182,13 +182,13 @@ elasticsearch: # paths: # - path: / logstash: - image: "hmctspublic.azurecr.io/imported/logstash/logstash" + image: "hmctsprod.azurecr.io/imported/logstash/logstash" imageTag: "7.16.1" imagePullPolicy: "IfNotPresent" logstashJavaOpts: "-Xmx1g -Xms512M" extraInitContainers: | - name: download-postgres-jdbc - image: hmctspublic.azurecr.io/curl:7.70.0 + image: hmctsprod.azurecr.io/curl:7.70.0 command: ['curl', '-L', 'https://jdbc.postgresql.org/download/postgresql-42.2.18.jar', '-o', '/logstash-lib/postgresql.jar'] volumeMounts: - name: logstash-lib diff --git a/charts/ccd-data-store-api/values.yaml b/charts/ccd-data-store-api/values.yaml index fb925175a6..f0ad6f5bcc 100644 --- a/charts/ccd-data-store-api/values.yaml +++ b/charts/ccd-data-store-api/values.yaml @@ -4,7 +4,7 @@ ccd: enabled: false java: - image: 'hmctspublic.azurecr.io/ccd/data-store-api:latest' + image: 'hmctsprod.azurecr.io/ccd/data-store-api:latest' ingressHost: ccd-data-store-api-{{ .Values.global.environment }}.service.core-compute-{{ .Values.global.environment }}.internal applicationPort: 4452 aadIdentityName: ccd From 5d9600ecf275ef90b7bbc7d00989f549b81a562b Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 25 Mar 2026 12:15:42 +0000 Subject: [PATCH 37/41] Switch callback allowlists to regex-capable matching and share preflight validation logic --- README.md | 6 +- build.gradle | 156 +++++++++--------- .../values.preview.template.yaml | 6 +- docs/api/security.md | 29 +++- docs/integration.md | 6 + .../references/callback-hotspots.md | 2 +- .../callbacks/CallbackUrlValidator.java | 25 +-- src/main/resources/application.properties | 3 + .../callbacks/CallbackUrlValidatorTest.java | 16 +- 9 files changed, 135 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index fa4d485386..8b6bb434cc 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ The following environment variables are required: | DRAFT_STORE_URL | - | Base URL for Draft Store API service. `http://localhost:8800` for the dockerised local instance. | | DRAFT_ENCRYPTION_KEY | - | Draft encryption key. The encryption key used by draft store to encrypt documents with. | | DRAFT_TTL_DAYS | - | Number of days after which the saved draft will be deleted if unmodified. | -| CCD_CALLBACK_ALLOWED_HOSTS | localhost,127.0.0.1 | Comma-separated callback destination host allowlist (`*` and `*.domain.tld` supported). Use environment-specific callback hosts (for example local Docker: `host.docker.internal`; AAT/pipeline: internal service DNS). | -| CCD_CALLBACK_ALLOWED_HTTP_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to use `http` for callbacks (all other callback hosts must use `https`). | -| CCD_CALLBACK_ALLOW_PRIVATE_HOSTS | localhost,127.0.0.1 | Comma-separated hosts allowed to resolve to private/local addresses for callbacks. | +| CCD_CALLBACK_ALLOWED_HOSTS | localhost,127.0.0.1 | Comma-separated callback destination match patterns. Exact hosts, legacy `*.domain.tld`, `*`, and regex patterns are supported. Invalid regex-like entries fail validation explicitly. Use environment-specific callback hosts or patterns (for example local Docker: `host.docker.internal`; preview/demo PR domains: `.*\\.demo\\.platform\\.hmcts\\.net,.*\\.preview\\.platform\\.hmcts\\.net`; AAT/pipeline: internal service DNS). | +| CCD_CALLBACK_ALLOWED_HTTP_HOSTS | localhost,127.0.0.1 | Comma-separated host match patterns allowed to use `http` for callbacks. Exact hosts, legacy `*.domain.tld`, `*`, and regex patterns are supported; invalid regex-like entries fail validation explicitly; all other callback hosts must use `https`. | +| CCD_CALLBACK_ALLOW_PRIVATE_HOSTS | localhost,127.0.0.1 | Comma-separated host match patterns allowed to resolve to private/local addresses for callbacks. Exact hosts, legacy `*.domain.tld`, `*`, and regex patterns are supported; invalid regex-like entries fail validation explicitly. | For callback hardening rollout guidance and environment examples (including preview/AAT allowlist hosts), see [`docs/api/security.md`](docs/api/security.md). diff --git a/build.gradle b/build.gradle index 4735833a78..f4ac124864 100644 --- a/build.gradle +++ b/build.gradle @@ -102,6 +102,12 @@ application { } sourceSets { + main { + java { + srcDir('buildSrc/src/main/java') + } + } + aat { java { srcDir('src/aat/java') @@ -227,49 +233,63 @@ task verifyBefTaStubHostConfigConsistency { doLast { def env = System.getenv() - def expectedStubHost = env.get("BEFTA_TEST_STUB_SERVICE_HOST") ?: "ccd-test-stubs-service-aat.service.core-compute-aat.internal" - def expectedAacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") - def valuesFiles = [ - file("charts/ccd-data-store-api/values.yaml"), - file("charts/ccd-data-store-api/values.preview.template.yaml"), - file("charts/ccd-data-store-api/values.aat.template.yaml") - ] - def issues = [] - - valuesFiles.each { valuesFile -> - if (!valuesFile.exists()) { - return - } - def content = valuesFile.getText("UTF-8") - boolean containsExpectedHostLiteral = content.contains(expectedStubHost) - if (!containsExpectedHostLiteral) { - issues << "${project.relativePath(valuesFile)}: missing BEFTA stub host definition" - } - def callbackAllowlistKeys = [ - "CCD_CALLBACK_ALLOWED_HOSTS", - "CCD_CALLBACK_ALLOWED_HTTP_HOSTS", - "CCD_CALLBACK_ALLOW_PRIVATE_HOSTS" + try { + def expectedStubHost = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.resolveStubHost( + env.get("BEFTA_TEST_STUB_SERVICE_BASE_URL"), + env.get("BEFTA_TEST_STUB_SERVICE_HOST"), + "ccd-test-stubs-service-aat.service.core-compute-aat.internal") + def expectedAacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") + def valuesFiles = [ + file("charts/ccd-data-store-api/values.yaml"), + file("charts/ccd-data-store-api/values.preview.template.yaml"), + file("charts/ccd-data-store-api/values.aat.template.yaml") ] - callbackAllowlistKeys.each { key -> - def matcher = content =~ /(?m)^\s*${java.util.regex.Pattern.quote(key)}:\s*(.+)$/ - boolean hasKey = matcher.find() - String lineValue = hasKey ? matcher.group(1) : "" - boolean hasStubHost = lineValue.contains(expectedStubHost) - boolean hasAacHost = expectedAacHost ? lineValue.contains(expectedAacHost) : true - if (hasKey && (!hasStubHost || !hasAacHost)) { - issues << "${project.relativePath(valuesFile)}: ${key} missing expected callback allowlist hosts" + def issues = [] + + valuesFiles.each { valuesFile -> + if (!valuesFile.exists()) { + return + } + def content = valuesFile.getText("UTF-8") + def beftaStubBaseUrlMatcher = content =~ /(?m)^\s*BEFTA_TEST_STUB_SERVICE_BASE_URL:\s*(.+)\s*$/ + boolean hasBeftaStubBaseUrl = beftaStubBaseUrlMatcher.find() + if (hasBeftaStubBaseUrl) { + String configuredBeftaStubBaseUrl = beftaStubBaseUrlMatcher.group(1) + String configuredBeftaStubHost = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.parseUrlHost( + configuredBeftaStubBaseUrl) + boolean beftaStubHostMatchesExpected = + uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher.matches(expectedStubHost, configuredBeftaStubHost) + if (!beftaStubHostMatchesExpected) { + issues << "${project.relativePath(valuesFile)}: BEFTA_TEST_STUB_SERVICE_BASE_URL host does not match expected stub host" + } + } + def callbackAllowlistKeys = [ + "CCD_CALLBACK_ALLOWED_HOSTS", + "CCD_CALLBACK_ALLOWED_HTTP_HOSTS", + "CCD_CALLBACK_ALLOW_PRIVATE_HOSTS" + ] + callbackAllowlistKeys.each { key -> + def matcher = content =~ /(?m)^\s*${java.util.regex.Pattern.quote(key)}:\s*(.+)$/ + boolean hasKey = matcher.find() + String lineValue = hasKey ? matcher.group(1) : "" + boolean hasStubHost = uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher.containsHost( + expectedStubHost, lineValue) + boolean hasAacHost = expectedAacHost + ? uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher.containsHost(expectedAacHost, lineValue) + : true + if (hasKey && (!hasStubHost || !hasAacHost)) { + issues << "${project.relativePath(valuesFile)}: ${key} missing expected callback allowlist hosts" + } } } - boolean beftaStubBaseUrlValid = - content.contains("BEFTA_TEST_STUB_SERVICE_BASE_URL: \"http://${expectedStubHost}/\"") - if (content.contains("BEFTA_TEST_STUB_SERVICE_BASE_URL:") - && !beftaStubBaseUrlValid) { - issues << "${project.relativePath(valuesFile)}: BEFTA_TEST_STUB_SERVICE_BASE_URL not set to expected stub host" - } - } - if (!issues.isEmpty()) { - throw new GradleException("BEFTA callback/stub host config drift detected:\n - " + issues.join("\n - ")) + if (!issues.isEmpty()) { + throw new GradleException("BEFTA callback/stub host config drift detected:\n - " + issues.join("\n - ")) + } + } catch (MalformedURLException malformedURLException) { + throw new GradleException("BEFTA callback/stub host config drift detected: invalid " + + "BEFTA_TEST_STUB_SERVICE_BASE_URL: " + env.get("BEFTA_TEST_STUB_SERVICE_BASE_URL"), + malformedURLException) } } } @@ -835,12 +855,10 @@ task preSmokeDiagnostics { if (beftaStubBaseUrl) { try { - String beftaStubHost = new URL(beftaStubBaseUrl).getHost() String aacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") - List requiredHosts = [beftaStubHost] - if (aacHost?.trim()) { - requiredHosts.add(aacHost.trim()) - } else { + List requiredHosts = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.requiredHosts( + beftaStubBaseUrl, aacHost) + if (!aacHost?.trim()) { logger.quiet("Smoke diagnostics: AAC_MANAGE_CASE_ASSIGNMENT_HOST is unset; " + "enforcing callback allowlist membership only for BEFTA stub host.") } @@ -851,22 +869,11 @@ task preSmokeDiagnostics { logger.quiet("Smoke diagnostics: callback allowlist env vars are not fully set; " + "skipping strict BEFTA stub host membership check.") } else { - List allowedHostsList = (callbackAllowedHosts ?: "").split(',').collect { it.trim() } - List allowedHttpHostsList = (callbackAllowedHttpHosts ?: "").split(',').collect { it.trim() } - List allowedPrivateHostsList = (callbackAllowPrivateHosts ?: "").split(',').collect { it.trim() } - List missingFromAllowed = requiredHosts.findAll { !allowedHostsList.contains(it) } - List missingFromHttpAllowed = requiredHosts.findAll { !allowedHttpHostsList.contains(it) } - List missingFromPrivateAllowed = requiredHosts.findAll { !allowedPrivateHostsList.contains(it) } - List issues = [] - if (!missingFromAllowed.isEmpty()) { - issues.add("CCD_CALLBACK_ALLOWED_HOSTS missing " + missingFromAllowed) - } - if (!missingFromHttpAllowed.isEmpty()) { - issues.add("CCD_CALLBACK_ALLOWED_HTTP_HOSTS missing " + missingFromHttpAllowed) - } - if (!missingFromPrivateAllowed.isEmpty()) { - issues.add("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS missing " + missingFromPrivateAllowed) - } + List issues = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.findAllowlistIssues( + requiredHosts, + callbackAllowedHosts ?: "", + callbackAllowedHttpHosts ?: "", + callbackAllowPrivateHosts ?: "") if (!issues.isEmpty()) { throw new GradleException("Smoke preflight failed: callback allowlist drift detected.\n" + "Required hosts: " + requiredHosts + "\n" @@ -923,12 +930,10 @@ task preFunctionalDiagnostics { if (beftaStubBaseUrl) { try { - String beftaStubHost = new URL(beftaStubBaseUrl).getHost() String aacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") - List requiredHosts = [beftaStubHost] - if (aacHost?.trim()) { - requiredHosts.add(aacHost.trim()) - } else { + List requiredHosts = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.requiredHosts( + beftaStubBaseUrl, aacHost) + if (!aacHost?.trim()) { logger.quiet("Functional diagnostics: AAC_MANAGE_CASE_ASSIGNMENT_HOST is unset; " + "enforcing callback allowlist membership only for BEFTA stub host.") } @@ -939,22 +944,11 @@ task preFunctionalDiagnostics { logger.quiet("Functional diagnostics: callback allowlist env vars are not fully set; " + "skipping strict BEFTA stub host membership check.") } else { - List allowedHostsList = (callbackAllowedHosts ?: "").split(',').collect { it.trim() } - List allowedHttpHostsList = (callbackAllowedHttpHosts ?: "").split(',').collect { it.trim() } - List allowedPrivateHostsList = (callbackAllowPrivateHosts ?: "").split(',').collect { it.trim() } - List missingFromAllowed = requiredHosts.findAll { !allowedHostsList.contains(it) } - List missingFromHttpAllowed = requiredHosts.findAll { !allowedHttpHostsList.contains(it) } - List missingFromPrivateAllowed = requiredHosts.findAll { !allowedPrivateHostsList.contains(it) } - List issues = [] - if (!missingFromAllowed.isEmpty()) { - issues.add("CCD_CALLBACK_ALLOWED_HOSTS missing " + missingFromAllowed) - } - if (!missingFromHttpAllowed.isEmpty()) { - issues.add("CCD_CALLBACK_ALLOWED_HTTP_HOSTS missing " + missingFromHttpAllowed) - } - if (!missingFromPrivateAllowed.isEmpty()) { - issues.add("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS missing " + missingFromPrivateAllowed) - } + List issues = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.findAllowlistIssues( + requiredHosts, + callbackAllowedHosts ?: "", + callbackAllowedHttpHosts ?: "", + callbackAllowPrivateHosts ?: "") if (!issues.isEmpty()) { throw new GradleException("Functional preflight failed: callback allowlist drift detected.\n" + "Required hosts: " + requiredHosts + "\n" diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index 57e64ee908..f646072fde 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -41,9 +41,9 @@ java: LOGGING_LEVEL_UK_GOV_HMCTS_CCD_SECURITY_IDAM: DEBUG LOG_CALLBACK_DETAILS: # callback hardening: destination allowlists used by CallbackService URL validation - CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal - CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal - CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal + CCD_CALLBACK_ALLOWED_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal,.*\.demo\.platform\.hmcts\.net,.*\.preview\.platform\.hmcts\.net + CCD_CALLBACK_ALLOWED_HTTP_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal,.*\.demo\.platform\.hmcts\.net,.*\.preview\.platform\.hmcts\.net + CCD_CALLBACK_ALLOW_PRIVATE_HOSTS: localhost,127.0.0.1,ccd-test-stubs-service-aat.service.core-compute-aat.internal,aac-manage-case-assignment-aat.service.core-compute-aat.internal,.*\.demo\.platform\.hmcts\.net,.*\.preview\.platform\.hmcts\.net DEFAULT_CACHE_TTL_SEC: 1 BEFTA_TEST_STUB_SERVICE_BASE_URL: "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/" SPRING_APPLICATION_JSON: | diff --git a/docs/api/security.md b/docs/api/security.md index b3804adc51..f07c880c77 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -76,9 +76,9 @@ Note: Wizard page mid-event callback URLs are validated at runtime before invoca Prevent untrusted callback destinations from being invoked and prevent sensitive credential/context headers from being leaked during callback execution. -- Callback hosts must be allowlisted (`CCD_CALLBACK_ALLOWED_HOSTS`). -- Callback URLs must use `https` unless the host is explicitly approved for `http` (`CCD_CALLBACK_ALLOWED_HTTP_HOSTS`). -- Callback hosts that resolve to local/private ranges are blocked unless explicitly approved (`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`). +- Callback hosts must match the configured allowlist patterns (`CCD_CALLBACK_ALLOWED_HOSTS`). +- Callback URLs must use `https` unless the host matches an explicitly approved `http` allowlist pattern (`CCD_CALLBACK_ALLOWED_HTTP_HOSTS`). +- Callback hosts that resolve to local/private ranges are blocked unless they match an explicitly approved private-host allowlist pattern (`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`). - Cloud instance metadata endpoint targets are explicitly blocked (for example `169.254.169.254`). - Callback URLs with embedded credentials are rejected (`https://user:pass@host/...`). - Callback redirects are rejected (`3xx` callback responses are not followed). @@ -89,9 +89,23 @@ Prevent untrusted callback destinations from being invoked and prevent sensitive These three settings enforce different controls and are all required for internal callback destinations: -- `CCD_CALLBACK_ALLOWED_HOSTS`: destination host allowlist (where callbacks may go). -- `CCD_CALLBACK_ALLOWED_HTTP_HOSTS`: explicit exceptions for hosts that may use `http` (all others must use `https`). -- `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`: explicit exceptions for hosts that resolve to private/local/internal addresses. +- `CCD_CALLBACK_ALLOWED_HOSTS`: destination host allowlist patterns (where callbacks may go). +- `CCD_CALLBACK_ALLOWED_HTTP_HOSTS`: explicit exceptions for host patterns that may use `http` (all others must use `https`). +- `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`: explicit exceptions for host patterns that resolve to private/local/internal addresses. + +Allowlist values are comma-separated match patterns. Supported forms are: + +- exact hosts such as `aac-manage-case-assignment-aat.service.core-compute-aat.internal` +- legacy subdomain wildcard entries such as `*.example.com` +- `*` to match any host +- regex patterns such as `.*\.demo\.platform\.hmcts\.net` + +Example comma-separated value: + +`.*\.demo\.platform\.hmcts\.net,.*\.preview\.platform\.hmcts\.net` + +Do not use shell-style globs such as `*demo.platform.hmcts.net`; invalid regex-like entries now fail validation +explicitly rather than being treated as literal hostnames. For internal service hosts used in AAT/preview, a callback can be blocked if any one of these is missing, even when the host appears in the other two lists. @@ -106,7 +120,8 @@ After enabling callback hardening, service teams should: - `CCD_CALLBACK_ALLOWED_HTTP_HOSTS` (only for explicitly approved `http` hosts) - `CCD_CALLBACK_ALLOW_PRIVATE_HOSTS` (only for explicitly approved private/local hosts) - For preview/AAT, include `ccd-test-stubs-service-aat.service.core-compute-aat.internal` and - `aac-manage-case-assignment-aat.service.core-compute-aat.internal` in all three allowlists. + `aac-manage-case-assignment-aat.service.core-compute-aat.internal` in all three allowlists, or use a regex + pattern where that is the intended operational model. 3. Validate callback URLs during definition onboarding/import so invalid URLs are rejected before runtime. 4. Re-run callback integration tests and verify expected callback hosts are accepted. 5. Ensure callback endpoints do not return redirects (`3xx`) and instead return final responses directly. diff --git a/docs/integration.md b/docs/integration.md index f964f637c9..4bdac502eb 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -25,6 +25,8 @@ fall back to AAT defaults and later fail callback host allowlist checks. ## Callback Allowlist Notes Callback preflight validation checks the callback allowlist configuration before BEFTA and related setup work runs. +Allowlist env values are interpreted as comma-separated host match patterns, so exact hosts, legacy `*.domain.tld`, +`*`, and regex patterns are supported. Invalid regex-like entries fail preflight validation explicitly. Required AAT callback hosts currently include: @@ -34,6 +36,10 @@ Required AAT callback hosts currently include: If callback allowlist values drift across Jenkins or Helm config, preflight validation should fail early with the missing hosts called out explicitly rather than allowing later callback failures. +Example comma-separated pattern value: + +`.*\.demo\.platform\.hmcts\.net,.*\.preview\.platform\.hmcts\.net` + Example: ```bash diff --git a/docs/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md b/docs/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md index f02bd2386e..2c5ae0ed26 100644 --- a/docs/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md +++ b/docs/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md @@ -36,7 +36,7 @@ ## Suggested Validation Rules - Scheme: `https` by default; `http` only for explicitly approved hosts (`CCD_CALLBACK_ALLOWED_HTTP_HOSTS`). -- Host allowlist: exact domains and/or controlled subdomain rules (`CCD_CALLBACK_ALLOWED_HOSTS`). +- Host allowlist: exact domains, controlled subdomain rules, and/or regex patterns (`CCD_CALLBACK_ALLOWED_HOSTS`). - DNS/IP checks: reject loopback, private, link-local, multicast, and metadata service ranges unless explicitly approved (`CCD_CALLBACK_ALLOW_PRIVATE_HOSTS`). ## Recommended Next Controls (Not Yet Enforced Here) diff --git a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java index 7a061a91f1..c2b0db8865 100644 --- a/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java @@ -4,6 +4,7 @@ import org.springframework.util.StringUtils; import uk.gov.hmcts.ccd.ApplicationParams; import uk.gov.hmcts.ccd.endpoint.exceptions.CallbackException; +import uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher; import java.net.Inet6Address; import java.net.InetAddress; @@ -15,7 +16,6 @@ @Component public class CallbackUrlValidator { - private static final String ALLOWLIST_WILDCARD = "*"; private static final String HTTPS_SCHEME = "https"; private static final String HTTP_SCHEME = "http"; // Cloud instance metadata endpoint; explicitly blocked to prevent SSRF credential exfiltration. @@ -74,27 +74,16 @@ public String sanitizeUrl(String url) { } private boolean isAllowedHost(String host, List allowedHosts) { - if (!StringUtils.hasLength(host) || allowedHosts == null) { - return false; + try { + CallbackHostPatternMatcher.validateEntries(allowedHosts); + return CallbackHostPatternMatcher.containsHost(host, allowedHosts); + } catch (IllegalArgumentException ex) { + throw new CallbackException(ex.getMessage()); } - return allowedHosts.stream() - .filter(StringUtils::hasLength) - .map(String::trim) - .anyMatch(allowed -> hostMatches(host, allowed)); } private boolean hostMatches(String host, String allowedHost) { - if (ALLOWLIST_WILDCARD.equals(allowedHost)) { - return true; - } - final String normalisedHost = host.toLowerCase(Locale.UK); - final String normalisedAllowedHost = allowedHost.toLowerCase(Locale.UK); - if (normalisedAllowedHost.startsWith("*.")) { - // Wildcard matches only subdomains (e.g. *.example.com -> a.example.com), not the apex domain. - String suffix = normalisedAllowedHost.substring(1); - return normalisedHost.endsWith(suffix); - } - return normalisedHost.equals(normalisedAllowedHost); + return CallbackHostPatternMatcher.matches(host, allowedHost); } private boolean resolvesToPrivateAddress(String host) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3ff6033483..0629e52f87 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -285,6 +285,9 @@ ccd.tx-timeout.default=${DATA_STORE_TX_TIMEOUT_DEFAULT:30} # CCD list of callback passthru header contexts, comma-separated ccd.callback.passthru-header-contexts=${CALLBACK_PASSTHRU_HEADER_CONTEXTS:Client-Context} # Callback destination controls. +# Callback host allowlists accept exact hosts, legacy *.example.com entries, or regex patterns such as +# .*\.demo\.platform\.hmcts\.net and .*\.preview\.platform\.hmcts\.net +# Invalid regex-like entries fail validation explicitly. ccd.callback.allowed-hosts=${CCD_CALLBACK_ALLOWED_HOSTS:localhost,127.0.0.1} ccd.callback.allowed-http-hosts=${CCD_CALLBACK_ALLOWED_HTTP_HOSTS:localhost,127.0.0.1} ccd.callback.allow-private-hosts=${CCD_CALLBACK_ALLOW_PRIVATE_HOSTS:localhost,127.0.0.1} diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java index 6fc71a0f70..bd63c6d841 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java @@ -28,7 +28,7 @@ class CallbackUrlValidatorTest { void setUp() { MockitoAnnotations.openMocks(this); subject = new CallbackUrlValidator(applicationParams); - when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("localhost", "*.allowed.example")); + when(applicationParams.getCallbackAllowedHosts()).thenReturn(List.of("localhost", ".*\\.allowed\\.example")); when(applicationParams.getCallbackAllowedHttpHosts()).thenReturn(List.of("localhost")); when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(List.of("localhost")); } @@ -76,6 +76,14 @@ void shouldMatchWildcardSubdomainPattern() { "allowed.example", "*.allowed.example")); } + @Test + void shouldMatchRegexPattern() { + assertTrue((Boolean) ReflectionTestUtils.invokeMethod(subject, "hostMatches", + "pr-123.demo.platform.hmcts.net", ".*\\.demo\\.platform\\.hmcts\\.net")); + assertFalse((Boolean) ReflectionTestUtils.invokeMethod(subject, "hostMatches", + "demo.platform.hmcts.net", ".*\\.demo\\.platform\\.hmcts\\.net")); + } + @Test void shouldRejectPrivateHostWhenNotExplicitlyAllowed() { when(applicationParams.getCallbackAllowPrivateHosts()).thenReturn(List.of("internal-only.example")); @@ -151,6 +159,12 @@ void shouldMatchAllowlistWildcardDirectly() { assertTrue((Boolean) ReflectionTestUtils.invokeMethod(subject, "hostMatches", "any.host", "*")); } + @Test + void shouldFallbackToLiteralComparisonForInvalidRegex() { + assertTrue((Boolean) ReflectionTestUtils.invokeMethod(subject, "hostMatches", "literal.host", "literal.host")); + assertFalse((Boolean) ReflectionTestUtils.invokeMethod(subject, "hostMatches", "other.host", "literal.host")); + } + @Test void shouldResolvePublicAddressAsNonPrivate() { assertFalse((Boolean) ReflectionTestUtils.invokeMethod(subject, "resolvesToPrivateAddress", "8.8.8.8")); From 6c6915b664f0b5d07d9023b2a479ba0f50f84eb0 Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 25 Mar 2026 12:17:35 +0000 Subject: [PATCH 38/41] Switch callback allowlists to regex-capable matching and share preflight validation logic --- buildSrc/build.gradle | 3 + .../ccd/util/CallbackAllowlistPreflight.java | 91 +++++++++++++++ .../ccd/util/CallbackHostPatternMatcher.java | 109 ++++++++++++++++++ .../util/CallbackAllowlistPreflightTest.java | 102 ++++++++++++++++ .../util/CallbackHostPatternMatcherTest.java | 60 ++++++++++ 5 files changed, 365 insertions(+) create mode 100644 buildSrc/build.gradle create mode 100644 buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackAllowlistPreflight.java create mode 100644 buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcher.java create mode 100644 src/test/java/uk/gov/hmcts/ccd/util/CallbackAllowlistPreflightTest.java create mode 100644 src/test/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcherTest.java diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 0000000000..075ba3d563 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'java' +} diff --git a/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackAllowlistPreflight.java b/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackAllowlistPreflight.java new file mode 100644 index 0000000000..6bb2195e36 --- /dev/null +++ b/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackAllowlistPreflight.java @@ -0,0 +1,91 @@ +package uk.gov.hmcts.ccd.util; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +public final class CallbackAllowlistPreflight { + private static final String ALLOWED_HOSTS_KEY = "CCD_CALLBACK_ALLOWED_HOSTS"; + private static final String ALLOWED_HTTP_HOSTS_KEY = "CCD_CALLBACK_ALLOWED_HTTP_HOSTS"; + private static final String ALLOW_PRIVATE_HOSTS_KEY = "CCD_CALLBACK_ALLOW_PRIVATE_HOSTS"; + + private CallbackAllowlistPreflight() { + } + + public static String resolveStubHost(String beftaStubBaseUrl, + String beftaStubHost, + String defaultStubHost) throws MalformedURLException { + if (hasText(beftaStubBaseUrl)) { + return parseUrlHost(beftaStubBaseUrl); + } + if (hasText(beftaStubHost)) { + return beftaStubHost.trim(); + } + return defaultStubHost; + } + + public static List requiredHosts(String beftaStubBaseUrl, String aacHost) throws MalformedURLException { + List requiredHosts = new ArrayList<>(); + if (hasText(beftaStubBaseUrl)) { + requiredHosts.add(parseUrlHost(beftaStubBaseUrl)); + } + if (hasText(aacHost)) { + requiredHosts.add(aacHost.trim()); + } + return requiredHosts; + } + + public static List findAllowlistIssues(List requiredHosts, + String callbackAllowedHosts, + String callbackAllowedHttpHosts, + String callbackAllowPrivateHosts) { + CallbackHostPatternMatcher.validateEntries(callbackAllowedHosts); + CallbackHostPatternMatcher.validateEntries(callbackAllowedHttpHosts); + CallbackHostPatternMatcher.validateEntries(callbackAllowPrivateHosts); + + List issues = new ArrayList<>(); + + List missingFromAllowed = requiredHosts.stream() + .filter(host -> !CallbackHostPatternMatcher.containsHost(host, callbackAllowedHosts)) + .toList(); + List missingFromHttpAllowed = requiredHosts.stream() + .filter(host -> !CallbackHostPatternMatcher.containsHost(host, callbackAllowedHttpHosts)) + .toList(); + List missingFromPrivateAllowed = requiredHosts.stream() + .filter(host -> !CallbackHostPatternMatcher.containsHost(host, callbackAllowPrivateHosts)) + .toList(); + + if (!missingFromAllowed.isEmpty()) { + issues.add(ALLOWED_HOSTS_KEY + " missing " + missingFromAllowed); + } + if (!missingFromHttpAllowed.isEmpty()) { + issues.add(ALLOWED_HTTP_HOSTS_KEY + " missing " + missingFromHttpAllowed); + } + if (!missingFromPrivateAllowed.isEmpty()) { + issues.add(ALLOW_PRIVATE_HOSTS_KEY + " missing " + missingFromPrivateAllowed); + } + + return issues; + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + public static String parseUrlHost(String urlValue) throws MalformedURLException { + return new URL(normaliseYamlScalar(urlValue)).getHost(); + } + + public static String normaliseYamlScalar(String value) { + if (!hasText(value)) { + return value; + } + String trimmed = value.trim(); + if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) + || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.substring(1, trimmed.length() - 1); + } + return trimmed; + } +} diff --git a/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcher.java b/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcher.java new file mode 100644 index 0000000000..74381b284e --- /dev/null +++ b/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcher.java @@ -0,0 +1,109 @@ +package uk.gov.hmcts.ccd.util; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +public final class CallbackHostPatternMatcher { + private static final String ALLOWLIST_WILDCARD = "*"; + + private CallbackHostPatternMatcher() { + } + + public static boolean containsHost(String host, String rawAllowlist) { + if (!hasText(host) || !hasText(rawAllowlist)) { + return false; + } + return Arrays.stream(rawAllowlist.split(",")) + .map(String::trim) + .anyMatch(entry -> matches(host, entry)); + } + + public static boolean containsHost(String host, List allowlist) { + if (!hasText(host) || allowlist == null) { + return false; + } + return allowlist.stream() + .filter(CallbackHostPatternMatcher::hasText) + .map(String::trim) + .anyMatch(entry -> matches(host, entry)); + } + + public static boolean matches(String host, String entry) { + if (!hasText(host) || !hasText(entry)) { + return false; + } + + final String normalisedHost = host.toLowerCase(Locale.UK); + final String trimmedEntry = entry.trim(); + final String normalisedEntry = trimmedEntry.toLowerCase(Locale.UK); + + if (ALLOWLIST_WILDCARD.equals(normalisedEntry)) { + return true; + } + if (normalisedEntry.startsWith("*.")) { + return normalisedHost.endsWith(normalisedEntry.substring(1)); + } + if (!isPlainHostname(trimmedEntry)) { + return matchesRegex(normalisedHost, normalisedEntry); + } + + return normalisedHost.equals(normalisedEntry); + } + + public static void validateEntry(String entry) { + if (!hasText(entry)) { + throw new IllegalArgumentException("Callback allowlist entry must not be blank"); + } + String trimmedEntry = entry.trim(); + String normalisedEntry = trimmedEntry.toLowerCase(Locale.UK); + + if (ALLOWLIST_WILDCARD.equals(normalisedEntry) || normalisedEntry.startsWith("*.") + || isPlainHostname(trimmedEntry)) { + return; + } + + try { + Pattern.compile(normalisedEntry); + } catch (PatternSyntaxException ex) { + throw new IllegalArgumentException("Invalid callback allowlist pattern: " + trimmedEntry, ex); + } + } + + public static void validateEntries(List entries) { + if (entries == null) { + return; + } + entries.stream() + .filter(CallbackHostPatternMatcher::hasText) + .forEach(CallbackHostPatternMatcher::validateEntry); + } + + public static void validateEntries(String rawAllowlist) { + if (!hasText(rawAllowlist)) { + return; + } + Arrays.stream(rawAllowlist.split(",")) + .map(String::trim) + .filter(CallbackHostPatternMatcher::hasText) + .forEach(CallbackHostPatternMatcher::validateEntry); + } + + private static boolean matchesRegex(String normalisedHost, String normalisedEntry) { + try { + return Pattern.compile(normalisedEntry).matcher(normalisedHost).matches(); + } catch (PatternSyntaxException ex) { + throw new IllegalArgumentException("Invalid callback allowlist pattern: " + normalisedEntry, ex); + } + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private static boolean isPlainHostname(String value) { + return value.matches("(?i)^[a-z0-9.-]+$"); + } +} diff --git a/src/test/java/uk/gov/hmcts/ccd/util/CallbackAllowlistPreflightTest.java b/src/test/java/uk/gov/hmcts/ccd/util/CallbackAllowlistPreflightTest.java new file mode 100644 index 0000000000..1cc311bda5 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/util/CallbackAllowlistPreflightTest.java @@ -0,0 +1,102 @@ +package uk.gov.hmcts.ccd.util; + +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CallbackAllowlistPreflightTest { + + @Test + void shouldBuildRequiredHostsFromBeftaAndAac() throws Exception { + List requiredHosts = CallbackAllowlistPreflight.requiredHosts( + "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/", + "aac-manage-case-assignment-aat.service.core-compute-aat.internal"); + + assertEquals(List.of( + "ccd-test-stubs-service-aat.service.core-compute-aat.internal", + "aac-manage-case-assignment-aat.service.core-compute-aat.internal"), requiredHosts); + } + + @Test + void shouldBuildRequiredHostsFromBeftaOnlyWhenAacMissing() throws Exception { + List requiredHosts = CallbackAllowlistPreflight.requiredHosts( + "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal/", " "); + + assertEquals(List.of("ccd-test-stubs-service-aat.service.core-compute-aat.internal"), requiredHosts); + } + + @Test + void shouldThrowForInvalidBeftaUrl() { + assertThrows(MalformedURLException.class, () -> + CallbackAllowlistPreflight.requiredHosts("not-a-url", "aac.service.internal")); + } + + @Test + void shouldPreferBeftaBaseUrlOverHostEnv() throws Exception { + String resolvedHost = CallbackAllowlistPreflight.resolveStubHost( + "http://resolved-from-base-url.internal/", + "fallback-host.internal", + "default-host.internal"); + + assertEquals("resolved-from-base-url.internal", resolvedHost); + } + + @Test + void shouldFallbackToBeftaHostEnvWhenBaseUrlMissing() throws Exception { + String resolvedHost = CallbackAllowlistPreflight.resolveStubHost( + null, + "fallback-host.internal", + "default-host.internal"); + + assertEquals("fallback-host.internal", resolvedHost); + } + + @Test + void shouldParseQuotedYamlUrlValue() throws Exception { + String parsedHost = CallbackAllowlistPreflight.parseUrlHost("\"https://quoted-host.internal/\""); + + assertEquals("quoted-host.internal", parsedHost); + } + + @Test + void shouldReportMissingAllowlistEntries() { + List issues = CallbackAllowlistPreflight.findAllowlistIssues( + List.of("stub.service.internal", "aac.service.internal"), + "stub.service.internal", + "stub.service.internal", + "stub.service.internal"); + + assertEquals(3, issues.size()); + assertTrue(issues.contains("CCD_CALLBACK_ALLOWED_HOSTS missing [aac.service.internal]")); + assertTrue(issues.contains("CCD_CALLBACK_ALLOWED_HTTP_HOSTS missing [aac.service.internal]")); + assertTrue(issues.contains("CCD_CALLBACK_ALLOW_PRIVATE_HOSTS missing [aac.service.internal]")); + } + + @Test + void shouldAcceptRegexMatchedAllowlistEntries() { + List issues = CallbackAllowlistPreflight.findAllowlistIssues( + List.of("pr-123.preview.platform.hmcts.net"), + ".*\\.preview\\.platform\\.hmcts\\.net", + ".*\\.preview\\.platform\\.hmcts\\.net", + ".*\\.preview\\.platform\\.hmcts\\.net"); + + assertTrue(issues.isEmpty()); + } + + @Test + void shouldRejectInvalidAllowlistPattern() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> + CallbackAllowlistPreflight.findAllowlistIssues( + List.of("pr-123.preview.platform.hmcts.net"), + "*preview.platform.hmcts.net", + ".*\\.preview\\.platform\\.hmcts\\.net", + ".*\\.preview\\.platform\\.hmcts\\.net")); + + assertTrue(exception.getMessage().contains("Invalid callback allowlist pattern")); + } +} diff --git a/src/test/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcherTest.java b/src/test/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcherTest.java new file mode 100644 index 0000000000..a17588f532 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcherTest.java @@ -0,0 +1,60 @@ +package uk.gov.hmcts.ccd.util; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CallbackHostPatternMatcherTest { + + @Test + void shouldMatchWildcard() { + assertTrue(CallbackHostPatternMatcher.matches("any.host", "*")); + } + + @Test + void shouldMatchLegacyWildcardSubdomain() { + assertTrue(CallbackHostPatternMatcher.matches("sub.allowed.example", "*.allowed.example")); + assertFalse(CallbackHostPatternMatcher.matches("allowed.example", "*.allowed.example")); + } + + @Test + void shouldMatchRegexPattern() { + assertTrue(CallbackHostPatternMatcher.matches("pr-123.preview.platform.hmcts.net", + ".*\\.preview\\.platform\\.hmcts\\.net")); + assertFalse(CallbackHostPatternMatcher.matches("preview.platform.hmcts.net", + ".*\\.preview\\.platform\\.hmcts\\.net")); + } + + @Test + void shouldFallbackToLiteralEqualityForNonMatchingHost() { + assertTrue(CallbackHostPatternMatcher.matches("literal.host", "literal.host")); + assertFalse(CallbackHostPatternMatcher.matches("other.host", "literal.host")); + } + + @Test + void shouldRejectInvalidRegexLikeEntry() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> CallbackHostPatternMatcher.validateEntry("*demo.platform.hmcts.net")); + assertTrue(exception.getMessage().contains("Invalid callback allowlist pattern")); + } + + @Test + void shouldCheckListAllowlist() { + assertTrue(CallbackHostPatternMatcher.containsHost("service.preview.platform.hmcts.net", + List.of("localhost", ".*\\.preview\\.platform\\.hmcts\\.net"))); + assertFalse(CallbackHostPatternMatcher.containsHost("service.demo.platform.hmcts.net", + List.of("localhost", ".*\\.preview\\.platform\\.hmcts\\.net"))); + } + + @Test + void shouldCheckRawCommaSeparatedAllowlist() { + assertTrue(CallbackHostPatternMatcher.containsHost("service.demo.platform.hmcts.net", + "localhost,.*\\.demo\\.platform\\.hmcts\\.net")); + assertFalse(CallbackHostPatternMatcher.containsHost("service.other.platform.hmcts.net", + "localhost,.*\\.demo\\.platform\\.hmcts\\.net")); + } +} From 6dea5cc035fd0549b1ba63cef08003a28ef6c8c6 Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 25 Mar 2026 12:26:26 +0000 Subject: [PATCH 39/41] Refine callback allowlist matching and preflight consistency checks --- build.gradle | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/build.gradle b/build.gradle index f4ac124864..671ff0db36 100644 --- a/build.gradle +++ b/build.gradle @@ -234,10 +234,6 @@ task verifyBefTaStubHostConfigConsistency { doLast { def env = System.getenv() try { - def expectedStubHost = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.resolveStubHost( - env.get("BEFTA_TEST_STUB_SERVICE_BASE_URL"), - env.get("BEFTA_TEST_STUB_SERVICE_HOST"), - "ccd-test-stubs-service-aat.service.core-compute-aat.internal") def expectedAacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") def valuesFiles = [ file("charts/ccd-data-store-api/values.yaml"), @@ -253,15 +249,11 @@ task verifyBefTaStubHostConfigConsistency { def content = valuesFile.getText("UTF-8") def beftaStubBaseUrlMatcher = content =~ /(?m)^\s*BEFTA_TEST_STUB_SERVICE_BASE_URL:\s*(.+)\s*$/ boolean hasBeftaStubBaseUrl = beftaStubBaseUrlMatcher.find() + String configuredBeftaStubHost = null if (hasBeftaStubBaseUrl) { String configuredBeftaStubBaseUrl = beftaStubBaseUrlMatcher.group(1) - String configuredBeftaStubHost = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.parseUrlHost( + configuredBeftaStubHost = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.parseUrlHost( configuredBeftaStubBaseUrl) - boolean beftaStubHostMatchesExpected = - uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher.matches(expectedStubHost, configuredBeftaStubHost) - if (!beftaStubHostMatchesExpected) { - issues << "${project.relativePath(valuesFile)}: BEFTA_TEST_STUB_SERVICE_BASE_URL host does not match expected stub host" - } } def callbackAllowlistKeys = [ "CCD_CALLBACK_ALLOWED_HOSTS", @@ -272,8 +264,9 @@ task verifyBefTaStubHostConfigConsistency { def matcher = content =~ /(?m)^\s*${java.util.regex.Pattern.quote(key)}:\s*(.+)$/ boolean hasKey = matcher.find() String lineValue = hasKey ? matcher.group(1) : "" - boolean hasStubHost = uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher.containsHost( - expectedStubHost, lineValue) + boolean hasStubHost = configuredBeftaStubHost + ? uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher.containsHost(configuredBeftaStubHost, lineValue) + : true boolean hasAacHost = expectedAacHost ? uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher.containsHost(expectedAacHost, lineValue) : true From acfe1ce0e5d83bbb5dcb7fdc83211379b7521698 Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 26 Mar 2026 15:29:08 +0000 Subject: [PATCH 40/41] Fix callback allowlist regex parsing and clean up callback hardening docs --- README.md | 2 +- .../ccd/util/CallbackHostPatternMatcher.java | 44 +++++++++++++++---- docs/api/security.md | 5 +++ .../uk/gov/hmcts/ccd/ApplicationParams.java | 19 ++++---- .../util/CallbackHostPatternMatcherTest.java | 24 ++++++++++ 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8b6bb434cc..e678cff49e 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ The following environment variables are required: | CCD_CALLBACK_ALLOWED_HTTP_HOSTS | localhost,127.0.0.1 | Comma-separated host match patterns allowed to use `http` for callbacks. Exact hosts, legacy `*.domain.tld`, `*`, and regex patterns are supported; invalid regex-like entries fail validation explicitly; all other callback hosts must use `https`. | | CCD_CALLBACK_ALLOW_PRIVATE_HOSTS | localhost,127.0.0.1 | Comma-separated host match patterns allowed to resolve to private/local addresses for callbacks. Exact hosts, legacy `*.domain.tld`, `*`, and regex patterns are supported; invalid regex-like entries fail validation explicitly. | -For callback hardening rollout guidance and environment examples (including preview/AAT allowlist hosts), +For callback hardening rollout guidance, allowlist pattern syntax, and environment examples (including preview/AAT allowlist hosts), see [`docs/api/security.md`](docs/api/security.md). ### Building diff --git a/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcher.java b/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcher.java index 74381b284e..b15cc48912 100644 --- a/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcher.java +++ b/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcher.java @@ -1,6 +1,6 @@ package uk.gov.hmcts.ccd.util; -import java.util.Arrays; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.regex.Pattern; @@ -16,7 +16,7 @@ public static boolean containsHost(String host, String rawAllowlist) { if (!hasText(host) || !hasText(rawAllowlist)) { return false; } - return Arrays.stream(rawAllowlist.split(",")) + return splitRawAllowlist(rawAllowlist).stream() .map(String::trim) .anyMatch(entry -> matches(host, entry)); } @@ -47,7 +47,7 @@ public static boolean matches(String host, String entry) { return normalisedHost.endsWith(normalisedEntry.substring(1)); } if (!isPlainHostname(trimmedEntry)) { - return matchesRegex(normalisedHost, normalisedEntry); + return matchesRegex(host, trimmedEntry); } return normalisedHost.equals(normalisedEntry); @@ -66,7 +66,7 @@ public static void validateEntry(String entry) { } try { - Pattern.compile(normalisedEntry); + Pattern.compile(trimmedEntry, Pattern.CASE_INSENSITIVE); } catch (PatternSyntaxException ex) { throw new IllegalArgumentException("Invalid callback allowlist pattern: " + trimmedEntry, ex); } @@ -85,17 +85,45 @@ public static void validateEntries(String rawAllowlist) { if (!hasText(rawAllowlist)) { return; } - Arrays.stream(rawAllowlist.split(",")) + splitRawAllowlist(rawAllowlist).stream() .map(String::trim) .filter(CallbackHostPatternMatcher::hasText) .forEach(CallbackHostPatternMatcher::validateEntry); } - private static boolean matchesRegex(String normalisedHost, String normalisedEntry) { + public static List splitRawAllowlist(String rawAllowlist) { + List entries = new ArrayList<>(); + StringBuilder currentEntry = new StringBuilder(); + + for (int i = 0; i < rawAllowlist.length(); i++) { + char currentChar = rawAllowlist.charAt(i); + if (currentChar == '\\' && i + 1 < rawAllowlist.length()) { + char nextChar = rawAllowlist.charAt(i + 1); + if (nextChar == ',') { + currentEntry.append(','); + i++; + continue; + } + currentEntry.append(currentChar); + continue; + } + if (currentChar == ',') { + entries.add(currentEntry.toString()); + currentEntry.setLength(0); + continue; + } + currentEntry.append(currentChar); + } + + entries.add(currentEntry.toString()); + return entries; + } + + private static boolean matchesRegex(String host, String entry) { try { - return Pattern.compile(normalisedEntry).matcher(normalisedHost).matches(); + return Pattern.compile(entry, Pattern.CASE_INSENSITIVE).matcher(host).matches(); } catch (PatternSyntaxException ex) { - throw new IllegalArgumentException("Invalid callback allowlist pattern: " + normalisedEntry, ex); + throw new IllegalArgumentException("Invalid callback allowlist pattern: " + entry, ex); } } diff --git a/docs/api/security.md b/docs/api/security.md index f07c880c77..9ab368fd02 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -104,6 +104,11 @@ Example comma-separated value: `.*\.demo\.platform\.hmcts\.net,.*\.preview\.platform\.hmcts\.net` +If a single regex entry needs a comma, escape it as `\,` so it stays within the same allowlist item rather than +being split into two entries. For example: + +`localhost,node[0-9]{1\,3}\.example\.internal` + Do not use shell-style globs such as `*demo.platform.hmcts.net`; invalid regex-like entries now fail validation explicitly rather than being treated as literal hostnames. diff --git a/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java b/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java index efe09e7ace..bc9a2f89f7 100644 --- a/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java +++ b/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java @@ -3,6 +3,7 @@ import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import uk.gov.hmcts.ccd.endpoint.exceptions.ServiceException; +import uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher; import jakarta.inject.Named; import jakarta.inject.Singleton; @@ -242,14 +243,14 @@ public class ApplicationParams { @Value("#{'${ccd.callback.passthru-header-contexts}'.split(',')}") private List callbackPassthruHeaderContexts; - @Value("#{'${ccd.callback.allowed-hosts}'.split(',')}") - private List callbackAllowedHosts; + @Value("${ccd.callback.allowed-hosts}") + private String callbackAllowedHosts; - @Value("#{'${ccd.callback.allowed-http-hosts}'.split(',')}") - private List callbackAllowedHttpHosts; + @Value("${ccd.callback.allowed-http-hosts}") + private String callbackAllowedHttpHosts; - @Value("#{'${ccd.callback.allow-private-hosts}'.split(',')}") - private List callbackAllowPrivateHosts; + @Value("${ccd.callback.allow-private-hosts}") + private String callbackAllowPrivateHosts; @Value("#{'${case.data.exclude.verifyaccess.casetype.validate}'.split(',')}") private List excludeVerifyAccessCaseTypesForValidate; @@ -662,15 +663,15 @@ public List getCallbackPassthruHeaderContexts() { } public List getCallbackAllowedHosts() { - return callbackAllowedHosts; + return CallbackHostPatternMatcher.splitRawAllowlist(callbackAllowedHosts); } public List getCallbackAllowedHttpHosts() { - return callbackAllowedHttpHosts; + return CallbackHostPatternMatcher.splitRawAllowlist(callbackAllowedHttpHosts); } public List getCallbackAllowPrivateHosts() { - return callbackAllowPrivateHosts; + return CallbackHostPatternMatcher.splitRawAllowlist(callbackAllowPrivateHosts); } public List getUploadTimestampFeaturedCaseTypes() { diff --git a/src/test/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcherTest.java b/src/test/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcherTest.java index a17588f532..ebd0b43b1a 100644 --- a/src/test/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcherTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcherTest.java @@ -29,6 +29,14 @@ void shouldMatchRegexPattern() { ".*\\.preview\\.platform\\.hmcts\\.net")); } + @Test + void shouldTreatRegexPatternsAsCaseInsensitiveWithoutRewritingThem() { + assertTrue(CallbackHostPatternMatcher.matches("PR-123.PREVIEW.PLATFORM.HMCTS.NET", + "pr-[0-9]+\\.preview\\.platform\\.hmcts\\.net")); + assertTrue(CallbackHostPatternMatcher.matches("api.callback.example", + "[A-Z]+\\.callback\\.example")); + } + @Test void shouldFallbackToLiteralEqualityForNonMatchingHost() { assertTrue(CallbackHostPatternMatcher.matches("literal.host", "literal.host")); @@ -57,4 +65,20 @@ void shouldCheckRawCommaSeparatedAllowlist() { assertFalse(CallbackHostPatternMatcher.containsHost("service.other.platform.hmcts.net", "localhost,.*\\.demo\\.platform\\.hmcts\\.net")); } + + @Test + void shouldSupportRegexQuantifierWithEscapedCommaInRawAllowlist() { + assertTrue(CallbackHostPatternMatcher.containsHost("node123.example.internal", + "localhost,node[0-9]{1\\,3}\\.example\\.internal")); + assertFalse(CallbackHostPatternMatcher.containsHost("node1234.example.internal", + "localhost,node[0-9]{1\\,3}\\.example\\.internal")); + } + + @Test + void shouldValidateRegexQuantifierWithEscapedCommaInRawAllowlist() { + assertThrows(IllegalArgumentException.class, () -> + CallbackHostPatternMatcher.validateEntries("localhost,node[0-9]{1,3}\\.example\\.internal")); + + CallbackHostPatternMatcher.validateEntries("localhost,node[0-9]{1\\,3}\\.example\\.internal"); + } } From aeb09215e7f42cd0eeea0009c987d26475b88647 Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 26 Mar 2026 16:44:18 +0000 Subject: [PATCH 41/41] Harden callback allowlist parsing and align contract test config --- src/contractTest/resources/application.properties | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/contractTest/resources/application.properties b/src/contractTest/resources/application.properties index 064ab7e8e2..d3782bb281 100644 --- a/src/contractTest/resources/application.properties +++ b/src/contractTest/resources/application.properties @@ -154,6 +154,10 @@ logging.level.au.com.dius.pact=DEBUG logging.level.uk.gov.hmcts.ccd.domain.service.validate.CaseDataIssueLogger=${CASE_DATA_ISSUE_LOG_LEVEL:DEBUG} logging.level.uk.gov.hmcts.ccd.domain.service.common.AccessControlService=${CCD_MULTIPARTY_LOG_LEVEL:INFO} +ccd.callback.allowed-hosts=${CCD_CALLBACK_ALLOWED_HOSTS:localhost,127.0.0.1} +ccd.callback.allowed-http-hosts=${CCD_CALLBACK_ALLOWED_HTTP_HOSTS:localhost,127.0.0.1} +ccd.callback.allow-private-hosts=${CCD_CALLBACK_ALLOW_PRIVATE_HOSTS:localhost,127.0.0.1} + # logging.level.org.springframework.web=DEBUG #logging.level.org.hibernate=INFO #logging.level.org.hibernate.type.descriptor.sql=trace