fix: reject secrets in device pod profiles

This commit is contained in:
Codex
2026-05-29 17:28:47 +08:00
parent baae127525
commit 752fe382ab
2 changed files with 68 additions and 2 deletions
+36
View File
@@ -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"
+32 -2
View File
@@ -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),