Skip to content

Cherry-pick security fixes into release/1.2.0 (#512, #521, #516) - #523

Merged
ravjotbrar merged 3 commits into
release/1.2.0from
cherry/1.2.0-security-fixes
Sep 18, 2026
Merged

ravjotbrar merged 3 commits into
release/1.2.0from
cherry/1.2.0-security-fixes

Conversation

@ravjotbrar

Copy link
Copy Markdown
Collaborator

Cherry-picks the merged security fixes from main into release/1.2.0, in main's merge order (all picked clean with -x, no conflicts):

Not included: #522 (safeStorage fail-closed) — still open on main; will follow once merged.

Verification on this branch

  • Server: build ok, 258/258 tests
  • Frontend: build ok, 211/211 tests
  • npx eslint .: clean

* Fix K8s sidecar registration and stale cluster topology broadcast

Two coupled defects broke the Kubernetes metrics path and left the
Cluster Topology view stale:

1. Sidecar registration (regressed in #379): the register handler
   required a pre-existing metricsServerMap entry, but in K8s the
   orchestrator never spawns collectors, so externally-managed sidecars
   were always rejected (401/404). Registration is now allowed when the
   nodeId belongs to the discovered cluster topology, and the sidecar's
   entry is created on first register. Scoped to K8s so the strict
   no-entry gate is preserved for spawned (Web/Electron) collectors.

   Sidecars authenticate with a shared ORCHESTRATOR_KEY provisioned to
   both the orchestrator and every sidecar via a Kubernetes Secret;
   resolveCollectorKey falls back to that key only in K8s mode.

2. Stale topology broadcast (regressed in #279): refreshAllClusterRegistries
   broadcast the registry every 30s but no longer re-discovered topology,
   so clients kept the connect-time snapshot. It now re-discovers each
   tracked cluster through a live client belonging to that cluster before
   broadcasting.

Adds tests covering registry refresh on topology change and that
reconcile acts on the refreshed topology. Updates the K8s example
manifests (Secret + env wiring) and deployment docs.

Signed-off-by: ravjotb <ravjot.brar@improving.com>

* Reuse the per-cluster connection grouping in refreshAllClusterRegistries

Build connectionIdsByCluster once and use it both to pick a client for
topology re-discovery and to target the broadcast, instead of making a
second pass over `clients` for a separate clientByCluster map. The client
is reached via clients.get(connectionId).client, keyed by the same
connectionIds already grouped for the broadcast.

No behavior change.

Signed-off-by: ravjotb <ravjot.brar@improving.com>

* Don't ship a placeholder orchestrator Secret in app.yaml

app.yaml defined a Secret named valkey-admin-orchestrator-key with a
committed placeholder value. Applying it after creating a real random
Secret of the same name would overwrite the real key with the public
placeholder, leaving the orchestrator and every sidecar signing with a
known key (CWE-798). An attacker able to reach the orchestrator could
then register a known cluster node with an attacker-controlled metrics
URI.

Remove the Secret definition from app.yaml — the manifest now only
references the Secret by name. The key is created out-of-band with a
user-supplied random value (kubectl create secret ...), so apply can
never clobber it. Docs updated to state the Secret is created first and
is intentionally not defined in the manifest.

Signed-off-by: ravjotb <ravjot.brar@improving.com>

* Address review feedback on topology refresh and K8s registration

- Carry per-cluster connection metadata forward on refresh instead of
  reverting to initialConnectionDetails: updateClusterNodeRegistry now
  takes a required NodeInfo, and the refresh loop sources it from an
  existing node of that cluster. Narrow discoverCluster to NodeInfo and
  drop the redundant getClusterTopology wrapper.
- Re-discover preconfigured clusters (K8s sidecars, headless Web) through
  the initial client each cycle, since they have no entry in `clients`;
  otherwise a node scaled in after boot never registers until a UI
  session opens. Await the boot-time K8s discovery.
- Bound each cluster's re-discovery with a timeout so one hung CLUSTER
  SLOTS can't stall the whole broadcast loop.
- examples/k8s/valkey-statefulset.yaml: give the inline metrics sidecar
  the ORCHESTRATOR_KEY it needs, and document that the Secret must exist
  and the cluster must be formed before the sidecars start.
- Document that the shared orchestrator key is a cluster-scoped
  credential and that the K8s metrics host is not pinned to loopback.
- Rework the topology-refresh test to exercise real discovery via a fake
  CLUSTER SLOTS client; drop a tautological reconcile test.

Signed-off-by: ravjotb <ravjot.brar@improving.com>

* Overwrite the known cluster entry on refresh instead of orphaning it

updateClusterNodeRegistry keyed the registry on the clusterId derived
from CLUSTER SLOTS (the first slot range's primary). That id changes on
failover/resharding, so re-discovering an existing cluster could write a
new entry under a different id and leave the old one orphaned in the map.

Add an optional clusterId override: the refresh loop passes the cluster's
existing id so the entry is overwritten in place, while boot-time first
discovery still falls back to the derived id. Adds a test covering the
changed-derived-id case.

Signed-off-by: ravjotb <ravjot.brar@improving.com>

* Add coverage for the K8s register and topology-refresh fixes

The branch's new behavior was largely untested, and the K8s-gated paths
are unreachable from the default test suite because isKubernetes is a
module-load constant.

- orchestrator-k8s-register.test.ts sets DEPLOYMENT_MODE=K8 before import
  to cover the shared-key path: resolveCollectorKey falls back to
  ORCHESTRATOR_KEY (and still prefers a minted per-node key), and
  handleRegister admits a signed sidecar for a known cluster node with no
  pre-existing entry while rejecting unknown nodes and wrong-key signatures.
- Extract resolveClusterRefreshTarget (the refresh client/nodeInfo choice)
  so it is unit-testable: a user-connected cluster carries its own node
  metadata forward; a preconfigured/headless cluster falls back to the
  initial client + initialConnectionDetails. Covered in metrics-orchestrator
  (user path / skip) and topology-refresh-preconfigured (fallback path).
  getInitialClient is routed via internals so the fallback is mockable;
  no behavior change.

Signed-off-by: ravjotb <ravjot.brar@improving.com>

* Use the initial client only for the preconfigured cluster on refresh

resolveClusterRefreshTarget fell back to the initial client for any
client-less cluster when preConfiguredConnection was set. In a mixed
deployment, an inactive cluster could then be rediscovered through a
different preconfigured cluster's client, storing that topology under the
inactive cluster's id.

Track the preconfigured clusterId (recorded at boot from the Web and K8s
discovery paths; updateClusterNodeRegistry now returns the written id) and
only use the initial client when the cluster being refreshed matches it.
Other client-less clusters are left unchanged. A user-connected cluster
still refreshes through its own client, so the preconfigured cluster is
covered by that path when a session is open.

Signed-off-by: ravjotb <ravjot.brar@improving.com>

---------

Signed-off-by: ravjotb <ravjot.brar@improving.com>
(cherry picked from commit 9836fb4)
Web mode has no authentication, yet the server bound to all interfaces
(0.0.0.0) by default, silently exposing an unauthenticated admin tool to
the network on any host/VM deployment.

Introduce SERVER_BIND_HOST (mirroring the existing METRICS_BIND_HOST
convention). It defaults to 127.0.0.1, so external exposure is now an
explicit operator decision:
- index.ts binds SERVER_BIND_HOST ?? (K8 ? 0.0.0.0 : 127.0.0.1)
- Dockerfile.app sets SERVER_BIND_HOST=0.0.0.0 so published ports work;
  docs show binding the published port to loopback to keep it local
- examples/k8s/app.yaml sets it explicitly
- Docker and AWS ElastiCache quick-starts warn that publishing 8080
  exposes an unauthenticated service
- documented SERVER_BIND_HOST in the server configuration reference

Also flips the Docker image build to attach provenance + SBOM
attestations (provenance: true, sbom: true) in docker-build.yml.

Signed-off-by: ravjotb <ravjot.brar@improving.com>
(cherry picked from commit 80c442e)
In Electron mode the WebSocket handshake was accepted for any request
presenting an Origin of "null" or "file://". That opaque origin is not
unique to the desktop renderer — a sandboxed iframe (or data:/blob:
document) in an ordinary browser produces the same "null" origin, and the
backend listens on loopback, so any visited web page could open a socket
to it and drive the user's Valkey connections with no credentials.

Gate the non-web / loopback renderer origins behind a per-launch token:
- electron.main.js mints a random 256-bit token each launch, passes it to
  the backend (fork env ELECTRON_WS_TOKEN) and to the renderer
  (webPreferences.additionalArguments).
- preload.js exposes it to the page via contextBridge.
- wsEpics.ts appends it to the Electron WS URL (?token=...).
- websocket-origin.ts requires a matching token (crypto.timingSafeEqual)
  alongside the local origin, and fails closed if no token is provisioned.

Web mode is unchanged (strict same-origin + configured allowlist). Tests
updated to require the token and cover missing/wrong/absent-token cases.

Signed-off-by: ravjotb <ravjot.brar@improving.com>
(cherry picked from commit bfaa579)
@github-actions github-actions Bot added area/frontend UI components, state, routing area/server Backend, WebSocket, actions area/docs Documentation site area/infra CI, Docker, deployment labels Sep 18, 2026
@ravjotbrar

Copy link
Copy Markdown
Collaborator Author

Cherry-picked directly onto release/1.2.0 per the usual process (no cherry branch/PR needed).

@ravjotbrar
ravjotbrar merged commit 5f8607c into release/1.2.0 Sep 18, 2026
4 checks passed
@ravjotbrar
ravjotbrar deleted the cherry/1.2.0-security-fixes branch September 18, 2026 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation site area/frontend UI components, state, routing area/infra CI, Docker, deployment area/server Backend, WebSocket, actions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant