Skip to content

Commit 0f81e60

Browse files
committed
fix: keep database passwords in ephemeral channels
1 parent 28a9d4c commit 0f81e60

6 files changed

Lines changed: 75 additions & 42 deletions

File tree

packages/cli/src/runtime-services.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic
243243
evidence.lifecycle = "provisioned"
244244
const values: Record<string, string> = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" }
245245
return {
246-
env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])),
246+
env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])),
247247
secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {},
248248
evidence,
249249
async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, async (customAction) => {
@@ -352,7 +352,7 @@ async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeServ
352352
evidence.lifecycle = "provisioned"
353353
const values: Record<string, string> = { host: connection.host, port: String(connection.port), username, password, database }
354354
return {
355-
env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])),
355+
env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])),
356356
secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {},
357357
evidence,
358358
async control(action) {
@@ -419,6 +419,12 @@ function mysqlConnectionArgs(host: string, port: number, username: string): stri
419419
return ["--batch", "--skip-column-names", "--protocol=TCP", "--host", host, "--port", String(port), "--user", username]
420420
}
421421

422+
function runtimeServiceOutputEnvironment(service: WorkspaceRecipeRuntimeService, values: Record<string, string>, secretOutputs: ReadonlySet<string> = new Set()): Record<string, string> {
423+
return Object.fromEntries(Object.entries(service.outputs)
424+
.filter(([output]) => !secretOutputs.has(output))
425+
.map(([output, name]) => [name, values[output] ?? ""]))
426+
}
427+
422428
function validateGeneratedMysqlIdentifier(identifier: string): string {
423429
if (!/^[a-z][a-z0-9_]{0,63}$/.test(identifier)) throw new Error("Generated MySQL isolation identifier is unsafe")
424430
return identifier

packages/runtime-playground/src/playground-cli-runner.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
8080
const wordpressInstallMode = spec.environment.wordpressInstallMode ?? "install-from-existing-files"
8181
const bootstrapIniEntries = runtimeBootstrapPhpIniEntries(spec)
8282
const useProgrammaticRunner = shouldUseProgrammaticPlaygroundRunner(spec, options)
83-
const requestWorkerEndpoint = useProgrammaticRunner ? undefined : {
83+
const requestWorkerEndpoint = useProgrammaticRunner || spec.environment.databaseSetup === "external" ? undefined : {
8484
route: `/wp-codebox-execute-${randomBytes(12).toString("hex")}.php`,
8585
token: randomBytes(32).toString("base64url"),
8686
payloadDirectory: join(spec.artifactsDirectory ?? "artifacts", "playground-internal-shared"),
@@ -182,7 +182,8 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
182182
fixedPreviewPort: spec.preview?.port ?? null,
183183
})
184184

185-
const proxiedServer = await withPreviewLeaseProvider(await withPreviewProxy({ ...server, ...(requestWorkerEndpoint ? { requestWorkerEndpoint } : {}) }, spec.preview?.port ?? 0, spec.preview?.bind), spec)
185+
const connectorServer = withConnectorSecretEnvironment(server, spec)
186+
const proxiedServer = await withPreviewLeaseProvider(await withPreviewProxy({ ...connectorServer, ...(requestWorkerEndpoint ? { requestWorkerEndpoint } : {}) }, spec.preview?.port ?? 0, spec.preview?.bind), spec)
186187
emitProgress("preview:ready", "complete", "Preview ready", {
187188
localUrl: proxiedServer.serverUrl,
188189
upstreamUrl: server.serverUrl,
@@ -451,19 +452,18 @@ function runtimeAutoPrependPhp(spec: RuntimeCreateSpec): string {
451452
}
452453

453454
function runtimeAutoPrependPhpBody(spec: RuntimeCreateSpec): string {
454-
const runtimeEnv = spec.environment.databaseSetup === "external" ? phpEnvAssignments(connectorRuntimeEnv(spec)) : ""
455+
const runtimeEnv = spec.environment.databaseSetup === "external" ? phpEnvAssignments(spec.runtimeEnv ?? {}) : ""
455456
return `${runtimeEnv}${distributionBootstrapPhp(spec)}`
456457
}
457458

458459
function externalDatabaseWpConfig(spec: RuntimeCreateSpec): string | undefined {
459460
if (spec.environment.databaseSetup !== "external") return undefined
460-
const connectorEnv = connectorRuntimeEnv(spec)
461-
const host = connectorEnv.DB_HOST
461+
const host = spec.runtimeEnv?.DB_HOST
462462
if (!host) return undefined
463-
const port = connectorEnv.DB_PORT
463+
const port = spec.runtimeEnv?.DB_PORT
464464
const values = {
465-
DB_NAME: connectorEnv.DB_NAME ?? "runtime",
466-
DB_USER: connectorEnv.DB_USER ?? "root",
465+
DB_NAME: spec.runtimeEnv?.DB_NAME ?? "runtime",
466+
DB_USER: spec.runtimeEnv?.DB_USER ?? "root",
467467
DB_HOST: port ? `${host}:${port}` : host,
468468
}
469469
return `<?php
@@ -479,8 +479,18 @@ require_once ABSPATH . 'wp-settings.php';
479479
`
480480
}
481481

482-
function connectorRuntimeEnv(spec: RuntimeCreateSpec): Record<string, string> {
483-
return { ...(spec.runtimeEnv ?? {}), ...(spec.secretEnv ?? {}) }
482+
function withConnectorSecretEnvironment(server: PlaygroundCliServer, spec: RuntimeCreateSpec): PlaygroundCliServer {
483+
const password = spec.environment.databaseSetup === "external" ? spec.secretEnv?.DB_PASSWORD : undefined
484+
if (password === undefined) return server
485+
return {
486+
...server,
487+
playground: {
488+
...server.playground,
489+
async run(options) {
490+
return await server.playground.run({ ...options, env: { ...(options.env ?? {}), DB_PASSWORD: password } })
491+
},
492+
},
493+
}
484494
}
485495

486496
function distributionBootstrapPhp(spec: RuntimeCreateSpec): string {

packages/runtime-playground/src/preview-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export interface PlaygroundServerRunResponse {
1010

1111
export interface PlaygroundCliServer {
1212
playground: {
13-
run(options: { code: string } | { scriptPath: string }): Promise<PlaygroundServerRunResponse>
13+
run(options: ({ code: string } | { scriptPath: string }) & { env?: Record<string, string> }): Promise<PlaygroundServerRunResponse>
1414
onMessage?(listener: (data: string) => Promise<string | void> | string | void): Promise<(() => Promise<void> | void) | void> | (() => Promise<void> | void) | void
1515
readFileAsText?(path: string): string | Promise<string>
1616
unlink?(path: string): Promise<void> | void

tests/external-mysql-runtime-service.test.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict"
2-
import { mkdtemp, readFile, rm } from "node:fs/promises"
2+
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"
33
import { tmpdir } from "node:os"
44
import { join } from "node:path"
55
import { executeRuntimeServiceProcess, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts"
@@ -116,45 +116,52 @@ assert.equal(provisioned.env.DB_HOST, "database.internal")
116116
assert.equal(provisioned.env.DB_PORT, "3307")
117117
assert.match(provisioned.env.DB_NAME ?? "", /^wp_codebox_[a-f0-9]{24}$/)
118118
assert.match(provisioned.env.DB_USER ?? "", /^wpcb_[a-f0-9]{24}$/)
119-
assert.equal(provisioned.secretEnv.DB_PASSWORD, provisioned.env.DB_PASSWORD)
119+
const generatedPassword = provisioned.secretEnv.DB_PASSWORD ?? ""
120+
assert.match(generatedPassword, /^[A-Za-z0-9_-]+$/)
121+
assert.equal(provisioned.env.DB_PASSWORD, undefined, "password is absent from the non-secret output channel")
120122
assert.equal(provisioned.evidence[0]?.provider, "external")
121123
assert.equal(provisioned.evidence[0]?.readiness, "ready")
122124
const createDatabase = successFake.calls.find((call) => call.stdin?.startsWith("CREATE DATABASE"))
123125
const createUser = successFake.calls.find((call) => call.stdin?.startsWith("CREATE USER"))
124126
assert.match(createDatabase?.stdin ?? "", /^CREATE DATABASE `wp_codebox_[a-f0-9]{24}`;\n$/)
125127
assert.match(createUser?.stdin ?? "", /^CREATE USER 'wpcb_[a-f0-9]{24}'@'%' IDENTIFIED BY '[A-Za-z0-9_-]+';\n$/)
126-
assert.equal(successFake.calls.some((call) => call.args.some((arg) => arg.includes(adminPassword) || arg.includes(provisioned.env.DB_PASSWORD ?? ""))), false, "passwords never enter argv")
128+
assert.equal(successFake.calls.some((call) => call.args.some((arg) => arg.includes(adminPassword) || arg.includes(generatedPassword))), false, "passwords never enter argv")
127129
assert.equal(JSON.stringify(runtimeServicePlan([externalService])).includes(adminPassword), false)
128130
assert.equal(JSON.stringify(provisioned.evidence).includes(adminPassword), false)
129-
assert.equal(JSON.stringify(provisioned.evidence).includes(provisioned.env.DB_PASSWORD ?? ""), false)
131+
assert.equal(JSON.stringify(provisioned.evidence).includes(generatedPassword), false)
130132
const bootstrapRoot = await mkdtemp(join(tmpdir(), "wp-codebox-external-mysql-bootstrap-"))
131133
const wordpressRoot = await mkdtemp(join(tmpdir(), "wp-codebox-external-mysql-wordpress-"))
132134
try {
133135
const bootstrapCalls: Parameters<PlaygroundCliModule["runCLI"]>[0][] = []
136+
const bootstrapRuns: Array<({ code: string } | { scriptPath: string }) & { env?: Record<string, string> }> = []
134137
const cliModule: PlaygroundCliModule = { async runCLI(options) {
135138
bootstrapCalls.push(options)
136-
return { serverUrl: "http://127.0.0.1:65535", playground: { async run() { return { text: "" } } }, async [Symbol.asyncDispose]() {} }
139+
return { serverUrl: "http://127.0.0.1:65535", playground: { async run(runOptions) { bootstrapRuns.push(runOptions); return { text: runOptions.env?.DB_PASSWORD ?? "" } } }, async [Symbol.asyncDispose]() {} }
137140
} }
138-
const { DB_PASSWORD: _password, ...runtimeEnv } = provisioned.env
139141
const runtimeSpec: RuntimeCreateSpec = {
140142
backend: "wordpress-playground",
141143
environment: { version: "mounted", wordpressInstallMode: "do-not-attempt-installing", databaseSetup: "external", assets: { wordpressDirectory: wordpressRoot }, blueprint: {} },
142144
policy,
143-
runtimeEnv,
145+
runtimeEnv: provisioned.env,
144146
secretEnv: provisioned.secretEnv,
145147
artifactsDirectory: bootstrapRoot,
146148
}
147149
const server = await startPlaygroundCliServer(runtimeSpec, [], { cliModule })
150+
const connectorResponse = await server.playground.run({ code: "<?php echo getenv('DB_PASSWORD');" })
151+
assert.equal(connectorResponse.text, generatedPassword, "generated password reaches PHP through the ephemeral run environment")
152+
assert.equal(bootstrapRuns[0]?.env?.DB_PASSWORD, generatedPassword)
148153
await server[Symbol.asyncDispose]()
149154
const mounts = bootstrapCalls[0]?.["mount-before-install"] ?? []
150155
const autoPrependPath = mounts.find((mount) => mount.vfsPath === "/internal/shared/wp-codebox-auto-prepend.php")?.hostPath
151156
const wpConfigPath = mounts.find((mount) => mount.vfsPath === "/wordpress/wp-config.php")?.hostPath
152157
assert.ok(autoPrependPath && wpConfigPath)
153158
const autoPrepend = await readFile(autoPrependPath, "utf8")
154159
const wpConfig = await readFile(wpConfigPath, "utf8")
155-
assert.match(autoPrepend, new RegExp(`putenv\\("DB_PASSWORD=${provisioned.env.DB_PASSWORD}`), "generated password reaches the connector bootstrap environment")
156160
assert.match(wpConfig, /getenv\('DB_PASSWORD'\)/)
157-
assert.equal(wpConfig.includes(provisioned.env.DB_PASSWORD ?? ""), false, "generated password is not serialized into wp-config")
161+
assert.equal(autoPrepend.includes(generatedPassword), false, "generated password is not serialized into auto-prepend PHP")
162+
assert.equal(wpConfig.includes(generatedPassword), false, "generated password is not serialized into wp-config")
163+
assert.equal(await directoryContains(bootstrapRoot, generatedPassword), false, "generated password is absent from persisted artifact files")
164+
assert.equal(await directoryContains(wordpressRoot, generatedPassword), false, "generated password is absent from persisted WordPress files")
158165
} finally {
159166
await rm(bootstrapRoot, { recursive: true, force: true })
160167
await rm(wordpressRoot, { recursive: true, force: true })
@@ -243,3 +250,15 @@ await assert.rejects(executeRuntimeServiceProcess(process.execPath, ["-e", "proc
243250
})
244251

245252
console.log("external MySQL runtime service tests passed")
253+
254+
async function directoryContains(root: string, needle: string): Promise<boolean> {
255+
for (const entry of await readdir(root, { withFileTypes: true })) {
256+
const path = join(root, entry.name)
257+
if (entry.isDirectory()) {
258+
if (await directoryContains(path, needle)) return true
259+
} else if (entry.isFile() && (await readFile(path)).includes(Buffer.from(needle))) {
260+
return true
261+
}
262+
}
263+
return false
264+
}

tests/playground-cli-runner-bootstrap-ini.test.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@ import type { RuntimeCreateSpec } from "../packages/runtime-core/src/index.js"
99
const wordpressDevelopDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-wordpress-develop-"))
1010
const artifactsDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-artifacts-"))
1111
const calls: Parameters<PlaygroundCliModule["runCLI"]>[0][] = []
12+
const runs: Array<({ code: string } | { scriptPath: string }) & { env?: Record<string, string> }> = []
1213

1314
const cliModule: PlaygroundCliModule = {
1415
async runCLI(options) {
1516
calls.push(options)
1617
return {
1718
serverUrl: "http://127.0.0.1:65535",
1819
playground: {
19-
async run() {
20-
return { text: "" }
20+
async run(options) {
21+
runs.push(options)
22+
return { text: options.env?.DB_PASSWORD ?? "" }
2123
},
2224
},
2325
async [Symbol.asyncDispose]() {},
@@ -62,17 +64,16 @@ try {
6264
}
6365

6466
const server = await startPlaygroundCliServer(spec, [], { cliModule })
67+
assert.equal((await server.playground.run({ code: "<?php echo getenv('DB_PASSWORD');" })).text, "secret")
6568
await server[Symbol.asyncDispose]()
6669

6770
assert.equal(calls.length, 1)
68-
assert.equal(calls[0]["mount-before-install"]?.length, 6)
71+
assert.equal(calls[0]["mount-before-install"]?.length, 4)
6972
assert.equal(calls[0]["mount-before-install"]?.[0]?.vfsPath, "/internal/shared/php.ini")
7073
assert.equal(calls[0]["mount-before-install"]?.[1]?.vfsPath, "/internal/shared/wp-codebox-auto-prepend.php")
71-
assert.equal(calls[0]["mount-before-install"]?.[2]?.vfsPath, "/internal/wp-codebox")
72-
assert.match(calls[0]["mount-before-install"]?.[3]?.vfsPath ?? "", /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/)
7374
// A wordpress-develop checkout is the runtime root, not an ordinary post-startup mount.
74-
assert.equal(calls[0]["mount-before-install"]?.[4]?.vfsPath, "/wordpress/wp-config.php")
75-
assert.deepEqual(calls[0]["mount-before-install"]?.[5], { hostPath: wordpressDevelopDirectory, vfsPath: "/wordpress" })
75+
assert.equal(calls[0]["mount-before-install"]?.[2]?.vfsPath, "/wordpress/wp-config.php")
76+
assert.deepEqual(calls[0]["mount-before-install"]?.[3], { hostPath: wordpressDevelopDirectory, vfsPath: "/wordpress" })
7677
assert.deepEqual(calls[0].mount, [])
7778
assert.equal(calls[0].workers, 6)
7879
assert.equal(calls[0].wordpressInstallMode, "do-not-attempt-installing")
@@ -93,14 +94,9 @@ try {
9394
const sharedAutoPrepend = await readFile(sharedAutoPrependPath as string, "utf8")
9495
assert.match(sharedAutoPrepend, /require_once '\/internal\/shared\/auto_prepend_file\.php'/)
9596
assert.match(sharedAutoPrepend, /putenv\("TC_MYSQL_PORT=33060"\);/)
96-
assert.match(sharedAutoPrepend, /putenv\("DB_PASSWORD=secret"\);/)
97-
const requestWorkerPath = calls[0]["mount-before-install"]?.[3]?.hostPath
98-
assert.equal(typeof requestWorkerPath, "string")
99-
const requestWorker = await readFile(requestWorkerPath as string, "utf8")
100-
assert.match(requestWorker, /HTTP_X_WP_CODEBOX_EXECUTION_TOKEN/)
101-
assert.match(requestWorker, /hash_equals/)
102-
assert.match(requestWorker, /\$_ENV\[\$wp_codebox_name\] = \$wp_codebox_value/)
103-
const externalWpConfigPath = calls[0]["mount-before-install"]?.[4]?.hostPath
97+
assert.doesNotMatch(sharedAutoPrepend, /secret|DB_PASSWORD/)
98+
assert.equal(runs[0]?.env?.DB_PASSWORD, "secret")
99+
const externalWpConfigPath = calls[0]["mount-before-install"]?.[2]?.hostPath
104100
assert.equal(typeof externalWpConfigPath, "string")
105101
const externalWpConfig = await readFile(externalWpConfigPath as string, "utf8")
106102
assert.match(externalWpConfig, /define\('DB_HOST', "127\.0\.0\.1:33061"\)/)

tests/runtime-services.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,19 +117,21 @@ const dependencies: RuntimeServiceDependencies = {
117117
async waitForReady() {},
118118
}
119119
const provisioned = await provisionRuntimeServices([service], { dependencies })
120+
const provisionedPassword = Buffer.alloc(24, 7).toString("base64url")
120121
assert.equal(provisioned.env.DB_PORT, "41001")
121-
assert.equal(provisioned.env.DB_PASSWORD, Buffer.alloc(24, 7).toString("base64url"))
122+
assert.equal(provisioned.env.DB_PASSWORD, undefined, "password is excluded from the non-secret output channel")
123+
assert.equal(provisioned.secretEnv.DB_PASSWORD, provisionedPassword)
122124
const runCall = calls.find((call) => call.args[0] === "run")
123125
assert.ok(runCall?.args.includes("MYSQL_PASSWORD"))
124126
assert.ok(runCall?.args.includes("127.0.0.1::3306"), "Docker publishes MySQL on a loopback ephemeral port")
125127
assert.deepEqual(runCall?.args.slice(runCall.args.indexOf("--tmpfs"), runCall.args.indexOf("--tmpfs") + 2), ["--tmpfs", "/var/lib/mysql"])
126128
assert.equal(runCall?.args.includes("--volume") || runCall?.args.includes("--mount"), false, "Docker uses no persistent volume")
127-
assert.equal(runCall?.args.some((arg) => arg.includes(provisioned.env.DB_PASSWORD)), false, "credentials never enter Docker argv")
129+
assert.equal(runCall?.args.some((arg) => arg.includes(provisionedPassword)), false, "credentials never enter Docker argv")
128130
const readinessCall = calls.find((call) => call.args[0] === "exec")
129131
assert.ok(readinessCall?.args.includes("mysql"), "MySQL readiness authenticates against the initialized database")
130-
assert.equal(readinessCall?.args.some((arg) => arg.includes(provisioned.env.DB_PASSWORD)), false, "readiness credentials never enter Docker argv")
131-
assert.equal(readinessCall?.env?.MYSQL_PWD, provisioned.env.DB_PASSWORD, "readiness credentials use the child environment")
132-
assert.equal(JSON.stringify(provisioned.evidence).includes(provisioned.env.DB_PASSWORD), false, "credentials never enter evidence")
132+
assert.equal(readinessCall?.args.some((arg) => arg.includes(provisionedPassword)), false, "readiness credentials never enter Docker argv")
133+
assert.equal(readinessCall?.env?.MYSQL_PWD, provisionedPassword, "readiness credentials use the child environment")
134+
assert.equal(JSON.stringify(provisioned.evidence).includes(provisionedPassword), false, "credentials never enter evidence")
133135
assert.equal(runCall?.env?.DOCKER_HOST, process.env.DOCKER_HOST, "Docker provider context is preserved")
134136
assert.equal(calls[0]?.args[0], "image", "the provider checks the image before starting the service")
135137
await provisioned.release()

0 commit comments

Comments
 (0)