diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..2b6b2767d2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# 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. + +### 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 docs/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 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. +``` + +### 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. +- 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/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 782a1cbc5e..a1cc079eac 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -105,6 +105,11 @@ 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.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 @@ -112,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 { @@ -139,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}" @@ -184,6 +190,9 @@ 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-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, @@ -196,6 +205,9 @@ 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-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, @@ -208,6 +220,9 @@ 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-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, @@ -220,6 +235,9 @@ 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-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, diff --git a/Jenkinsfile_nightly b/Jenkinsfile_nightly index 3812977cde..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" @@ -96,6 +96,11 @@ 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.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/README.md b/README.md index 617508e2d6..e678cff49e 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/) @@ -47,6 +49,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 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, allowlist pattern syntax, and environment examples (including preview/AAT allowlist hosts), +see [`docs/api/security.md`](docs/api/security.md). ### Building 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/build.gradle b/build.gradle index 7141f7536e..0cfd61379c 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') @@ -198,6 +204,90 @@ 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 verifyBefTaStubHostConfigConsistency { + group = "Verification" + description = "Ensures Helm values keep BEFTA stub host and callback allowlists aligned." + + doLast { + def env = System.getenv() + try { + 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") + 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) + configuredBeftaStubHost = uk.gov.hmcts.ccd.util.CallbackAllowlistPreflight.parseUrlHost( + configuredBeftaStubBaseUrl) + } + 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 = configuredBeftaStubHost + ? uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher.containsHost(configuredBeftaStubHost, lineValue) + : true + 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" + } + } + } + + 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) + } + } +} +check.dependsOn verifyBefTaStubHostConfigConsistency + task integration(type: Test) { description = "Runs integration tests" group = "Verification" @@ -262,7 +352,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' @@ -598,7 +688,9 @@ compileTestJava { // adopted from // https://github.com/springfox/springfox/blob/fb780ee1f14627b239fba95730a69900b9b2313a/gradle/coverage.gradle jacocoTestReport { - executionData(test, integration) + dependsOn generateJsonSchema2Pojo, compileJava, processResources, testUnit, testIt, integration + mustRunAfter generateJsonSchema2Pojo, compileJava, processResources + executionData(testUnit, testIt, integration) doFirst { logger.lifecycle("{} Starting jacocoTestReport ...", timestamp()) } @@ -735,9 +827,160 @@ 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") + 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: 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 aacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") + 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.") + } + 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.") + } else { + 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" + + "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: " + + beftaStubBaseUrl, malformedURLException) + } + } + + 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 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 aacHost = env.get("AAC_MANAGE_CASE_ASSIGNMENT_HOST") + 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.") + } + 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 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" + + "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: required callback hosts ${requiredHosts} 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 + dependsOn preSmokeDiagnostics new File("$buildDir/test-results/test").mkdirs() copy { @@ -779,6 +1022,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/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..b15cc48912 --- /dev/null +++ b/buildSrc/src/main/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcher.java @@ -0,0 +1,137 @@ +package uk.gov.hmcts.ccd.util; + +import java.util.ArrayList; +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 splitRawAllowlist(rawAllowlist).stream() + .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(host, trimmedEntry); + } + + 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(trimmedEntry, Pattern.CASE_INSENSITIVE); + } 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; + } + splitRawAllowlist(rawAllowlist).stream() + .map(String::trim) + .filter(CallbackHostPatternMatcher::hasText) + .forEach(CallbackHostPatternMatcher::validateEntry); + } + + 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(entry, Pattern.CASE_INSENSITIVE).matcher(host).matches(); + } catch (PatternSyntaxException ex) { + throw new IllegalArgumentException("Invalid callback allowlist pattern: " + entry, 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/charts/ccd-data-store-api/Chart.yaml b/charts/ccd-data-store-api/Chart.yaml index 88984aaf76..69dc755e86 100644 --- a/charts/ccd-data-store-api/Chart.yaml +++ b/charts/ccd-data-store-api/Chart.yaml @@ -2,14 +2,14 @@ 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 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.aat.template.yaml b/charts/ccd-data-store-api/values.aat.template.yaml index bed06bdc19..df74a3377c 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,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 696373792b..f646072fde 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-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: | @@ -92,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 @@ -116,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" @@ -178,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 c2ae820393..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 @@ -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-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 diff --git a/docs/api/security.md b/docs/api/security.md index c061a1d778..9ab368fd02 100644 --- a/docs/api/security.md +++ b/docs/api/security.md @@ -66,3 +66,69 @@ 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 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 + +Prevent untrusted callback destinations from being invoked and prevent sensitive credential/context headers from being leaked during callback execution. + +- 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). +- 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 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` + +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. + +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: + +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) + - 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, 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. +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 6f98f45092..4bdac502eb 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -6,3 +6,43 @@ 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. + +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. + +## 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: + +- `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 comma-separated pattern value: + +`.*\.demo\.platform\.hmcts\.net,.*\.preview\.platform\.hmcts\.net` + +Example: + +```bash +export TEST_STUB_SERVICE_BASE_URL=http://host.docker.internal:5555 +./gradlew functional +``` diff --git a/docs/skills/ccd-callback-ssrf-hardening/SKILL.md b/docs/skills/ccd-callback-ssrf-hardening/SKILL.md new file mode 100644 index 0000000000..e5c5f0013f --- /dev/null +++ b/docs/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/docs/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md b/docs/skills/ccd-callback-ssrf-hardening/references/callback-hotspots.md new file mode 100644 index 0000000000..2c5ae0ed26 --- /dev/null +++ b/docs/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, 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) + +- 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/docs/skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh b/docs/skills/ccd-callback-ssrf-hardening/scripts/scan_callback_risks.sh new file mode 100755 index 0000000000..2897cce580 --- /dev/null +++ b/docs/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/docs/skills/ccd-sonarqube-remediation/SKILL.md b/docs/skills/ccd-sonarqube-remediation/SKILL.md new file mode 100644 index 0000000000..21856d1085 --- /dev/null +++ b/docs/skills/ccd-sonarqube-remediation/SKILL.md @@ -0,0 +1,82 @@ +--- +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 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 + +- 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 +# optional, if configured in CI/local: +# ./gradlew sonarqube +``` + +## 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. +- 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/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/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 diff --git a/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java b/src/main/java/uk/gov/hmcts/ccd/ApplicationParams.java index bc11977026..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,6 +243,15 @@ public class ApplicationParams { @Value("#{'${ccd.callback.passthru-header-contexts}'.split(',')}") private List callbackPassthruHeaderContexts; + @Value("${ccd.callback.allowed-hosts}") + private String callbackAllowedHosts; + + @Value("${ccd.callback.allowed-http-hosts}") + private String callbackAllowedHttpHosts; + + @Value("${ccd.callback.allow-private-hosts}") + private String callbackAllowPrivateHosts; + @Value("#{'${case.data.exclude.verifyaccess.casetype.validate}'.split(',')}") private List excludeVerifyAccessCaseTypesForValidate; @@ -652,6 +662,18 @@ public List getCallbackPassthruHeaderContexts() { return callbackPassthruHeaderContexts; } + public List getCallbackAllowedHosts() { + return CallbackHostPatternMatcher.splitRawAllowlist(callbackAllowedHosts); + } + + public List getCallbackAllowedHttpHosts() { + return CallbackHostPatternMatcher.splitRawAllowlist(callbackAllowedHttpHosts); + } + + public List getCallbackAllowPrivateHosts() { + return CallbackHostPatternMatcher.splitRawAllowlist(callbackAllowPrivateHosts); + } + public List getUploadTimestampFeaturedCaseTypes() { return uploadTimestampFeaturedCaseTypes; } 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 e0b63d816d..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 @@ -10,10 +10,13 @@ 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.CallbackException; import uk.gov.hmcts.ccd.endpoint.exceptions.ResourceNotFoundException; import uk.gov.hmcts.ccd.endpoint.exceptions.ServiceException; @@ -25,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; @@ -39,14 +44,19 @@ 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_]\\w*)(?::([^}]*))?}"); 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,13 +67,15 @@ 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 - && ((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 { @@ -89,13 +101,13 @@ public CaseTypeDefinition getCaseType(final String caseTypeId) { if (caseTypeDefinition != null) { caseTypeDefinition.getCaseFieldDefinitions().stream() .forEach(CaseFieldDefinition::propagateACLsToNestedFields); + validateCaseTypeCallbackUrls(caseTypeDefinition); } return caseTypeDefinition; } 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 { @@ -118,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 { @@ -138,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); } } } @@ -163,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); } } @@ -185,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 { @@ -239,6 +246,68 @@ private List getCaseTypeIdFromJurisdictionDefinition(List getJurisdictionsFromDefinitionStore(Optional> jurisdictionIds) { try { UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(applicationParams.jurisdictionDefURL()); @@ -256,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<>(); @@ -267,4 +335,13 @@ private List getJurisdictionsFromDefinitionStore(Optiona } } } + + private boolean isNotFound(Exception e) { + return e instanceof HttpClientErrorException httpClientErrorException + && httpClientErrorException.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 7c1f7fe218..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 @@ -33,9 +33,11 @@ import jakarta.servlet.http.HttpServletRequest; import java.time.Duration; import java.time.Instant; +import java.util.List; 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; @@ -44,10 +46,15 @@ @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 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( + "(?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+[\\p{Alnum}._/+=-]+"); private final SecurityUtils securityUtils; private final RestTemplate restTemplate; @@ -55,6 +62,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, @@ -62,13 +70,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 @@ -108,11 +118,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 = callbackUrlValidator.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 +138,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 {}", callbackUrlValidator.sanitizeUrl(url), + caseDetails.getCaseTypeId(), caseEvent.getId()); return new CallbackException("Callback to service has been unsuccessful for event " + caseEvent.getName()); }); @@ -136,8 +149,8 @@ private Optional> sendRequest(final String url, final CallbackType callbackType, final Class clazz, final CallbackRequest callbackRequest) { - - HttpHeaders securityHeaders = securityUtils.authorizationHeaders(); + callbackUrlValidator.validateCallbackUrl(url); + final String safeUrl = callbackUrlValidator.sanitizeUrl(url); CallbackTelemetryThreadContext.setTelemetryContext(new CallbackTelemetryContext(callbackType)); int httpStatus = 0; @@ -146,18 +159,24 @@ 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, - printCallbackDetails(requestEntity)); + 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, + requestDetails); } ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.POST, requestEntity, clazz); - if (logCallbackDetails(url)) { - LOG.info("Callback {} response received: {}", url, printCallbackDetails(responseEntity)); + if (shouldLogCallbackDetails) { + 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); @@ -166,7 +185,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,13 +193,13 @@ 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); } } 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()); } @@ -188,6 +207,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) { @@ -208,10 +236,16 @@ 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) + && ALLOWED_PASSTHRU_HEADERS.stream().anyMatch(allowed -> allowed.equalsIgnoreCase(headerName)); + } + private void addPassThruContextValuesToHttpHeaders(HttpHeaders httpHeaders, String context) { if (null != request.getAttribute(context)) { if (httpHeaders.containsKey(context)) { @@ -225,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 @@ -247,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 != null ? request.getAttribute(CLIENT_CONTEXT) : null; + 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; @@ -260,8 +295,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 new file mode 100644 index 0000000000..c2b0db8865 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidator.java @@ -0,0 +1,116 @@ +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 uk.gov.hmcts.ccd.util.CallbackHostPatternMatcher; + +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 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; + + 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) { + try { + CallbackHostPatternMatcher.validateEntries(allowedHosts); + return CallbackHostPatternMatcher.containsHost(host, allowedHosts); + } catch (IllegalArgumentException ex) { + throw new CallbackException(ex.getMessage()); + } + } + + private boolean hostMatches(String host, String allowedHost) { + return CallbackHostPatternMatcher.matches(host, allowedHost); + } + + 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/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/main/resources/application.properties b/src/main/resources/application.properties index d597782b5a..0629e52f87 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -284,6 +284,13 @@ 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} # 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/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/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/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/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"); + } +} 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..9c407b3162 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCallbackValidationTest.java @@ -0,0 +1,153 @@ +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 java.util.UUID; + +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.doAnswer; +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"); + } + + @Test + @DisplayName("should fail case type retrieval when callback placeholder is unresolved") + 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")); + 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) { + 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/DefaultCaseDefinitionRepositoryCoreTest.java b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java new file mode 100644 index 0000000000..27b10a33f3 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/data/definition/DefaultCaseDefinitionRepositoryCoreTest.java @@ -0,0 +1,292 @@ +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.test.util.ReflectionTestUtils; +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.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; +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 + @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(); + 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 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() { + 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 + @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")) + .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); + } + + @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() { + assertDoesNotThrow( + () -> 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/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/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/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..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 @@ -49,17 +49,19 @@ 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; 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; 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; @@ -79,6 +81,8 @@ class CallbackServiceTest { private Jwt principal; @Mock private ObjectMapper objectMapper; + @Mock + private CallbackUrlValidator callbackUrlValidator; @Captor private ArgumentCaptor argument; @@ -149,12 +153,42 @@ void setUp() { initSecurityContext(); callbackService = new CallbackService(securityUtils, restTemplate, applicationParams, appinsights, request, - objectMapper); + objectMapper, callbackUrlValidator); final ResponseEntity responseEntity = new ResponseEntity<>(callbackResponse, HttpStatus.OK); 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()); + 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<>(); @@ -194,6 +228,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 { @@ -280,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 { @@ -294,14 +442,32 @@ 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))); } + @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 { @@ -326,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() { 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..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 @@ -474,13 +474,19 @@ 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 CallbackUrlValidator callbackUrlValidator = new CallbackUrlValidator(applicationParams); final CallbackService underTest = new CallbackService(Mockito.mock(SecurityUtils.class), restTemplate, - Mockito.mock(ApplicationParams.class), Mockito.mock(AppInsights.class), - Mockito.mock(HttpServletRequest.class), Mockito.mock(ObjectMapper.class)); + applicationParams, Mockito.mock(AppInsights.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/callbacks/CallbackUrlValidatorTest.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java new file mode 100644 index 0000000000..bd63c6d841 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/callbacks/CallbackUrlValidatorTest.java @@ -0,0 +1,180 @@ +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.net.InetAddress; +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 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")); + 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")); + } + + @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 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")); + } + + @Test + void shouldThrowWhenHostResolutionFailsInPrivateAddressCheck() { + CallbackException exception = assertThrows(CallbackException.class, + () -> ReflectionTestUtils.invokeMethod(subject, "resolvesToPrivateAddress", "%%%")); + assertTrue(exception.getMessage().contains("Unable to resolve callback host")); + } + +} 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"; 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/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..ebd0b43b1a --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/util/CallbackHostPatternMatcherTest.java @@ -0,0 +1,84 @@ +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 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")); + 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")); + } + + @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"); + } +} 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))); 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