Skip to content

Commit 59ba87f

Browse files
authored
fix(runtime-services): keep database passwords ephemeral (#2053)
* fix: keep database passwords in ephemeral channels * test: prove database passwords avoid persisted files * fix: preserve passwordless database worker isolation * fix: inject connector secrets into PHP runtimes * test: cover connector secrets across execution paths * fix: map connector secret output targets * fix: reserve connector secret environment targets
1 parent 6aa2b97 commit 59ba87f

18 files changed

Lines changed: 337 additions & 137 deletions

package-lock.json

Lines changed: 6 additions & 83 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"packages/cli/dist",
8181
"packages/cli/package.json",
8282
"scripts/apply-development-patches.mjs",
83+
"patches",
8384
"README.md",
8485
"LICENSE"
8586
],
@@ -284,7 +285,6 @@
284285
"devDependencies": {
285286
"@cloudflare/workers-types": "^5.20260718.1",
286287
"@types/node": "^24.0.0",
287-
"patch-package": "^8.0.1",
288288
"tsx": "^4.20.0",
289289
"wrangler": "^4.112.0"
290290
},
@@ -420,6 +420,7 @@
420420
"on-finished": "^2.4.1",
421421
"once": "^1.4.0",
422422
"pako": "^1.0.11",
423+
"patch-package": "^8.0.1",
423424
"parseurl": "^1.3.3",
424425
"path-expression-matcher": "^1.5.0",
425426
"path-to-regexp": "^0.1.13",

