Files
outline/server/utils/environment.ts
T
2b6ee79778 fix: all _FILE suffix secrets in env are cemented at startup (#12889)
* fix: Resolve _FILE env secrets lazily to avoid clobbering third-party variables

The Docker-style secrets support added in #11906 eagerly copied every
*_FILE environment variable into its base variable at boot. This broke
AWS SDK credential refresh on EKS Pod Identity: the rotating token in
AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE was frozen into the static
AWS_CONTAINER_AUTHORIZATION_TOKEN variable, which the SDK prefers and
never re-reads, so S3 access failed once the boot-time token expired.
It similarly resolved the standard OpenSSL SSL_CERT_FILE CA bundle into
Outline's SSL_CERT setting, failing validation at startup.

File secrets are now resolved lazily through a proxy when a variable is
read off the environment export, which limits resolution to variables
declared on the Environment classes, and SSL_CERT_FILE is explicitly
reserved for OpenSSL.

Fixes #12885

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqP3Gb29iG5bJmhYuEAAjF

* refactor: Generalize reserved file variable documentation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqP3Gb29iG5bJmhYuEAAjF

* refactor: Clear SSL_CERT_FILE in test environment instead of reserving it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqP3Gb29iG5bJmhYuEAAjF

* docs: Trim withFileSecrets JSDoc

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqP3Gb29iG5bJmhYuEAAjF

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-06 16:14:03 -04:00

83 lines
2.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import path from "node:path";
import dotenv from "@dotenvx/dotenvx";
let environment: Record<string, string> = {};
const envPath = path.resolve(process.cwd(), `.env`);
const envDefault = fs.existsSync(envPath)
? dotenv.parse(fs.readFileSync(envPath, "utf8"))
: {};
// Load environment specific variables, in reverse order of precedence
const environments = ["production", "development", "local", "test"];
for (const env of environments) {
const isEnv = process.env.NODE_ENV === env || envDefault.NODE_ENV === env;
const isLocalDevelopment =
env === "local" &&
(process.env.NODE_ENV === "development" ||
envDefault.NODE_ENV === "development");
if (isEnv || isLocalDevelopment) {
const resolvedPath = path.resolve(process.cwd(), `.env.${env}`);
if (fs.existsSync(resolvedPath)) {
environment = {
...environment,
...dotenv.parse(fs.readFileSync(resolvedPath, "utf8")),
};
}
}
}
process.env = {
...envDefault,
...environment,
...process.env,
};
/**
* Wraps an environment record so that Docker-style file secrets are resolved
* lazily. When a variable is read through the proxy and has no value, but a
* corresponding `<NAME>_FILE` variable is set, the referenced file is read and
* its contents trimmed of leading/trailing whitespace are cached on the
* underlying record and returned. If the base variable is already set, the
* file is not read.
*
* @param env - the environment record to wrap.
* @returns a proxy over the record that resolves `_FILE` secrets on read.
*/
export function withFileSecrets(
env: Record<string, string | undefined>
): Record<string, string | undefined> {
return new Proxy(env, {
get(target, prop, receiver) {
if (typeof prop !== "string" || !prop.length || prop.endsWith("_FILE")) {
return Reflect.get(target, prop, receiver);
}
const value = target[prop];
if (value !== undefined) {
return value;
}
const filePath = target[`${prop}_FILE`];
if (!filePath) {
return undefined;
}
try {
target[prop] = fs.readFileSync(filePath, "utf8").trim();
} catch (err) {
// oxlint-disable-next-line no-console
console.error(
`Failed to read file for ${prop}_FILE (${filePath}): ${(err as Error).message}`
);
}
return target[prop];
},
});
}
export default withFileSecrets(process.env);