Skip to content

Make the stdout guard survive DOTENV_CONFIG_QUIET - #21

Merged
effecet merged 1 commit into
mainfrom
fix/dotenv-stdout-transport
Aug 18, 2026
Merged

effecet merged 1 commit into
mainfrom
fix/dotenv-stdout-transport

Conversation

@effecet

@effecet effecet commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Follow-up to #20, which is already merged. An independent review of that change found the guard it added was weaker than its own documentation claimed.

The gap

quiet: true alone is defeatable. dotenv resolves it as

:248   quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || options.quiet)
:292   quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || quiet)   ← re-read AFTER populate

The environment variable outranks the code option, and parseBoolean('false') is false. Because line 292 re-reads it after the .env file is loaded, a DOTENV_CONFIG_QUIET=false inside the .env being read defeats it too — not only one exported in the shell.

Either way the injection banner returns to stdout, which for this server is the JSON-RPC transport, and the failure mode is a client registering zero tools with no error to read.

#20 documented this precedence and treated it as "don't do that". This makes it unreachable instead.

The fix

console.log is routed to stderr for the duration of the config call and restored in finally, so the banner cannot reach stdout however quiet resolves — and anything else that logs during env loading is caught with it. quiet: true stays as the primary, so in the normal case nothing is emitted at all.

tests/integration/helpers.ts deliberately does not get the swap: it is test-only, its stdout is not a transport, and the db-guard pinning regex matches the call expression, which is unchanged — so both files still resolve DATABASE_URL identically.

Two guards

One pins the swap in db.ts. The other is behavioural, and proves the swap is load-bearing rather than redundant: it sets DOTENV_CONFIG_QUIET=false, calls dotenv with quiet: true, and asserts a banner is produced. If that ever goes empty, dotenv changed its precedence and the swap should be re-evaluated before removal — rather than silently becoming a no-op nobody notices.

That guard passed the moment it was written, which is how we know the vulnerability was real and not theoretical.

Verification

  • 32/32 in the two guard suites; tsc clean on TypeScript 7.0.2
  • Controlled comparison with DOTENV_CONFIG_QUIET=false in the environment: unfixed writes 1 stdout line and it is the banner; fixed writes 0

The 7 embed failures on a local run are pre-existing and environmental — bge-small isn't cached in a fresh clone, and CI pre-caches it in a dedicated step.

Follow-up to the previous commit, from an independent review of it: `quiet: true`
alone is defeatable, so the guard it provides was weaker than documented.

dotenv resolves quiet as

    processEnv.DOTENV_CONFIG_QUIET || options.quiet     (lib/main.js:248)

and RE-READS it from processEnv after populate (lib/main.js:292). The env var
therefore outranks the code option, parseBoolean('false') is false, and the
second read means a DOTENV_CONFIG_QUIET=false inside the .env file being loaded
defeats it too — not just one exported in the shell. Either way the injection
banner returns to stdout, which for this server is the JSON-RPC transport, and
the failure mode is a client registering zero tools with no error to read.

This makes it unreachable rather than merely discouraged: console.log is routed
to stderr for the duration of the config call and restored in `finally`, so the
banner cannot reach stdout however quiet resolves — and anything else that logs
during env loading is caught with it. `quiet: true` stays as the primary, so in
the normal case nothing is emitted at all.

tests/integration/helpers.ts deliberately does NOT get the swap: it is test-only,
its stdout is not a transport, and the db-guard pinning regex matches the call
expression, which is unchanged — so both files still resolve DATABASE_URL
identically.

Two guards added. One pins the swap in db.ts. The other is behavioural and proves
the swap is load-bearing rather than redundant: it sets DOTENV_CONFIG_QUIET=false,
calls dotenv with quiet:true, and asserts a banner IS produced — if that ever goes
empty, dotenv changed its precedence and the swap should be re-evaluated before
removal.

Verified: 32/32 in the two guard suites, tsc clean on TypeScript 7.0.2, and a
controlled comparison with DOTENV_CONFIG_QUIET=false in the environment — unfixed
writes 1 stdout line and it is the banner, fixed writes 0.
@effecet
effecet merged commit ce89e88 into main Aug 18, 2026
6 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.

1 participant