packages/cli/src/commands/recipe-run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
200200
policy,
201201
externalServices: recipe.inputs?.externalServices ?? [],
202202
externalServiceWritesApproved: options.externalServiceWritesApproved,
203+
reservedEnvNames: [...Object.keys(runtimeEnv), ...(recipe.inputs?.secretEnv ?? [])],
203204
onEvidence: (evidence) => { serviceEvidence = evidence },
204205
},
205206
))
@@ -229,6 +230,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
229230
policy: effectivePolicy,
230231
runtimeEnv,
231232
secretEnv,
233+
secretEnvTargets: managedServices.secretEnvTargets,
232234
artifactsDirectory: configuredArtifactsDirectory,
233235
metadata: {
234236
...runtimeMetadata(configuredArtifactsDirectory, plan.runtime.wp),

packages/cli/src/recipe-validation.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,18 +753,36 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe,
753753

754754
function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: string, path: string, message: string) => void): void {
755755
const ids = new Set<string>()
756+
const services = recipe.inputs?.services ?? []
756757
const exposedEnvironment = new Set<string>([
757758
...Object.keys(recipe.distribution?.env ?? {}),
758759
...Object.keys(recipe.inputs?.runtimeEnv ?? {}),
759760
...(recipe.inputs?.secretEnv ?? []),
760761
])
761762
const environment = new Set(exposedEnvironment)
762-
for (const [index, service] of (recipe.inputs?.services ?? []).entries()) {
763+
const outputOwners = new Map<string, Array<{ serviceIndex: number; output: string }>>()
764+
for (const [serviceIndex, service] of services.entries()) {
765+
for (const [output, name] of Object.entries(service.outputs)) {
766+
const owners = outputOwners.get(name) ?? []
767+
owners.push({ serviceIndex, output })
768+
outputOwners.set(name, owners)
769+
}
770+
}
771+
const connectorTargets = new Set<string>()
772+
for (const [index, service] of services.entries()) {
763773
const path = `$.inputs.services[${index}]`
764774
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(service.id)) addIssue("invalid-runtime-service-id", `${path}.id`, "Runtime service ids must be stable identifiers.")
765775
if (ids.has(service.id)) addIssue("duplicate-runtime-service-id", `${path}.id`, `Runtime service ids must be unique: ${service.id}`)
766776
ids.add(service.id)
767777
if (!["mysql", "redis", "smtp", "http"].includes(service.kind)) addIssue("unsupported-runtime-service-kind", `${path}.kind`, `Unsupported managed runtime service kind: ${service.kind}`)
778+
if (service.kind === "mysql" && service.outputs.password) {
779+
const target = "DB_PASSWORD"
780+
if (exposedEnvironment.has(target)) addIssue("runtime-service-secret-target-collision", `${path}.outputs.password`, `Managed connector secret target is already injected by recipe environment: ${target}`)
781+
const conflictingOutput = (outputOwners.get(target) ?? []).some((owner) => !(owner.serviceIndex === index && owner.output === "password" && service.outputs.password === target))
782+
if (conflictingOutput) addIssue("runtime-service-secret-target-collision", `${path}.outputs.password`, `Managed connector secret target collides with a managed output: ${target}`)
783+
if (connectorTargets.has(target)) addIssue("ambiguous-runtime-service-secret-target", `${path}.outputs.password`, `Multiple managed connectors target the same runtime environment name: ${target}`)
784+
connectorTargets.add(target)
785+
}
768786
if (service.configuration?.provider === "external") {
769787
if (service.kind !== "mysql") addIssue("unsupported-runtime-service-provider", `${path}.configuration.provider`, "The external provider supports only MySQL-compatible services.")
770788
const boundary = recipe.inputs?.externalServices?.find((candidate) => candidate.id === service.configuration?.externalService)

packages/cli/src/runtime-services.ts

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export function runtimeServiceEvidenceFromError(error: unknown): RuntimeServiceE
5050
interface ManagedRuntimeService {
5151
env: Record<string, string>
5252
secretEnv: Record<string, string>
53+
secretEnvTargets: Record<string, string>
5354
evidence: RuntimeServiceEvidence
5455
release(): Promise<void>
5556
control(action: RuntimeServiceControlAction, options?: Record<string, unknown>): Promise<RuntimeServiceControlResult>
@@ -66,6 +67,7 @@ export interface RuntimeServiceProvider {
6667
readonly name: string
6768
readonly kind: string
6869
version(service: WorkspaceRecipeRuntimeService): string
70+
secretEnvTargets(service: WorkspaceRecipeRuntimeService): Record<string, string>
6971
provision(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidence: RuntimeServiceEvidence[]): Promise<ManagedRuntimeService>
7072
}
7173

@@ -82,6 +84,7 @@ export interface ProvisionRuntimeServicesOptions {
8284
policy?: RuntimePolicy
8385
externalServices?: WorkspaceRecipeExternalServiceBoundary[]
8486
externalServiceWritesApproved?: boolean
87+
reservedEnvNames?: readonly string[]
8588
}
8689

8790
const defaultDependencies: RuntimeServiceDependencies = {
@@ -99,21 +102,24 @@ export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): A
99102
})
100103
}
101104

102-
export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: ProvisionRuntimeServicesOptions = {}): Promise<{ env: Record<string, string>; secretEnv: Record<string, string>; evidence: RuntimeServiceEvidence[]; control(serviceId: string, action: RuntimeServiceControlAction, controlOptions?: Record<string, unknown>): Promise<RuntimeServiceControlResult>; release(): Promise<void> }> {
105+
export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: ProvisionRuntimeServicesOptions = {}): Promise<{ env: Record<string, string>; secretEnv: Record<string, string>; secretEnvTargets: Record<string, string>; evidence: RuntimeServiceEvidence[]; control(serviceId: string, action: RuntimeServiceControlAction, controlOptions?: Record<string, unknown>): Promise<RuntimeServiceControlResult>; release(): Promise<void> }> {
103106
const dependencies = options.dependencies ?? defaultDependencies
104107
const provisioned: ManagedRuntimeService[] = []
105108
const evidence: RuntimeServiceEvidence[] = []
109+
let environment: ReturnType<typeof aggregateRuntimeServiceEnvironment>
106110
const context: RuntimeServiceProvisionContext = {
107111
signal: options.signal,
108112
policy: options.policy,
109113
externalServices: options.externalServices ?? [],
110114
externalServiceWritesApproved: options.externalServiceWritesApproved ?? false,
111115
}
112116
try {
117+
validateDeclaredRuntimeServiceSecretTargets(services, options.reservedEnvNames ?? [])
113118
for (const service of services) {
114119
const managed = await runtimeServiceProvider(service).provision(service, dependencies, context, evidence)
115120
provisioned.push(managed)
116121
}
122+
environment = aggregateRuntimeServiceEnvironment(provisioned, options.reservedEnvNames ?? [])
117123
} catch (error) {
118124
await releaseServices(provisioned).catch(() => undefined)
119125
if (error instanceof RuntimeServiceProvisionError) throw error
@@ -126,8 +132,7 @@ export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeS
126132
const lease = provisioned.length > 0 ? setInterval(() => undefined, 1_000) : undefined
127133

128134
return {
129-
env: Object.assign({}, ...provisioned.map((service) => service.env)),
130-
secretEnv: Object.assign({}, ...provisioned.map((service) => service.secretEnv)),
135+
...environment,
131136
evidence,
132137
async control(serviceId, action, controlOptions) {
133138
const service = provisioned.find((candidate) => candidate.evidence.id === serviceId)
@@ -159,6 +164,7 @@ export async function provisionRuntimeServicesForRecipe(
159164
policy: options.policy,
160165
externalServices: options.externalServices,
161166
externalServiceWritesApproved: options.externalServiceWritesApproved,
167+
reservedEnvNames: options.reservedEnvNames,
162168
})
163169
try {
164170
return await guard(provisioning)
@@ -185,24 +191,30 @@ const mysqlDockerProvider: RuntimeServiceProvider = {
185191
name: "docker",
186192
kind: "mysql",
187193
version: mysqlDockerImage,
194+
secretEnvTargets: mysqlRuntimeServiceSecretTargets,
188195
provision: provisionMysqlDockerService,
189196
}
190197

191198
const mysqlExternalProvider: RuntimeServiceProvider = {
192199
name: "external",
193200
kind: "mysql",
194201
version: (service) => `mysql-compatible:${service.configuration?.engine ?? "mysql"}`,
202+
secretEnvTargets: mysqlRuntimeServiceSecretTargets,
195203
provision: provisionMysqlExternalService,
196204
}
197205

198-
const redisDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "redis", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.redis, provision: provisionRedisDockerService }
199-
const smtpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "smtp", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.smtp, provision: provisionSmtpDockerService }
200-
const httpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "http", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.http, provision: provisionHttpDockerService }
206+
const redisDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "redis", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.redis, secretEnvTargets: () => ({}), provision: provisionRedisDockerService }
207+
const smtpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "smtp", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.smtp, secretEnvTargets: () => ({}), provision: provisionSmtpDockerService }
208+
const httpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "http", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.http, secretEnvTargets: () => ({}), provision: provisionHttpDockerService }
201209

