Skip to content

Commit 6a7e446

Browse files
fix(security): hard production guard on insecure-TLS escape hatch + workflow perms + example SDK bump
Three security alerts on axonflow-sdk-java: 1. CodeQL java/insecure-trustmanager (#8, high) at HttpClientFactory.java. The trust-all path is an intentional, double-gated development escape hatch for self-signed certs (requires BOTH insecureSkipVerify(true) AND AXONFLOW_INSECURE_TLS env var). Rather than remove the dev affordance, this adds a hard production guard: when a production-like deployment environment is detected (ENVIRONMENT / AXONFLOW_ENVIRONMENT / APP_ENV / SPRING_PROFILES_ACTIVE / NODE_ENV / ... carrying a 'prod'/'production' token), the insecure path is REFUSED outright, TLS verification stays enabled, and a SECURITY error is logged naming the signalling variable. New tests prove the guard PREVENTS the insecure path (not just logs), handles compound values (prod,metrics), and does not false-positive on non-production values. Alert to be dismissed-by-design on #2711 now that the production path is belt-and-suspenders hardened. Real code change -> VERSION bumped 8.5.0 -> 8.5.1 + CHANGELOG. 2. CodeQL actions/missing-workflow-permissions (#9, med) on heartbeat-real-stack.yml: added a top-level least-privilege 'permissions: contents: read' block (workflow only checks out + tests). Block kept identical to the axonflow-sdk-typescript fix for coherence. 3. Dependabot #2 (med): examples/wcp-retry-idempotency/pom.xml pinned a stale com.getaxonflow:axonflow-sdk 5.5.0 (vulnerable < 6.0.0); bumped to the current released 8.5.0. Refs getaxonflow/axonflow-enterprise#2711 Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
1 parent ea1ae3d commit 6a7e446

6 files changed

Lines changed: 261 additions & 6 deletions

File tree

.github/workflows/heartbeat-real-stack.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ concurrency:
1919
env:
2020
AXONFLOW_TELEMETRY: 'off'
2121

22+
# Least-privilege token: this workflow only checks out the repo and runs
23+
# tests. No write access to repo contents, packages, or pull requests is
24+
# required.
25+
permissions:
26+
contents: read
27+
2228
jobs:
2329
real-stack:
2430
name: real-stack ${{ matrix.os }}

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to the AxonFlow Java SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [8.5.1] - 2026-06-16: TLS security hardening (production guard)
9+
10+
Patch release. No public API changes.
11+
12+
### Security
13+
14+
- **Hard production guard on the insecure-TLS escape hatch.**
15+
`HttpClientFactory` already required BOTH `insecureSkipVerify(true)` on the
16+
config builder AND the `AXONFLOW_INSECURE_TLS` environment variable before it
17+
would disable TLS certificate verification (a development-only path for
18+
self-signed certificates). It now additionally refuses to disable verification
19+
whenever a production-like deployment environment is detected, even if both
20+
gates are set. A production environment is signalled by any of a set of common
21+
deployment environment variables (for example `ENVIRONMENT`,
22+
`AXONFLOW_ENVIRONMENT`, `APP_ENV`, `SPRING_PROFILES_ACTIVE`, `NODE_ENV`)
23+
carrying a `prod` or `production` token. When the guard trips, TLS certificate
24+
verification remains enabled and a `SECURITY` error is logged naming the
25+
signalling variable. This is belt-and-suspenders hardening; the development
26+
escape hatch for local self-signed certificates is unchanged. Clears CodeQL
27+
`java/insecure-trustmanager` (alert #8).
28+
829
## [8.5.0] - 2026-06-09 — Decision Mode PEP: decide → fulfill → forward
930

1031
Adds the SDK analog of the platform PEP client (`platform/shared/pep`, ADR-056,

examples/wcp-retry-idempotency/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
<dependency>
2323
<groupId>com.getaxonflow</groupId>
2424
<artifactId>axonflow-sdk</artifactId>
25-
<version>5.5.0</version>
25+
<version>8.5.0</version>
2626
</dependency>
2727
</dependencies>
2828

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.getaxonflow</groupId>
88
<artifactId>axonflow-sdk</artifactId>
9-
<version>8.5.0</version>
9+
<version>8.5.1</version>
1010
<packaging>jar</packaging>
1111

1212
<name>AxonFlow Java SDK</name>

src/main/java/com/getaxonflow/sdk/util/HttpClientFactory.java

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import com.getaxonflow.sdk.AxonFlowConfig;
1919
import java.security.SecureRandom;
2020
import java.security.cert.X509Certificate;
21+
import java.util.Locale;
22+
import java.util.Set;
2123
import java.util.concurrent.TimeUnit;
2224
import javax.net.ssl.SSLContext;
2325
import javax.net.ssl.TrustManager;
@@ -40,6 +42,50 @@ public final class HttpClientFactory {
4042
*/
4143
static final String INSECURE_TLS_ENV_VAR = "AXONFLOW_INSECURE_TLS";
4244

45+
/**
46+
* Common deployment-environment variables inspected by {@link #detectedProductionEnvSignal()}. If
47+
* any is set to a production token ({@code "prod"}/{@code "production"}, case-insensitive), the
48+
* insecure TLS path is refused outright as a hard production guard, even when both {@code
49+
* insecureSkipVerify(true)} and {@value #INSECURE_TLS_ENV_VAR} are set. This is
50+
* belt-and-suspenders on top of the existing double-gate: the trust-all path is a
51+
* development-only escape hatch for self-signed certificates and must never run in production,
52+
* even by operator error.
53+
*/
54+
private static final String[] DEPLOYMENT_ENV_VARS = {
55+
"AXONFLOW_ENVIRONMENT",
56+
"AXONFLOW_ENV",
57+
"ENVIRONMENT",
58+
"APP_ENV",
59+
"APPLICATION_ENV",
60+
"DEPLOY_ENV",
61+
"DEPLOYMENT_ENV",
62+
"STAGE",
63+
"SPRING_PROFILES_ACTIVE",
64+
"NODE_ENV",
65+
"RAILS_ENV",
66+
"RACK_ENV",
67+
"DJANGO_ENV",
68+
"FLASK_ENV",
69+
"ASPNETCORE_ENVIRONMENT",
70+
};
71+
72+
/**
73+
* Token delimiters used to split a deployment-environment variable's value before matching
74+
* production tokens. Includes whitespace, comma/semicolon/colon (e.g. {@code
75+
* SPRING_PROFILES_ACTIVE=prod,metrics}) AND hyphen/underscore/dot/slash so common compound names
76+
* such as {@code production-us}, {@code prod_west} or {@code us/east/prod} are tokenised
77+
* correctly.
78+
*/
79+
private static final String ENV_VALUE_TOKEN_DELIMITERS = "[\\s,;:._/-]+";
80+
81+
/**
82+
* Tokens that, when they immediately precede a {@code prod}/{@code production} token, negate it
83+
* so the guard does NOT trip. This keeps non-production labels such as {@code non-prod}, {@code
84+
* not-prod} and {@code pre-prod} (which tokenise to {@code [non, prod]} etc.) usable with the
85+
* development escape hatch.
86+
*/
87+
private static final Set<String> NEGATION_PREFIXES = Set.of("non", "not", "pre", "no");
88+
4389
private HttpClientFactory() {
4490
// Utility class
4591
}
@@ -65,7 +111,23 @@ public static OkHttpClient create(AxonFlowConfig config) {
65111

66112
if (config.isInsecureSkipVerify()) {
67113
if (isInsecureTlsEnvVarEnabled()) {
68-
configureInsecureSsl(builder);
114+
String productionSignal = detectedProductionEnvSignal();
115+
if (productionSignal != null) {
116+
// Hard production guard: refuse to disable TLS verification when a production-like
117+
// deployment environment is detected, even though both gates were set. Keep the default
118+
// (verifying) TrustManager in place.
119+
logger.error(
120+
"*** SECURITY *** insecureSkipVerify(true) and {}=true were both set, but a "
121+
+ "production-like deployment environment was detected ({}). REFUSING to disable "
122+
+ "TLS certificate verification. The insecure trust-all path is a development-only "
123+
+ "escape hatch for self-signed certificates and MUST NOT run in production. TLS "
124+
+ "certificate verification REMAINS ENABLED. Unset the production environment "
125+
+ "signal (or run against a non-production environment) to use insecureSkipVerify.",
126+
INSECURE_TLS_ENV_VAR,
127+
productionSignal);
128+
} else {
129+
configureInsecureSsl(builder);
130+
}
69131
} else {
70132
logger.warn(
71133
"insecureSkipVerify(true) was set on AxonFlowConfig but environment variable {} is "
@@ -157,4 +219,50 @@ static boolean isInsecureTlsEnvVarEnabled() {
157219
}
158220
return "true".equalsIgnoreCase(value) || "1".equals(value);
159221
}
222+
223+
/**
224+
* Returns a human-readable {@code "VAR=value"} description of the first {@link
225+
* #DEPLOYMENT_ENV_VARS deployment environment variable} that signals a production-like
226+
* environment, or {@code null} if none do.
227+
*
228+
* <p>A variable signals production when any token of its value (split on whitespace, comma,
229+
* semicolon, colon, hyphen, underscore, dot or slash) equals (case-insensitively) {@code "prod"}
230+
* or {@code "production"}. Tokenising on those delimiters handles compound settings such as
231+
* {@code SPRING_PROFILES_ACTIVE=prod,metrics} and {@code ENVIRONMENT=production-us}. A {@code
232+
* prod}/{@code production} token immediately preceded by a {@link #NEGATION_PREFIXES negation
233+
* prefix} (for example {@code non-prod} or {@code pre-prod}) does NOT count, so non-production
234+
* labels remain usable with the development escape hatch.
235+
*
236+
* <p>Package-private to allow test verification.
237+
*/
238+
static String detectedProductionEnvSignal() {
239+
for (String var : DEPLOYMENT_ENV_VARS) {
240+
String value = System.getenv(var);
241+
if (value == null || value.isEmpty()) {
242+
continue;
243+
}
244+
String[] tokens = value.toLowerCase(Locale.ROOT).split(ENV_VALUE_TOKEN_DELIMITERS);
245+
for (int i = 0; i < tokens.length; i++) {
246+
if (!tokens[i].equals("prod") && !tokens[i].equals("production")) {
247+
continue;
248+
}
249+
if (i > 0 && NEGATION_PREFIXES.contains(tokens[i - 1])) {
250+
// Negated form such as non-prod / pre-prod: not a production signal.
251+
continue;
252+
}
253+
return var + "=" + value;
254+
}
255+
}
256+
return null;
257+
}
258+
259+
/**
260+
* Returns {@code true} if any common deployment environment variable signals a production-like
261+
* environment. Used as a hard production guard: even when both {@code insecureSkipVerify(true)}
262+
* and {@value #INSECURE_TLS_ENV_VAR} are set, TLS certificate verification is NOT disabled in a
263+
* production-like environment. Package-private to allow test verification.
264+
*/
265+
static boolean isProductionLikeEnvironment() {
266+
return detectedProductionEnvSignal() != null;
267+
}
160268
}

src/test/java/com/getaxonflow/sdk/util/HttpClientFactoryTest.java

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ void shouldActivateInsecurePathWhenBothGatesArePresent() {
130130
// (covered by sibling tests). If either gate stops being required, this test will still
131131
// pass — the gating behaviour is asserted in the negative-path tests.
132132
AxonFlowConfig config =
133-
AxonFlowConfig.builder().agentUrl("https://localhost:8080").insecureSkipVerify(true).build();
133+
AxonFlowConfig.builder()
134+
.agentUrl("https://localhost:8080")
135+
.insecureSkipVerify(true)
136+
.build();
134137

135138
OkHttpClient client = HttpClientFactory.create(config);
136139

@@ -168,8 +171,7 @@ void shouldKeepDefaultPathWhenOnlyEnvVarIsSet() {
168171
// Complementary negative path: env var alone, without insecureSkipVerify(true), must
169172
// keep the default safe TrustManager and OkHostnameVerifier. The double-gate is symmetric:
170173
// BOTH must be present — neither alone activates the insecure path.
171-
AxonFlowConfig config =
172-
AxonFlowConfig.builder().agentUrl("https://localhost:8080").build();
174+
AxonFlowConfig config = AxonFlowConfig.builder().agentUrl("https://localhost:8080").build();
173175

174176
OkHttpClient client = HttpClientFactory.create(config);
175177

@@ -178,4 +180,122 @@ void shouldKeepDefaultPathWhenOnlyEnvVarIsSet() {
178180
.as("default hostname verifier must remain when only env var is set")
179181
.contains("OkHostnameVerifier");
180182
}
183+
184+
@Test
185+
@DisplayName(
186+
"should REFUSE the insecure TLS path when both gates are set but a production environment "
187+
+ "is detected (hard production guard)")
188+
@SetEnvironmentVariable(key = "AXONFLOW_INSECURE_TLS", value = "true")
189+
@SetEnvironmentVariable(key = "ENVIRONMENT", value = "production")
190+
void shouldRefuseInsecurePathInProductionEnvironment() {
191+
// Belt-and-suspenders: even with BOTH insecureSkipVerify(true) AND AXONFLOW_INSECURE_TLS=true,
192+
// a production-like deployment environment must hard-refuse the trust-all path. This asserts
193+
// the guard actually PREVENTS the insecure path (not merely logs): the default verifying
194+
// hostname verifier MUST remain installed.
195+
AxonFlowConfig config =
196+
AxonFlowConfig.builder()
197+
.agentUrl("https://localhost:8080")
198+
.insecureSkipVerify(true)
199+
.build();
200+
201+
OkHttpClient client = HttpClientFactory.create(config);
202+
203+
assertThat(client).isNotNull();
204+
205+
// Definitive proof the insecure path was refused: the insecure path installs the permissive
206+
// `(hostname, session) -> true` verifier (a synthetic lambda whose class name does NOT contain
207+
// "OkHostnameVerifier"). Seeing OkHttp's default verifier means the trust-all path never ran.
208+
assertThat(client.hostnameVerifier().getClass().getName())
209+
.as(
210+
"production guard must keep the default verifying hostname verifier, refusing trust-all")
211+
.contains("OkHostnameVerifier");
212+
213+
// Parity with a plain default client: the guard must leave the SAME verifier type in place as a
214+
// config that never requested insecureSkipVerify at all. (OkHttp lazily builds a fresh default
215+
// SSLSocketFactory per client, so the socket-factory instances are NOT identity-equal even on
216+
// the safe path; the verifier type is the stable, meaningful signal here.)
217+
AxonFlowConfig defaultConfig =
218+
AxonFlowConfig.builder().agentUrl("https://localhost:8080").build();
219+
OkHttpClient defaultClient = HttpClientFactory.create(defaultConfig);
220+
assertThat(client.hostnameVerifier().getClass())
221+
.as("production guard must leave the default verifier type in place")
222+
.isEqualTo(defaultClient.hostnameVerifier().getClass());
223+
}
224+
225+
@Test
226+
@DisplayName("production-guard helper should be false in a clean (non-production) environment")
227+
void productionGuardShouldBeFalseByDefault() {
228+
// The standard CI/dev shell sets none of the inspected deployment env vars to a production
229+
// token; if this fails, the test environment is mislabelled as production.
230+
assertThat(HttpClientFactory.isProductionLikeEnvironment())
231+
.as("no production signal should be present in a clean test environment")
232+
.isFalse();
233+
assertThat(HttpClientFactory.detectedProductionEnvSignal()).isNull();
234+
}
235+
236+
@Test
237+
@DisplayName("production-guard helper should detect ENVIRONMENT=production")
238+
@SetEnvironmentVariable(key = "ENVIRONMENT", value = "production")
239+
void productionGuardShouldDetectEnvironmentProduction() {
240+
assertThat(HttpClientFactory.isProductionLikeEnvironment()).isTrue();
241+
assertThat(HttpClientFactory.detectedProductionEnvSignal()).isEqualTo("ENVIRONMENT=production");
242+
}
243+
244+
@Test
245+
@DisplayName(
246+
"production-guard helper should detect a compound SPRING_PROFILES_ACTIVE=prod,metrics")
247+
@SetEnvironmentVariable(key = "SPRING_PROFILES_ACTIVE", value = "prod,metrics")
248+
void productionGuardShouldDetectCompoundSpringProfile() {
249+
// Compound, delimiter-separated values must be tokenised so a 'prod' token is still caught.
250+
assertThat(HttpClientFactory.isProductionLikeEnvironment()).isTrue();
251+
assertThat(HttpClientFactory.detectedProductionEnvSignal())
252+
.isEqualTo("SPRING_PROFILES_ACTIVE=prod,metrics");
253+
}
254+
255+
@Test
256+
@DisplayName("production-guard helper should NOT trip on a non-production value")
257+
@SetEnvironmentVariable(key = "ENVIRONMENT", value = "development")
258+
void productionGuardShouldNotTripOnNonProductionValue() {
259+
// A non-production environment must still allow the development escape hatch: the guard must
260+
// not false-positive on substrings like 'reproduction' or unrelated values.
261+
assertThat(HttpClientFactory.isProductionLikeEnvironment()).isFalse();
262+
assertThat(HttpClientFactory.detectedProductionEnvSignal()).isNull();
263+
}
264+
265+
@Test
266+
@DisplayName("production-guard helper should detect a hyphen-delimited ENVIRONMENT=production-us")
267+
@SetEnvironmentVariable(key = "ENVIRONMENT", value = "production-us")
268+
void productionGuardShouldDetectHyphenDelimitedProductionName() {
269+
// AxonFlow's own deployments use hyphen-delimited names like 'axonflow-production-us'; the
270+
// tokeniser must split on '-' so the embedded production token is caught.
271+
assertThat(HttpClientFactory.isProductionLikeEnvironment()).isTrue();
272+
assertThat(HttpClientFactory.detectedProductionEnvSignal())
273+
.isEqualTo("ENVIRONMENT=production-us");
274+
}
275+
276+
@Test
277+
@DisplayName("production-guard helper should detect an underscore-delimited APP_ENV=prod_west")
278+
@SetEnvironmentVariable(key = "APP_ENV", value = "prod_west")
279+
void productionGuardShouldDetectUnderscoreDelimitedProdName() {
280+
assertThat(HttpClientFactory.isProductionLikeEnvironment()).isTrue();
281+
assertThat(HttpClientFactory.detectedProductionEnvSignal()).isEqualTo("APP_ENV=prod_west");
282+
}
283+
284+
@Test
285+
@DisplayName("production-guard helper should NOT trip on a negated ENVIRONMENT=non-prod")
286+
@SetEnvironmentVariable(key = "ENVIRONMENT", value = "non-prod")
287+
void productionGuardShouldNotTripOnNegatedNonProd() {
288+
// 'non-prod' tokenises to [non, prod]; the 'non' negation prefix must keep the development
289+
// escape hatch usable in a non-production environment.
290+
assertThat(HttpClientFactory.isProductionLikeEnvironment()).isFalse();
291+
assertThat(HttpClientFactory.detectedProductionEnvSignal()).isNull();
292+
}
293+
294+
@Test
295+
@DisplayName("production-guard helper should NOT trip on a negated SPRING profile pre-prod")
296+
@SetEnvironmentVariable(key = "SPRING_PROFILES_ACTIVE", value = "pre-prod,metrics")
297+
void productionGuardShouldNotTripOnNegatedPreProd() {
298+
assertThat(HttpClientFactory.isProductionLikeEnvironment()).isFalse();
299+
assertThat(HttpClientFactory.detectedProductionEnvSignal()).isNull();
300+
}
181301
}

0 commit comments

Comments
 (0)