Skip to content

Commit b61a101

Browse files
committed
Production hardening: headers, CORS, SCA, graceful shutdown, helm wiring
A defensive sweep — none of the individual changes is large, but together they close most of the "expected for any production service" gaps from the audit. Security headers (#8) - SecurityConfig now explicitly configures HSTS: max-age 2 years (OWASP baseline), includeSubDomains. Spring Security 6's defaults already provide X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and no-cache Cache-Control on auth-bearing responses; the explicit headers(...) block locks them in so a future refactor can't drop them silently. CORS posture (#9) - New CorsConfigurationSource bean registered with an empty CorsConfiguration. Server-to-server API; browsers should never reach these endpoints, and the empty allow-list makes that intent explicit rather than implied. An institution that ever builds a browser-based admin console overrides the bean with their own allow-list — no other code changes. Dependency scanning (#7) - .github/dependabot.yml — weekly Maven + GitHub Actions updates, grouped by ecosystem (spring-boot, testcontainers, resilience4j) so review cost scales with meaningful changes, not individual packages. Major Spring Boot bumps are excluded — those are release decisions. - .github/workflows/dependency-check.yml — runs the OWASP dependency-check Maven plugin weekly and on PRs that touch pom.xml. Fails the build on any CVSS >= 7 (High / Critical), warns-only on Medium / Low. Caches the NVD database so subsequent runs are fast. Graceful shutdown (#10) - spring.lifecycle.timeout-per-shutdown-phase: 30s + server.shutdown: graceful in application.yml. On SIGTERM the listener refuses new requests and lets in-flight ones drain. Without this a half-processed pacs.008 would be cut at TCP close, leaving a saga at FUNDS_RESERVED with no acknowledgement to the originator. - helm/templates/deployment.yaml: terminationGracePeriodSeconds: 60 so Kubernetes doesn't SIGKILL the pod before the 30s drain completes. Helm wiring (#12) - values.yaml now surfaces fraud, rate-limit, saga timeout, idempotency, reconciliation batch size, balance seed accounts, and the RTP endpoint. - configmap.yaml wires those values to the corresponding environment variables the application reads (FRAUD_ENABLED, RATE_LIMIT_*, SAGA_TIMEOUT_*, IDEMPOTENCY_*, RECONCILIATION_BATCH_SIZE, SHADOW_LEDGER_SEED_ACCOUNTS, RTP_ENDPOINT). Coverage: - SecurityHeadersTest: 5 integration tests via TestRestTemplate verifying every response from a real port carries X-Content-Type-Options, X-Frame- Options DENY, Strict-Transport-Security (with the configured maxAge and includeSubDomains), Cache-Control with no-cache/no-store, and that unauthenticated endpoints get the same headers as authenticated ones.
1 parent 1c01770 commit b61a101

8 files changed

Lines changed: 316 additions & 0 deletions

File tree

.github/dependabot.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Dependabot keeps Maven dependencies and GitHub Actions versions current
2+
# so CVE patches land via PR rather than via manual sweeps. PRs are opened
3+
# weekly, grouped by ecosystem so a single update day's review burden is
4+
# bounded.
5+
6+
version: 2
7+
updates:
8+
# ── Maven ─────────────────────────────────────────────────────────────
9+
- package-ecosystem: "maven"
10+
directory: "/"
11+
schedule:
12+
interval: "weekly"
13+
day: "monday"
14+
open-pull-requests-limit: 10
15+
labels:
16+
- "dependencies"
17+
- "maven"
18+
# Group routine version bumps so review cost scales with the *number*
19+
# of meaningful changes, not the number of individual packages.
20+
groups:
21+
spring-boot:
22+
patterns:
23+
- "org.springframework.boot:*"
24+
- "org.springframework:*"
25+
testcontainers:
26+
patterns:
27+
- "org.testcontainers:*"
28+
resilience4j:
29+
patterns:
30+
- "io.github.resilience4j:*"
31+
# Major-version bumps for Spring Boot are not auto-PR'd — those are
32+
# release decisions, not dependency hygiene.
33+
ignore:
34+
- dependency-name: "org.springframework.boot:*"
35+
update-types: ["version-update:semver-major"]
36+
37+
# ── GitHub Actions ────────────────────────────────────────────────────
38+
- package-ecosystem: "github-actions"
39+
directory: "/"
40+
schedule:
41+
interval: "weekly"
42+
day: "monday"
43+
open-pull-requests-limit: 5
44+
labels:
45+
- "dependencies"
46+
- "github-actions"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: OWASP Dependency Check
2+
3+
# Runs the OWASP dependency-check Maven plugin against the project's
4+
# transitive dependency graph. The job is scheduled weekly because the
5+
# scan is slow (initial NVD download takes 5–10 minutes on a cold runner)
6+
# and also runs on pull requests that touch pom.xml so a new dependency
7+
# can't sneak in with an unaddressed CVE.
8+
9+
on:
10+
schedule:
11+
# Mondays 04:00 UTC — pairs with the Dependabot weekly cadence so that
12+
# by mid-week the team is reviewing the union of "dependencies updated"
13+
# PRs and "CVEs detected" reports.
14+
- cron: "0 4 * * 1"
15+
pull_request:
16+
branches: [ main ]
17+
paths:
18+
- "pom.xml"
19+
- ".github/workflows/dependency-check.yml"
20+
workflow_dispatch:
21+
22+
jobs:
23+
scan:
24+
name: Scan for known vulnerabilities
25+
runs-on: ubuntu-latest
26+
27+
steps:
28+
- name: Checkout
29+
uses: actions/checkout@v4
30+
31+
- name: Set up Java 17
32+
uses: actions/setup-java@v4
33+
with:
34+
java-version: '17'
35+
distribution: 'temurin'
36+
cache: maven
37+
38+
# The dependency-check plugin downloads the NVD CVE feed on first run
39+
# (~250MB). Cache it so subsequent runs only fetch the daily delta.
40+
- name: Cache NVD database
41+
uses: actions/cache@v4
42+
with:
43+
path: ~/.m2/repository/org/owasp/dependency-check-data
44+
key: dependency-check-data-${{ runner.os }}
45+
46+
- name: Run dependency-check
47+
# failBuildOnCVSS=7 trips the build on any High or Critical CVE
48+
# (CVSS 7.0–10.0). Medium and Low are reported but don't fail —
49+
# avoids alert fatigue while still catching the actually-dangerous
50+
# ones. format=HTML so reviewers can open the report directly.
51+
run: |
52+
mvn -B org.owasp:dependency-check-maven:check \
53+
-DfailBuildOnCVSS=7 \
54+
-Dformats=HTML,JSON \
55+
--no-transfer-progress
56+
57+
- name: Upload dependency-check report
58+
if: always()
59+
uses: actions/upload-artifact@v4
60+
with:
61+
name: dependency-check-report
62+
path: target/dependency-check-report.*

helm/templates/configmap.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,31 @@ data:
2929
# TLS keystore paths (files mounted from the tls Secret volume)
3030
TLS_KEYSTORE_PATH: {{ printf "%s/%s" .Values.tls.mountPath .Values.tls.keystoreFilename | quote }}
3131
FED_TRUSTSTORE_PATH: {{ printf "%s/%s" .Values.tls.mountPath .Values.tls.fedTruststoreFilename | quote }}
32+
33+
# RTP gateway (empty = SandboxRtpClient; set to TCH endpoint when onboarded)
34+
RTP_ENDPOINT: {{ .Values.openfednow.gateway.rtpEndpoint | quote }}
35+
36+
# Rate limiting on the FedNow / RTP transfer endpoints (issue #46)
37+
RATE_LIMIT_TRANSFERS_PER_SECOND: {{ .Values.openfednow.rateLimit.transfersPerSecond | toString | quote }}
38+
39+
# Saga timeout monitor (issue #37)
40+
SAGA_TIMEOUT_SECONDS: {{ .Values.openfednow.saga.timeoutSeconds | toString | quote }}
41+
SAGA_TIMEOUT_CHECK_INTERVAL_SECONDS: {{ .Values.openfednow.saga.timeoutCheckIntervalSeconds | toString | quote }}
42+
43+
# Idempotency record retention (issue #38)
44+
IDEMPOTENCY_TTL_HOURS: {{ .Values.openfednow.idempotency.ttlHours | toString | quote }}
45+
IDEMPOTENCY_CLEANUP_INTERVAL_MINUTES: {{ .Values.openfednow.idempotency.cleanupIntervalMinutes | toString | quote }}
46+
47+
# Reconciliation pagination (issue #51)
48+
RECONCILIATION_BATCH_SIZE: {{ .Values.openfednow.reconciliation.batchSize | toString | quote }}
49+
50+
# Shadow Ledger balance seeding (issue #39) — comma-separated account IDs
51+
SHADOW_LEDGER_SEED_ACCOUNTS: {{ .Values.openfednow.shadowLedger.seedAccounts | quote }}
52+
53+
# Fraud pre-screening (issue #47). Disabled by default; institutions plug in
54+
# their own FraudScreeningPort for production scoring.
55+
FRAUD_ENABLED: {{ .Values.openfednow.fraud.enabled | toString | quote }}
56+
FRAUD_MAX_AMOUNT: {{ .Values.openfednow.fraud.maxSingleTransferAmount | quote }}
57+
FRAUD_VELOCITY_MAX: {{ .Values.openfednow.fraud.velocity.maxPerWindow | toString | quote }}
58+
FRAUD_VELOCITY_WINDOW_SECONDS: {{ .Values.openfednow.fraud.velocity.windowSeconds | toString | quote }}
59+
FRAUD_ACCOUNT_DENYLIST: {{ .Values.openfednow.fraud.accountDenylist | quote }}

helm/templates/deployment.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ spec:
2626
{{- end }}
2727
serviceAccountName: {{ include "openfednow.serviceAccountName" . }}
2828

29+
# Must exceed spring.lifecycle.timeout-per-shutdown-phase (30s) so
30+
# Kubernetes does not SIGKILL the pod before the in-flight payment
31+
# drain completes. 60s gives the Spring graceful-shutdown window plus
32+
# headroom for the kubelet shutdown signal propagation.
33+
terminationGracePeriodSeconds: 60
34+
2935
securityContext:
3036
runAsNonRoot: true
3137
runAsUser: 1000

helm/values.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,49 @@ openfednow:
4242
adapter: fiserv
4343
gateway:
4444
fednowEndpoint: "https://connect.fednow.org"
45+
rtpEndpoint: "" # Set when TCH RTP onboarding is live; empty = sandbox client
4546
responseTimeoutSeconds: 18
4647

48+
# Per-client rate limiting on /fednow/** and /rtp/** POST endpoints. 429 +
49+
# Retry-After on excess. See ADR / docs for behavior.
50+
rateLimit:
51+
transfersPerSecond: 100
52+
53+
# Saga timeout monitor. Sweeps non-terminal sagas older than the threshold
54+
# and compensates with ISO 20022 XPIR.
55+
saga:
56+
timeoutSeconds: 30
57+
timeoutCheckIntervalSeconds: 10
58+
59+
# Idempotency retention for duplicate pacs.008 detection. FedNow's documented
60+
# retry window is 24h; the default 48h doubles it for clock-skew slack.
61+
idempotency:
62+
ttlHours: 48
63+
cleanupIntervalMinutes: 60
64+
65+
# Reconciliation pagination. Accounts with unconfirmed entries are scanned
66+
# in keyset-paginated batches so memory stays flat regardless of institution
67+
# scale (see #51).
68+
reconciliation:
69+
batchSize: 500
70+
71+
# Shadow Ledger balance seeding on startup. Comma-separated account IDs.
72+
# Empty by default; opt in once the operations team has confirmed which
73+
# accounts must be hydrated from the core on a fresh deployment.
74+
shadowLedger:
75+
seedAccounts: ""
76+
77+
# Fraud pre-screening. Disabled by default — institutions are expected to
78+
# plug in their own FraudScreeningPort for production. When enabled, the
79+
# rule-based default service applies amount cap + debtor velocity + denylist.
80+
fraud:
81+
enabled: false
82+
maxSingleTransferAmount: "25000.00"
83+
velocity:
84+
maxPerWindow: 10
85+
windowSeconds: 60
86+
accountDenylist: "" # Comma-separated account IDs
87+
4788
redis:
4889
host: redis-master
4990
port: 6379

src/main/java/io/openfednow/security/SecurityConfig.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import org.springframework.context.annotation.Bean;
66
import org.springframework.context.annotation.Configuration;
77
import org.springframework.core.env.Environment;
8+
import org.springframework.web.cors.CorsConfiguration;
9+
import org.springframework.web.cors.CorsConfigurationSource;
10+
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
811
import org.springframework.security.config.Customizer;
912
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1013
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -103,14 +106,52 @@ void verifyProdCredentials() {
103106
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
104107
http
105108
.csrf(csrf -> csrf.disable())
109+
// CORS uses our explicit deny-by-default configuration source —
110+
// see corsConfigurationSource() below.
111+
.cors(Customizer.withDefaults())
106112
.authorizeHttpRequests(auth -> auth
107113
.requestMatchers("/admin/**").hasRole("ADMIN")
108114
.anyRequest().permitAll()
109115
)
116+
// Spring Security 6 enables a default header set (X-Content-Type-Options,
117+
// X-Frame-Options DENY, Cache-Control, X-XSS-Protection: 0). HSTS is
118+
// opt-in — enable it explicitly so any TLS-terminated deployment instructs
119+
// compliant browsers to remember the HTTPS-only policy. The Ingress /
120+
// load balancer is the actual enforcer; this is defense in depth.
121+
.headers(headers -> headers
122+
.httpStrictTransportSecurity(hsts -> hsts
123+
.includeSubDomains(true)
124+
// 2 years — the OWASP recommended baseline; long enough that
125+
// browsers won't fall back to plaintext between visits.
126+
.maxAgeInSeconds(63_072_000)))
110127
.httpBasic(Customizer.withDefaults());
111128
return http.build();
112129
}
113130

131+
/**
132+
* CORS posture: <strong>deny by default</strong>.
133+
*
134+
* <p>OpenFedNow is a server-to-server API — FedNow and TCH submit requests
135+
* over mTLS, the institution's core banking adapter calls outbound. No
136+
* browser-origin client should ever hit these endpoints. The empty
137+
* configuration source registered here makes that intent explicit: there
138+
* is no allow-list, so every preflight is denied.
139+
*
140+
* <p>An institution that builds a browser-based admin console can override
141+
* this bean with their own {@link CorsConfigurationSource} carrying the
142+
* appropriate allow-list — no other framework code needs to change.
143+
*/
144+
@Bean
145+
public CorsConfigurationSource corsConfigurationSource() {
146+
// Returning a configuration with no allowed origins / methods / headers
147+
// means Spring Security responds to preflights with the request rejected.
148+
// A future admin UI override would replace this bean with one carrying
149+
// an institution-specific allow-list.
150+
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
151+
source.registerCorsConfiguration("/**", new CorsConfiguration());
152+
return source;
153+
}
154+
114155
@Bean
115156
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
116157
return new InMemoryUserDetailsManager(

src/main/resources/application.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@ spring:
22
application:
33
name: openfednow
44

5+
# Graceful shutdown: on SIGTERM, refuse new requests and let in-flight
6+
# ones complete before the JVM exits. Critical for payments — a half-
7+
# processed pacs.008 cut at TCP close would leave a saga at FUNDS_RESERVED
8+
# with no acknowledgement to the originator. The phase timeout caps the
9+
# drain at 30s; Kubernetes' preStop + terminationGracePeriodSeconds must
10+
# exceed this so the pod isn't SIGKILL'd before drain completes.
11+
lifecycle:
12+
timeout-per-shutdown-phase: 30s
13+
14+
server:
15+
# GRACEFUL is the trigger for spring.lifecycle.timeout-per-shutdown-phase.
16+
# IMMEDIATE (the default) would close the listener and cut every in-flight
17+
# request — unsafe for payments.
18+
shutdown: graceful
19+
520
# Shadow Ledger — Redis configuration
621
data:
722
redis:
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package io.openfednow.security;
2+
3+
import io.openfednow.infrastructure.AbstractInfrastructureIntegrationTest;
4+
import org.junit.jupiter.api.Test;
5+
import org.springframework.beans.factory.annotation.Autowired;
6+
import org.springframework.boot.test.context.SpringBootTest;
7+
import org.springframework.boot.test.web.client.TestRestTemplate;
8+
import org.springframework.http.HttpHeaders;
9+
import org.springframework.http.ResponseEntity;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
13+
/**
14+
* Verifies the HTTP security response headers configured in {@link SecurityConfig}.
15+
*
16+
* <p>Spring Security 6 enables the default header set automatically; this test
17+
* exists so a future refactor (e.g., switching to a custom header chain) can't
18+
* silently drop them. HSTS is configured explicitly and is verified here too.
19+
*
20+
* <p>Drives a real HTTP port via {@link TestRestTemplate} so the full filter
21+
* chain is exercised end-to-end.
22+
*/
23+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
24+
class SecurityHeadersTest extends AbstractInfrastructureIntegrationTest {
25+
26+
@Autowired
27+
private TestRestTemplate restTemplate;
28+
29+
@Test
30+
void responseHasXContentTypeOptionsHeader() {
31+
HttpHeaders headers = anyEndpointHeaders();
32+
assertThat(headers.getFirst("X-Content-Type-Options")).isEqualTo("nosniff");
33+
}
34+
35+
@Test
36+
void responseHasXFrameOptionsDeny() {
37+
HttpHeaders headers = anyEndpointHeaders();
38+
// Spring Security default: DENY
39+
assertThat(headers.getFirst("X-Frame-Options")).isEqualTo("DENY");
40+
}
41+
42+
@Test
43+
void responseHasStrictTransportSecurity() {
44+
HttpHeaders headers = anyEndpointHeaders();
45+
String hsts = headers.getFirst("Strict-Transport-Security");
46+
assertThat(hsts).isNotNull();
47+
// Configured to 2 years (63072000 seconds) with includeSubDomains
48+
assertThat(hsts).contains("max-age=63072000");
49+
assertThat(hsts).contains("includeSubDomains");
50+
}
51+
52+
@Test
53+
void responseHasCacheControl() {
54+
HttpHeaders headers = anyEndpointHeaders();
55+
// Spring Security defaults set no-cache directives so admin / auth responses
56+
// are not cached by intermediaries.
57+
String cacheControl = headers.getFirst("Cache-Control");
58+
assertThat(cacheControl).isNotNull();
59+
assertThat(cacheControl).containsAnyOf("no-cache", "no-store");
60+
}
61+
62+
@Test
63+
void healthEndpointAlsoCarriesSecurityHeaders() {
64+
// Even unauthenticated paths get the security headers — the chain is global.
65+
ResponseEntity<String> response = restTemplate.getForEntity("/fednow/health", String.class);
66+
HttpHeaders headers = response.getHeaders();
67+
assertThat(headers.getFirst("X-Content-Type-Options")).isEqualTo("nosniff");
68+
assertThat(headers.getFirst("X-Frame-Options")).isEqualTo("DENY");
69+
assertThat(headers.getFirst("Strict-Transport-Security")).isNotNull();
70+
}
71+
72+
private HttpHeaders anyEndpointHeaders() {
73+
// /actuator/health is permitAll-ed and stable across environments
74+
ResponseEntity<String> response = restTemplate.getForEntity("/actuator/health", String.class);
75+
return response.getHeaders();
76+
}
77+
}

0 commit comments

Comments
 (0)