fix: harden secret-file readers

This commit is contained in:
Peter Steinberger
2026-03-10 23:40:10 +00:00
parent 208fb1aa35
commit 201420a7ee
26 changed files with 433 additions and 188 deletions

View File

@@ -1,5 +1,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listIrcAccountIds, resolveDefaultIrcAccountId } from "./accounts.js";
import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
import type { CoreConfig } from "./types.js";
function asConfig(value: unknown): CoreConfig {
@@ -76,3 +79,28 @@ describe("resolveDefaultIrcAccountId", () => {
expect(resolveDefaultIrcAccountId(cfg)).toBe("aaa");
});
});
describe("resolveIrcAccount", () => {
it.runIf(process.platform !== "win32")("rejects symlinked password files", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-irc-account-"));
const passwordFile = path.join(dir, "password.txt");
const passwordLink = path.join(dir, "password-link.txt");
fs.writeFileSync(passwordFile, "secret-pass\n", "utf8");
fs.symlinkSync(passwordFile, passwordLink);
const cfg = asConfig({
channels: {
irc: {
host: "irc.example.com",
nick: "claw",
passwordFile: passwordLink,
},
},
});
const account = resolveIrcAccount({ cfg });
expect(account.password).toBe("");
expect(account.passwordSource).toBe("none");
fs.rmSync(dir, { recursive: true, force: true });
});
});

View File

@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
import {
createAccountListHelpers,
normalizeResolvedSecretInputString,
@@ -100,13 +100,11 @@ function resolvePassword(accountId: string, merged: IrcAccountConfig) {
}
if (merged.passwordFile?.trim()) {
try {
const filePassword = readFileSync(merged.passwordFile.trim(), "utf-8").trim();
if (filePassword) {
return { password: filePassword, source: "passwordFile" as const };
}
} catch {
// Ignore unreadable files here; status will still surface missing configuration.
const filePassword = tryReadSecretFileSync(merged.passwordFile, "IRC password file", {
rejectSymlink: true,
});
if (filePassword) {
return { password: filePassword, source: "passwordFile" as const };
}
}
@@ -137,11 +135,10 @@ function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig):
envPassword ||
"";
if (!resolvedPassword && passwordFile) {
try {
resolvedPassword = readFileSync(passwordFile, "utf-8").trim();
} catch {
// Ignore unreadable files; monitor/probe status will surface failures.
}
resolvedPassword =
tryReadSecretFileSync(passwordFile, "IRC NickServ password file", {
rejectSymlink: true,
}) ?? "";
}
const merged: IrcNickServConfig = {

View File

@@ -0,0 +1,30 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import type { CoreConfig } from "./types.js";
describe("resolveNextcloudTalkAccount", () => {
it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-"));
const secretFile = path.join(dir, "secret.txt");
const secretLink = path.join(dir, "secret-link.txt");
fs.writeFileSync(secretFile, "bot-secret\n", "utf8");
fs.symlinkSync(secretFile, secretLink);
const cfg = {
channels: {
"nextcloud-talk": {
baseUrl: "https://cloud.example.com",
botSecretFile: secretLink,
},
},
} as CoreConfig;
const account = resolveNextcloudTalkAccount({ cfg });
expect(account.secret).toBe("");
expect(account.secretSource).toBe("none");
fs.rmSync(dir, { recursive: true, force: true });
});
});

View File

@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
import {
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
@@ -88,13 +88,13 @@ function resolveNextcloudTalkSecret(
}
if (merged.botSecretFile) {
try {
const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim();
if (fileSecret) {
return { secret: fileSecret, source: "secretFile" };
}
} catch {
// File not found or unreadable, fall through.
const fileSecret = tryReadSecretFileSync(
merged.botSecretFile,
"Nextcloud Talk bot secret file",
{ rejectSymlink: true },
);
if (fileSecret) {
return { secret: fileSecret, source: "secretFile" };
}
}

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveZaloToken } from "./token.js";
import type { ZaloConfig } from "./types.js";
@@ -55,4 +58,20 @@ describe("resolveZaloToken", () => {
expect(res.token).toBe("work-token");
expect(res.source).toBe("config");
});
it.runIf(process.platform !== "win32")("rejects symlinked token files", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-"));
const tokenFile = path.join(dir, "token.txt");
const tokenLink = path.join(dir, "token-link.txt");
fs.writeFileSync(tokenFile, "file-token\n", "utf8");
fs.symlinkSync(tokenFile, tokenLink);
const cfg = {
tokenFile: tokenLink,
} as ZaloConfig;
const res = resolveZaloToken(cfg);
expect(res.token).toBe("");
expect(res.source).toBe("none");
fs.rmSync(dir, { recursive: true, force: true });
});
});

View File

@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
import type { ZaloConfig } from "./types.js";
@@ -9,16 +9,7 @@ export type ZaloTokenResolution = BaseTokenResolution & {
};
function readTokenFromFile(tokenFile: string | undefined): string {
const trimmedPath = tokenFile?.trim();
if (!trimmedPath) {
return "";
}
try {
return readFileSync(trimmedPath, "utf8").trim();
} catch {
// ignore read failures
return "";
}
return tryReadSecretFileSync(tokenFile, "Zalo token file", { rejectSymlink: true }) ?? "";
}
export function resolveZaloToken(