202210
function mysqlDockerImage(service: WorkspaceRecipeRuntimeService): string {
203211
return MYSQL_IMAGES[service.configuration?.engine ?? "mysql"]
204212
}
205213

214+
function mysqlRuntimeServiceSecretTargets(service: WorkspaceRecipeRuntimeService): Record<string, string> {
215+
return service.outputs.password ? { DB_PASSWORD: service.outputs.password } : {}
216+
}
217+
206218
function runtimeServiceProvider(service: WorkspaceRecipeRuntimeService): RuntimeServiceProvider {
207219
if (service.kind === mysqlDockerProvider.kind) return service.configuration?.provider === "external" ? mysqlExternalProvider : mysqlDockerProvider
208220
if (service.configuration?.provider === "external") throw new Error(`Managed runtime service kind does not support the external provider: ${service.kind}`)
@@ -243,8 +255,9 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic
243255
evidence.lifecycle = "provisioned"
244256
const values: Record<string, string> = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" }
245257
return {
246-
env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])),
258+
env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])),
247259
secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {},
260+
secretEnvTargets: mysqlRuntimeServiceSecretTargets(service),
248261
evidence,
249262
async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, async (customAction) => {
250263
if (customAction === "flush") {
@@ -352,8 +365,9 @@ async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeServ
352365
evidence.lifecycle = "provisioned"
353366
const values: Record<string, string> = { host: connection.host, port: String(connection.port), username, password, database }
354367
return {
355-
env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])),
368+
env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])),
356369
secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {},
370+
secretEnvTargets: mysqlRuntimeServiceSecretTargets(service),
357371
evidence,
358372
async control(action) {
359373
const result: RuntimeServiceControlResult = { serviceId: service.id, action, status: "unsupported", fidelity: "unsupported", reason: `The ${service.kind} provider does not support ${action}.` }
@@ -419,6 +433,62 @@ function mysqlConnectionArgs(host: string, port: number, username: string): stri
419433
return ["--batch", "--skip-column-names", "--protocol=TCP", "--host", host, "--port", String(port), "--user", username]
420434
}
421435

