Skip to content

Trim NODE_ENV before the key gate reads it, so a stray space cannot ship the public key - #354

Merged
davidmckayv merged 3 commits into
CopilotKit:mainfrom
kevin9327:production-gate-trims-node-env
Sep 4, 2026
Merged

davidmckayv merged 3 commits into
CopilotKit:mainfrom
kevin9327:production-gate-trims-node-env

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

What this changes

server/src/config.ts has two hard refusals that turn a local-only setting into a boot failure on a
deployed server. Both compare NODE_ENV against "production". They did not compare it the same way.

// keyEncryptionKey, before this change
if (value === PLACEHOLDER_KEY) {
  if (environment.NODE_ENV === "production") { throw ... }
// privateHostsAllowed, ~300 lines below, and its comment
// Through `optional`, so the comparison trims. Read raw, `NODE_ENV="production "` out of an env
// file would slip past a gate that the switch beside it, which does trim, would still trip.
if (optional(environment, "NODE_ENV") === "production") { throw ... }

The comment is exactly right, and it was describing the gate above it. Both sides of the comparison
come out of one env file, and a trailing space there is invisible: Docker's env_file preserves the
value verbatim, and so does every hosting dashboard with a text box. NODE_ENV=production trips the
private-hosts refusal and slips past the key refusal.

What slips past matters more than what trips. PLACEHOLDER_KEY is the key in .env.example, and the
file says why it is dangerous: it is a valid key — right length, right encoding — so nothing else
about it fails a check, and a deployment that never changed it encrypts its whole credential vault
with a value printed in this public repository while looking exactly like one that did. With the
space, that deployment starts. The only sign is a console.warn at boot, in the same stream as
everything else a container prints on startup.

The fix. The question moves into one isProduction(environment) used by both gates, so a third
gate that needs it cannot pick the wrong way to ask. environment.NODE_ENV was the only raw
environment read left in the file — every other read already goes through required, optional or
commaSeparated — so this closes the class, not just the instance.

Dockerfile:208 and charts/openbot/templates/_helpers.tpl:128 both set NODE_ENV cleanly, so the
whitespace has to come from a hand-written source: a .env, a docker compose environment: entry,
a manifest somebody typed, or a PaaS text box. Those are the deployments least likely to have
rotated the key.

Where it runs

  • New state that outlives a request? None. One pure predicate over the environment map, read
    at boot inside loadConfig.
  • What happens on the second replica? Every replica reads the same environment and now
    answers the same way. Before this, every replica agreed too — they all agreed on the wrong
    answer. The concrete outcome of the change is that such a deployment refuses to start at all,
    on every replica, instead of starting on the public key.
  • Anything serialised? Nothing.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway: no acting path is touched.
  • New refusals and new failures each write a row: this is a boot-time refusal that throws before
    the audit store exists, which is how the sibling refusal beside it already behaves.
  • Nothing new is trusted from the client.

Changelog

  • A stray space in NODE_ENV no longer lets the public example key through, under Unreleased.

Proof

Three tests added to server/tests/config.test.ts, next to the ones that already pin the
private-hosts gate. Fail-before confirmed by restoring environment.NODE_ENV === "production" and
re-running:

(fail) deployment configuration > refuses the example key when NODE_ENV carries whitespace
  at server/tests/config.test.ts:213

 85 pass
 1 fail

With the fix:

$ bun test server/tests/config.test.ts
 86 pass
 0 fail
 116 expect() calls

Honest note on the other two: refuses the example encryption key on a production deployment and
warns about the example key and still starts under NODE_ENV=%p pass before and after. They are
here because that gate had no test of its own at all, which is part of why it drifted from the one
beside it.

Gates:

$ bun run format:check   Checked 515 files. No fixes applied.
$ bun run lint           Checked 518 files. No fixes applied.
$ bun run typecheck      app / server / worker: Exited with code 0

…hip the public key

A deployment that never changed KEY_ENCRYPTION_KEY encrypts its credential
vault with the key printed in .env.example. It is a valid key - right
length, right encoding - so nothing else about it fails a check, and the
refusal under NODE_ENV=production is the only thing standing between
"copied the example file" and that outcome.

That refusal compared environment.NODE_ENV raw. The other production
refusal in the same file, on private-host browsing, reads it through
`optional` and therefore trims, and its comment says exactly why:

  Through `optional`, so the comparison trims. Read raw,
  `NODE_ENV="production "` out of an env file would slip past a gate that
  the switch beside it, which does trim, would still trip.

Both sides of the comparison come out of one env file, and a trailing space
there is invisible: Docker's env_file preserves it verbatim, and so does
every hosting dashboard with a text box. So `NODE_ENV=production ` tripped
one gate, slipped past the other, and started the deployment on a key
published in this repository with a console warning nobody reads at boot.

The question moves into `isProduction`, used by both, so the next gate that
needs it cannot pick the wrong way to ask. `environment.NODE_ENV` was the
only raw environment read left in the file; everything else already goes
through required, optional or commaSeparated.

The example key had no test of its own at all, which is part of why this
drifted, so the production refusal and the local warning are now pinned
alongside the whitespace case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security fix: routes both production gates through one trimmed isProduction(). Reviewed diff, resolved CHANGELOG against main, validated locally (config tests + format). Approving CI.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security fix: both production refusals now route through one trimmed isProduction(). Reviewed diff, resolved CHANGELOG, validated locally. CI green.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed diff against current main; resolved CHANGELOG keep-both; validated locally (format + composed test run). CI green.

@davidmckayv
davidmckayv merged commit 94f6685 into CopilotKit:main Sep 4, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants