mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
* 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>
83 lines
2.4 KiB
TypeScript
83 lines
2.4 KiB
TypeScript
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);
|