436+
function runtimeServiceOutputEnvironment(service: WorkspaceRecipeRuntimeService, values: Record<string, string>, secretOutputs: ReadonlySet<string> = new Set()): Record<string, string> {
437+
return Object.fromEntries(Object.entries(service.outputs)
438+
.filter(([output]) => !secretOutputs.has(output))
439+
.map(([output, name]) => [name, values[output] ?? ""]))
440+
}
441+
442+
function validateDeclaredRuntimeServiceSecretTargets(services: readonly WorkspaceRecipeRuntimeService[], reservedEnvNames: readonly string[]): void {
443+
const reserved = new Set(reservedEnvNames)
444+
const outputOwners = new Map<string, Array<{ serviceIndex: number; output: string }>>()
445+
for (const [serviceIndex, service] of services.entries()) {
446+
for (const [output, name] of Object.entries(service.outputs)) {
447+
const owners = outputOwners.get(name) ?? []
448+
owners.push({ serviceIndex, output })
449+
outputOwners.set(name, owners)
450+
}
451+
}
452+
const targets = new Map<string, string>()
453+
for (const [serviceIndex, service] of services.entries()) {
454+
for (const [target, source] of Object.entries(runtimeServiceProvider(service).secretEnvTargets(service))) {
455+
if (reserved.has(target)) throw new Error(`Managed runtime service secret target is reserved by injected environment: ${target}`)
456+
const conflictingOutput = (outputOwners.get(target) ?? []).some((owner) => !(owner.serviceIndex === serviceIndex && owner.output === "password" && source === target))
457+
if (conflictingOutput) throw new Error(`Managed runtime service secret target collides with managed output: ${target}`)
458+
if (targets.has(target)) throw new Error(`Managed runtime service secret target is ambiguous: ${target}`)
459+
targets.set(target, source)
460+
}
461+
}
462+
}
463+
464+
function aggregateRuntimeServiceEnvironment(services: readonly ManagedRuntimeService[], reservedEnvNames: readonly string[]): { env: Record<string, string>; secretEnv: Record<string, string>; secretEnvTargets: Record<string, string> } {
465+
const env: Record<string, string> = {}
466+
const secretEnv: Record<string, string> = {}
467+
const secretEnvTargets: Record<string, string> = {}
468+
for (const service of services) {
469+
mergeUniqueEnvironment(env, service.env, "runtime service environment")
470+
mergeUniqueEnvironment(secretEnv, service.secretEnv, "runtime service secret environment")
471+
for (const [target, source] of Object.entries(service.secretEnvTargets)) {
472+
const existing = secretEnvTargets[target]
473+
if (existing !== undefined && existing !== source) throw new Error(`Managed runtime service secret target is ambiguous: ${target}`)
474+
secretEnvTargets[target] = source
475+
}
476+
}
477+
for (const [target, source] of Object.entries(secretEnvTargets)) {
478+
if (!(source in secretEnv)) throw new Error(`Managed runtime service secret target references an unavailable secret: ${target}`)
479+
if (target in env) throw new Error(`Managed runtime service secret target collides with non-secret environment: ${target}`)
480+
if (reservedEnvNames.includes(target)) throw new Error(`Managed runtime service secret target is reserved by injected environment: ${target}`)
481+
}
482+
return { env, secretEnv, secretEnvTargets }
483+
}
484+
485+
function mergeUniqueEnvironment(target: Record<string, string>, source: Record<string, string>, label: string): void {
486+
for (const [name, value] of Object.entries(source)) {
487+
if (name in target) throw new Error(`Duplicate ${label} name: ${name}`)
488+
target[name] = value
489+
}
490+
}
491+
422492
function validateGeneratedMysqlIdentifier(identifier: string): string {
423493
if (!/^[a-z][a-z0-9_]{0,63}$/.test(identifier)) throw new Error("Generated MySQL isolation identifier is unsafe")
424494
return identifier
@@ -501,6 +571,7 @@ async function provisionSimpleDockerService(
501571
return {
502572
env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])),
503573
secretEnv: {},
574+
secretEnvTargets: {},
504575
evidence,
505576
async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, spec.customControl ? async (candidate, candidateOptions) => await spec.customControl?.(container, candidate, candidateOptions) ?? false : undefined, async () => await dependencies.waitForReady("127.0.0.1", ports[0] as number, 30_000)) },
506577
async release() { await releaseService(container, evidence, dependencies) },

packages/runtime-core/src/runtime-contracts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface RuntimeCreateSpec {
3131
artifactsDirectory?: string
3232
runtimeEnv?: Record<string, string>
3333
secretEnv?: Record<string, string>
34+
/** Maps an environment target to the secretEnv name that supplies its value. */
35+
secretEnvTargets?: Record<string, string>
3436
metadata?: Record<string, unknown>
3537
preview?: RuntimePreviewSpec
3638
onBrowserStartupProgress?: BrowserStartupProgressListener

packages/runtime-core/src/runtime-env.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ export function resolveSecretEnvNames(names: readonly string[], options: Resolve
6363
return secretEnv
6464
}
6565

66+
export function resolveRuntimeSecretEnvTargets(secretEnv: Record<string, string>, targets: Record<string, string> = {}): Record<string, string> {
67+
const resolved: Record<string, string> = {}
68+
for (const [target, source] of Object.entries(targets)) {
69+
assertRuntimeEnvName(target, "secret env target")
70+
assertRuntimeEnvName(source, "secret env source")
71+
if (!(source in secretEnv)) throw new Error(`Secret env target ${target} references unavailable source: ${source}`)
72+
resolved[target] = secretEnv[source] as string
73+
}
74+
return resolved
75+
}
76+
77+
export function assertRuntimeSecretEnvTargetsAvailable(targets: Record<string, string> = {}, ...environmentSources: Array<Record<string, unknown>>): void {
78+
for (const target of Object.keys(targets)) {
79+
assertRuntimeEnvName(target, "secret env target")
80+
if (environmentSources.some((source) => target in source)) throw new Error(`Secret env target collides with injected environment: ${target}`)
81+
}
82+
}
83+
6684
export function registerRuntimeSecretRedactions(secretEnv: Record<string, string>, registrar: RuntimeEnvRedactionRegistrar): void {
6785
for (const [name, value] of Object.entries(secretEnv)) {
6886
registrar.registerSecretName(name)

0 commit comments

Comments
 (0)