From 752fe382abc3fe305a30a9cf6160a8fae526e26f Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 17:28:47 +0800 Subject: [PATCH] fix: reject secrets in device pod profiles --- internal/cloud/access-control.test.ts | 36 +++++++++++++++++++++++++++ internal/cloud/access-control.ts | 34 +++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index d67cc2fd..a95a5ef4 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -68,6 +68,21 @@ test("cloud api access control grants visible device pods and requires device-po assert.equal(podCreate.body.devicePod.profile.route.gatewaySessionId, "redacted"); assert.equal(JSON.stringify(podCreate.body).includes("gws_missing"), false); + const secretProfile = await postJson(port, "/v1/admin/device-pods", { + devicePodId: "device-pod-secret", + profile: { + schemaVersion: 1, + target: { id: "target-secret" }, + route: { + gatewaySessionId: "gws_secret", + cloudToken: "should-not-be-stored" + } + } + }, adminCookie); + assert.equal(secretProfile.status, 400); + assert.equal(secretProfile.body.error.code, "device_pod_profile_secret_forbidden"); + assert.equal(JSON.stringify(secretProfile.body).includes("should-not-be-stored"), false); + const emptyLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); assert.equal(emptyLogin.status, 200); const aliceCookie = emptyLogin.cookie; @@ -721,6 +736,27 @@ test("cloud api first-admin setup validates device-pod seed before creating admi assert.equal(ambiguous.status, 400); assert.equal(ambiguous.body.error.code, "invalid_params"); + const secretSeed = await postJson(port, "/v1/setup/first-admin", { + username: "admin", + password: "admin-pass", + devicePod: { + devicePodId: "device-pod-71-freq", + profile: { + schemaVersion: 1, + target: { id: "target-71-freq" }, + route: { gatewaySessionId: "gws_seed" }, + databaseUrl: "postgres://user:pass@example.invalid/hwlab" + } + } + }); + assert.equal(secretSeed.status, 400); + assert.equal(secretSeed.body.error.code, "device_pod_profile_secret_forbidden"); + assert.equal(JSON.stringify(secretSeed.body).includes("postgres://"), false); + + const afterSecretSeed = await getJson(port, "/v1/setup/status"); + assert.equal(afterSecretSeed.status, 200); + assert.equal(afterSecretSeed.body.setupRequired, true); + const setup = await postJson(port, "/v1/setup/first-admin", { username: "admin", password: "admin-pass" diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 77a35607..14c576ea 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -117,6 +117,8 @@ const MUTATING_INTENTS = new Set([ const DEFAULT_DEVICE_LEASE_TTL_SECONDS = 15 * 60; const MAX_DEVICE_LEASE_TTL_SECONDS = 60 * 60; const DEVICE_JOB_OUTPUT_MAX_BYTES = 12000; +const DEVICE_POD_PROFILE_SECRET_KEY_PATTERN = /(?:token|secret|password|passphrase|credential|api[_-]?key|access[_-]?key|private[_-]?key|git[_-]?key|cloud[_-]?token|kubeconfig|database[_-]?url|db[_-]?url|connection[_-]?string)/iu; +const DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(?:ql)?:\/\/|mysql:\/\/|mongodb(?:\+srv)?:\/\/|redis:\/\/|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})/u; export function createAccessController(options = {}) { return new AccessController({ @@ -331,11 +333,12 @@ class AccessController { const devicePodId = request.method === "PUT" ? decodeURIComponent(url.pathname.split("/").filter(Boolean)[3] ?? "") : textOr(body.devicePodId ?? body.id, ""); + const profile = assertDevicePodProfileSafe(normalizeObject(body.profile ?? body.profileJson), "profile"); const pod = await this.store.upsertDevicePod({ id: requiredText(devicePodId, "devicePodId"), name: textOr(body.name, devicePodId), status: body.status === "disabled" ? "disabled" : "active", - profile: normalizeObject(body.profile ?? body.profileJson), + profile, now: this.now() }); return sendJson(response, request.method === "POST" ? 201 : 200, { @@ -944,7 +947,7 @@ class MemoryAccessStore { async revokeSessionByTokenHash(tokenHash, now) { for (const session of this.sessions.values()) if (session.tokenHash === tokenHash) session.revokedAt = now; } async upsertDevicePod(input) { const existing = this.devicePods.get(input.id); - const profile = normalizeObject(input.profile); + const profile = assertDevicePodProfileSafe(normalizeObject(input.profile), "profile"); const now = input.now ?? this.now(); const pod = { id: input.id, name: input.name ?? existing?.name ?? input.id, status: input.status ?? existing?.status ?? "active", profile, profileHash: profileHash(profile), createdAt: existing?.createdAt ?? now, updatedAt: now }; this.devicePods.set(pod.id, pod); @@ -1086,6 +1089,32 @@ async function jsonBody(request) { function requiredText(value, field) { const text = textOr(value, ""); if (!text) throw Object.assign(new Error(`${field} is required`), { statusCode: 400, code: "invalid_params" }); return text; } function textOr(value, fallback) { const text = String(value ?? "").trim(); return text || fallback; } function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; } +function assertDevicePodProfileSafe(profile, field = "profile") { + const findings = []; + collectDevicePodProfileSecretFindings(profile, field, findings); + if (findings.length > 0) { + const paths = findings.slice(0, 3).join(", "); + throw Object.assign(new Error(`${field} contains forbidden secret material at ${paths}`), { statusCode: 400, code: "device_pod_profile_secret_forbidden" }); + } + return profile; +} +function collectDevicePodProfileSecretFindings(value, path, findings) { + if (findings.length >= 5) return; + if (Array.isArray(value)) { + value.forEach((item, index) => collectDevicePodProfileSecretFindings(item, `${path}[${index}]`, findings)); + return; + } + if (value && typeof value === "object") { + for (const [key, child] of Object.entries(value)) { + const childPath = `${path}.${key}`; + if (DEVICE_POD_PROFILE_SECRET_KEY_PATTERN.test(key)) findings.push(childPath); + collectDevicePodProfileSecretFindings(child, childPath, findings); + if (findings.length >= 5) return; + } + return; + } + if (typeof value === "string" && DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN.test(value)) findings.push(path); +} function firstAdminDevicePodSeeds(body = {}) { const hasSingle = Object.hasOwn(body, "devicePod"); const hasMany = Object.hasOwn(body, "devicePods"); @@ -1100,6 +1129,7 @@ function firstAdminDevicePodSeed(value, field) { const id = requiredText(value.devicePodId ?? value.id, `${field}.devicePodId`); const profile = normalizeObject(value.profile ?? value.profileJson); if (Object.keys(profile).length === 0) throw Object.assign(new Error(`${field}.profile is required`), { statusCode: 400, code: "invalid_params" }); + assertDevicePodProfileSafe(profile, `${field}.profile`); return { id, name: textOr(value.name, id),