fix: reject secrets in device pod profiles
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user