From 9f30d4c566dbb6a9b6106a2d12db57e7c597876b Mon Sep 17 00:00:00 2001 From: Jason Benedicic <48251655+jabenedicic@users.noreply.github.com> Date: Mon, 18 May 2026 13:47:08 +0100 Subject: [PATCH 1/2] fix(flagd): validate FLAGD_SYNC_PORT before parse to avoid kubelet service-link collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FlagdOptions.prebuild()` reads `FLAGD_SYNC_PORT` first in `in-process` resolver mode (introduced in #1651), falling back to `FLAGD_PORT` only when unset. Kubernetes service-link environment variable injection populates `FLAGD_SYNC_PORT` with `tcp://:` whenever a Service named `flagd-sync` shares the pod's namespace — which is exactly the topology produced by the upstream flagd Helm chart and the OpenFeature Operator's in-process configuration pattern. The SDK then calls `Integer.parseInt("tcp://...")` and dies at startup with `NumberFormatException`, breaking JVM consumers using `FLAGD_RESOLVER=in-process`. Validate the env var parses as a port in `[1, 65535]` before using it. On invalid input, log at WARN with a pointer to the most likely root cause (service-link injection) and fall back to `FLAGD_PORT`. Behaviour for valid, user-set `FLAGD_SYNC_PORT` is unchanged. The Node.js and Go in-process providers don't read `FLAGD_SYNC_PORT` at all, so this collision is Java-specific; users on those SDKs aren't affected today. A future change may want to revisit whether the SDK should read this env var at all given it's already reserved by the flagd daemon's own `--sync-port` CLI flag, but validation is the minimal change that unblocks affected users without altering the fallback chain. Signed-off-by: Jason Benedicic <48251655+jabenedicic@users.noreply.github.com> --- .../contrib/providers/flagd/FlagdOptions.java | 42 +++++++++++++++++-- .../providers/flagd/FlagdOptionsTest.java | 35 ++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java index 673c9dcea..637781d3b 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java @@ -17,6 +17,7 @@ import jdk.jfr.Experimental; import lombok.Builder; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; /** @@ -24,6 +25,7 @@ */ @Builder(toBuilder = true) @Getter +@Slf4j @SuppressWarnings("PMD.TooManyStaticImports") public class FlagdOptions { @@ -314,9 +316,31 @@ void prebuild() { String defaultPort = determineDefaultPortForResolver(); String fromPortEnv = fallBackToEnvOrDefault(Config.PORT_ENV_VAR_NAME, defaultPort); - String portValue = resolverType == Config.Resolver.IN_PROCESS - ? fallBackToEnvOrDefault(Config.SYNC_PORT_ENV_VAR_NAME, fromPortEnv) - : fromPortEnv; + String portValue = fromPortEnv; + if (resolverType == Config.Resolver.IN_PROCESS) { + String syncPortValue = fallBackToEnvOrDefault(Config.SYNC_PORT_ENV_VAR_NAME, null); + if (syncPortValue != null) { + if (isValidPort(syncPortValue)) { + portValue = syncPortValue; + } else { + // FLAGD_SYNC_PORT is unset by the user but populated with a non-numeric + // value — most commonly Kubernetes service-link env injection from a + // Service named `flagd-sync` in the pod's namespace, which produces + // `FLAGD_SYNC_PORT=tcp://:`. Fall back to FLAGD_PORT + // rather than failing at parse time. + log.warn( + "Ignoring {} value '{}' (not a valid port); falling back to {} ('{}'). " + + "This commonly indicates Kubernetes service-link environment " + + "variable injection from a Service named 'flagd-sync' in the " + + "pod's namespace; set enableServiceLinks: false on the pod " + + "template or rename the Service to avoid the collision.", + Config.SYNC_PORT_ENV_VAR_NAME, + syncPortValue, + Config.PORT_ENV_VAR_NAME, + fromPortEnv); + } + } + } port = Integer.parseInt(portValue); } @@ -329,4 +353,16 @@ private String determineDefaultPortForResolver() { return Config.DEFAULT_IN_PROCESS_PORT; } } + + private static boolean isValidPort(String value) { + if (value == null) { + return false; + } + try { + int parsed = Integer.parseInt(value); + return parsed > 0 && parsed <= 65535; + } catch (NumberFormatException e) { + return false; + } + } } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java index 9468e9a7f..86d2a56a3 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java @@ -237,6 +237,41 @@ void testInProcessProvider_syncPortTakesPrecedenceOverFlagdPort() { assertThat(flagdOptions.getPort()).isEqualTo(9999); } + @Test + @SetEnvironmentVariable(key = RESOLVER_ENV_VAR, value = RESOLVER_IN_PROCESS) + @SetEnvironmentVariable(key = PORT_ENV_VAR_NAME, value = "8888") + @SetEnvironmentVariable(key = SYNC_PORT_ENV_VAR_NAME, value = "tcp://10.0.0.1:8015") + void testInProcessProvider_invalidSyncPortFallsBackToFlagdPort() { + // Kubernetes service-link injection populates FLAGD_SYNC_PORT with a URL like + // tcp://: when a Service named flagd-sync shares the pod's + // namespace. The SDK must not fail on this; it should fall back to FLAGD_PORT. + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.getResolverType()).isEqualTo(Resolver.IN_PROCESS); + assertThat(flagdOptions.getPort()).isEqualTo(8888); + } + + @Test + @SetEnvironmentVariable(key = RESOLVER_ENV_VAR, value = RESOLVER_IN_PROCESS) + @SetEnvironmentVariable(key = SYNC_PORT_ENV_VAR_NAME, value = "tcp://10.0.0.1:8015") + void testInProcessProvider_invalidSyncPortWithNoFlagdPortUsesDefault() { + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.getResolverType()).isEqualTo(Resolver.IN_PROCESS); + assertThat(flagdOptions.getPort()).isEqualTo(Integer.parseInt(DEFAULT_IN_PROCESS_PORT)); + } + + @Test + @SetEnvironmentVariable(key = RESOLVER_ENV_VAR, value = RESOLVER_IN_PROCESS) + @SetEnvironmentVariable(key = PORT_ENV_VAR_NAME, value = "8888") + @SetEnvironmentVariable(key = SYNC_PORT_ENV_VAR_NAME, value = "99999") + void testInProcessProvider_outOfRangeSyncPortFallsBackToFlagdPort() { + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.getResolverType()).isEqualTo(Resolver.IN_PROCESS); + assertThat(flagdOptions.getPort()).isEqualTo(8888); + } + @Test @SetEnvironmentVariable(key = RESOLVER_ENV_VAR, value = RESOLVER_RPC) void testRpcProviderFromEnv_noPortConfigured_defaultsToCorrectPort() { From 5db2d509a72ad0fce0fc01197ddae74a32d09e31 Mon Sep 17 00:00:00 2001 From: Jason Benedicic <48251655+jabenedicic@users.noreply.github.com> Date: Mon, 18 May 2026 14:01:41 +0100 Subject: [PATCH 2/2] fix(flagd): also guard final port parse against invalid FLAGD_PORT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on #1798: the final `Integer.parseInt(portValue)` in `FlagdOptions.prebuild()` could still throw if `FLAGD_PORT` itself is invalid — same Kubernetes service-link injection pattern that hits the in-process branch can hit RPC-mode consumers when a Service named `flagd` exists in the pod's namespace, producing `FLAGD_PORT=tcp://...`. Validate `portValue` before the final parse and fall back to the resolver's default port on invalid input, with a WARN log to surface the misconfiguration rather than silently masking it. Adds two test cases: - RPC mode with invalid `FLAGD_PORT` → default RPC port. - In-process mode with both env vars invalid → default in-process port. Signed-off-by: Jason Benedicic <48251655+jabenedicic@users.noreply.github.com> --- .../contrib/providers/flagd/FlagdOptions.java | 13 +++++++++++ .../providers/flagd/FlagdOptionsTest.java | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java index 637781d3b..1859100d1 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java @@ -342,6 +342,19 @@ void prebuild() { } } + if (!isValidPort(portValue)) { + // Last-line-of-defence: FLAGD_PORT itself can be polluted by Kubernetes + // service-link injection too if a Service named `flagd` exists in the + // pod's namespace (FLAGD_PORT=tcp://:8013), which would + // affect RPC-mode consumers identically. Fall back to the resolver's + // default port rather than throwing at parse time. + log.warn( + "Configured port value '{}' is not a valid port; falling back to default '{}'.", + portValue, + defaultPort); + portValue = defaultPort; + } + port = Integer.parseInt(portValue); } } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java index 86d2a56a3..f6c3cab2f 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java @@ -272,6 +272,29 @@ void testInProcessProvider_outOfRangeSyncPortFallsBackToFlagdPort() { assertThat(flagdOptions.getPort()).isEqualTo(8888); } + @Test + @SetEnvironmentVariable(key = RESOLVER_ENV_VAR, value = RESOLVER_RPC) + @SetEnvironmentVariable(key = PORT_ENV_VAR_NAME, value = "tcp://10.0.0.1:8013") + void testRpcProvider_invalidFlagdPortFallsBackToDefault() { + // RPC-mode equivalent of the in-process collision: if a Service named `flagd` + // shares the pod's namespace, kubelet injects FLAGD_PORT=tcp://:8013. + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.getResolverType()).isEqualTo(Resolver.RPC); + assertThat(flagdOptions.getPort()).isEqualTo(Integer.parseInt(DEFAULT_RPC_PORT)); + } + + @Test + @SetEnvironmentVariable(key = RESOLVER_ENV_VAR, value = RESOLVER_IN_PROCESS) + @SetEnvironmentVariable(key = PORT_ENV_VAR_NAME, value = "tcp://10.0.0.1:8013") + @SetEnvironmentVariable(key = SYNC_PORT_ENV_VAR_NAME, value = "tcp://10.0.0.1:8015") + void testInProcessProvider_bothPortEnvsInvalidFallsBackToDefault() { + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.getResolverType()).isEqualTo(Resolver.IN_PROCESS); + assertThat(flagdOptions.getPort()).isEqualTo(Integer.parseInt(DEFAULT_IN_PROCESS_PORT)); + } + @Test @SetEnvironmentVariable(key = RESOLVER_ENV_VAR, value = RESOLVER_RPC) void testRpcProviderFromEnv_noPortConfigured_defaultsToCorrectPort() {