diff --git a/packages/core/src/config/variable.ts b/packages/core/src/config/variable.ts index a01246621838..87d6779eea9b 100644 --- a/packages/core/src/config/variable.ts +++ b/packages/core/src/config/variable.ts @@ -101,7 +101,11 @@ export const substitute = Effect.fn("ConfigVariable.substitute")(function* (inpu const separator = token.indexOf(":-") const name = separator === -1 ? token : token.slice(0, separator) const fallback = separator === -1 ? "" : token.slice(separator + 2) - const value = (input.env?.[name] ?? process.env[name]) || fallback + // Own-property resolution only: a plain lookup reads through the prototype + // chain, so {env:constructor} would inject the Object function source (and + // ignore any :- fallback) instead of resolving as unset. + const supplied = input.env && Object.hasOwn(input.env, name) ? input.env[name] : undefined + const value = (supplied ?? (Object.hasOwn(process.env, name) ? process.env[name] : undefined)) || fallback push(escape ? JSON.stringify(value).slice(1, -1) : value, false) cursor = match.index + match[0].length } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 9b654959aa62..d05e27c4cbde 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -1508,6 +1508,26 @@ describe("Config", () => { }).pipe(Effect.provide(AppNodeBuilder.build(FSUtil.node))), ) + it.effect("resolves env references by own property, not through the prototype chain", () => + Effect.gen(function* () { + const substitute = (text: string, env?: Record) => + ConfigVariable.substitute({ type: "virtual", source: "test", dir: "/", text, ...(env ? { env } : {}) }) + for (const name of ["constructor", "toString", "hasOwnProperty", "valueOf", "__proto__"]) { + const text = `{ "v": "{env:${name}}", "f": "{env:${name}:-FALLBACK}" }` + // Not actually set in the supplied env: resolves as unset ("" / the :- fallback), + // never as the inherited Object.prototype member. + expect(JSON.parse(yield* substitute(text, {}))).toEqual({ v: "", f: "FALLBACK" }) + // Same on the process.env path (no env supplied, the normal config-file path). + expect(JSON.parse(yield* substitute(text))).toEqual({ v: "", f: "FALLBACK" }) + // Genuinely set under a prototype name in the supplied env: substitutes its real value. + expect(JSON.parse(yield* substitute(text, Object.fromEntries([[name, "real"]])))).toEqual({ + v: "real", + f: "real", + }) + } + }).pipe(Effect.provide(AppNodeBuilder.build(FSUtil.node))), + ) + it.effect("does not substitute env references on // comment lines", () => Effect.gen(function* () { const substitute = (text: string, env: Record) =>