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

@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
- Secret files: harden CLI and channel credential file reads against path-swap races by requiring direct regular files for `*File` secret inputs and rejecting symlink-backed secret files.
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.

View File

@@ -87,6 +87,8 @@ Token/secret files:
}
```
`tokenFile` and `secretFile` must point to regular files. Symlinks are rejected.
Multiple accounts:
```json5

View File

@@ -115,7 +115,7 @@ Provider options:
- `channels.nextcloud-talk.enabled`: enable/disable channel startup.
- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL.
- `channels.nextcloud-talk.botSecret`: bot shared secret.
- `channels.nextcloud-talk.botSecretFile`: secret file path.
- `channels.nextcloud-talk.botSecretFile`: regular-file secret path. Symlinks are rejected.
- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection).
- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups.
- `channels.nextcloud-talk.apiPasswordFile`: API password file path.

View File

@@ -892,7 +892,7 @@ Primary reference:
- `channels.telegram.enabled`: enable/disable channel startup.
- `channels.telegram.botToken`: bot token (BotFather).
- `channels.telegram.tokenFile`: read token from file path.
- `channels.telegram.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows.
- `channels.telegram.actions.poll`: enable or disable Telegram poll creation (default: enabled; still requires `sendMessage`).
@@ -953,7 +953,7 @@ Primary reference:
Telegram-specific high-signal fields:
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` (`tokenFile` must point to a regular file; symlinks are rejected)
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`

View File

@@ -179,7 +179,7 @@ Provider options:
- `channels.zalo.enabled`: enable/disable channel startup.
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
- `channels.zalo.tokenFile`: read token from file path.
- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
@@ -193,7 +193,7 @@ Provider options:
Multi-account options:
- `channels.zalo.accounts.<id>.botToken`: per-account token.
- `channels.zalo.accounts.<id>.tokenFile`: per-account token file.
- `channels.zalo.accounts.<id>.tokenFile`: per-account regular token file. Symlinks are rejected.
- `channels.zalo.accounts.<id>.name`: display name.
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.

View File

@@ -203,7 +203,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
}
```
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile` (regular file only; symlinks rejected), with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid.
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).

View File

@@ -199,7 +199,7 @@ If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe.
Use this when auditing access or deciding what to back up:
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
- **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**:

View File

@@ -127,7 +127,7 @@ openclaw health
Use this when debugging auth or deciding what to back up:
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
- **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**:

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(

View File

@@ -1,54 +1,12 @@
import { mkdir, symlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { describe, expect, it } from "vitest";
import { MAX_SECRET_FILE_BYTES, readSecretFromFile } from "./secret-file.js";
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-secret-file-test-");
afterEach(async () => {
await tempDirs.cleanup();
});
describe("readSecretFromFile", () => {
it("reads and trims a regular secret file", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, " top-secret \n", "utf8");
expect(readSecretFromFile(file, "Gateway password")).toBe("top-secret");
it("keeps the shared secret-file limit", () => {
expect(MAX_SECRET_FILE_BYTES).toBe(16 * 1024);
});
it("rejects files larger than the secret-file limit", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, "x".repeat(MAX_SECRET_FILE_BYTES + 1), "utf8");
expect(() => readSecretFromFile(file, "Gateway password")).toThrow(
`Gateway password file at ${file} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`,
);
});
it("rejects non-regular files", async () => {
const dir = await createTempDir();
const nestedDir = path.join(dir, "secret-dir");
await mkdir(nestedDir);
expect(() => readSecretFromFile(nestedDir, "Gateway password")).toThrow(
`Gateway password file at ${nestedDir} must be a regular file.`,
);
});
it("rejects symlinks", async () => {
const dir = await createTempDir();
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
expect(() => readSecretFromFile(link, "Gateway password")).toThrow(
`Gateway password file at ${link} must not be a symlink.`,
);
it("exposes the hardened secret reader", () => {
expect(typeof readSecretFromFile).toBe("function");
});
});

View File

@@ -1,43 +1,10 @@
import fs from "node:fs";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_SECRET_FILE_MAX_BYTES, readSecretFileSync } from "../infra/secret-file.js";
export const MAX_SECRET_FILE_BYTES = 16 * 1024;
export const MAX_SECRET_FILE_BYTES = DEFAULT_SECRET_FILE_MAX_BYTES;
export function readSecretFromFile(filePath: string, label: string): string {
const resolvedPath = resolveUserPath(filePath.trim());
if (!resolvedPath) {
throw new Error(`${label} file path is empty.`);
}
let stat: fs.Stats;
try {
stat = fs.lstatSync(resolvedPath);
} catch (err) {
throw new Error(`Failed to inspect ${label} file at ${resolvedPath}: ${String(err)}`, {
cause: err,
});
}
if (stat.isSymbolicLink()) {
throw new Error(`${label} file at ${resolvedPath} must not be a symlink.`);
}
if (!stat.isFile()) {
throw new Error(`${label} file at ${resolvedPath} must be a regular file.`);
}
if (stat.size > MAX_SECRET_FILE_BYTES) {
throw new Error(`${label} file at ${resolvedPath} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`);
}
let raw = "";
try {
raw = fs.readFileSync(resolvedPath, "utf8");
} catch (err) {
throw new Error(`Failed to read ${label} file at ${resolvedPath}: ${String(err)}`, {
cause: err,
});
}
const secret = raw.trim();
if (!secret) {
throw new Error(`${label} file at ${resolvedPath} is empty.`);
}
return secret;
return readSecretFileSync(filePath, label, {
maxBytes: MAX_SECRET_FILE_BYTES,
rejectSymlink: true,
});
}

View File

@@ -93,7 +93,7 @@ export type TelegramAccountConfig = {
/** If false, do not start this Telegram account. Default: true. */
enabled?: boolean;
botToken?: string;
/** Path to file containing bot token (for secret managers like agenix). */
/** Path to a regular file containing the bot token; symlinks are rejected. */
tokenFile?: string;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;

View File

@@ -0,0 +1,70 @@
import { mkdir, symlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
DEFAULT_SECRET_FILE_MAX_BYTES,
readSecretFileSync,
tryReadSecretFileSync,
} from "./secret-file.js";
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-secret-file-test-");
afterEach(async () => {
await tempDirs.cleanup();
});
describe("readSecretFileSync", () => {
it("reads and trims a regular secret file", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, " top-secret \n", "utf8");
expect(readSecretFileSync(file, "Gateway password")).toBe("top-secret");
});
it("rejects files larger than the secret-file limit", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1), "utf8");
expect(() => readSecretFileSync(file, "Gateway password")).toThrow(
`Gateway password file at ${file} exceeds ${DEFAULT_SECRET_FILE_MAX_BYTES} bytes.`,
);
});
it("rejects non-regular files", async () => {
const dir = await createTempDir();
const nestedDir = path.join(dir, "secret-dir");
await mkdir(nestedDir);
expect(() => readSecretFileSync(nestedDir, "Gateway password")).toThrow(
`Gateway password file at ${nestedDir} must be a regular file.`,
);
});
it("rejects symlinks when configured", async () => {
const dir = await createTempDir();
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
expect(() => readSecretFileSync(link, "Gateway password", { rejectSymlink: true })).toThrow(
`Gateway password file at ${link} must not be a symlink.`,
);
});
it("returns undefined from the non-throwing helper for rejected files", async () => {
const dir = await createTempDir();
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
expect(tryReadSecretFileSync(link, "Telegram bot token", { rejectSymlink: true })).toBe(
undefined,
);
});
});

133
src/infra/secret-file.ts Normal file
View File

@@ -0,0 +1,133 @@
import fs from "node:fs";
import { resolveUserPath } from "../utils.js";
import { openVerifiedFileSync } from "./safe-open-sync.js";
export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
export type SecretFileReadOptions = {
maxBytes?: number;
rejectSymlink?: boolean;
};
export type SecretFileReadResult =
| {
ok: true;
secret: string;
resolvedPath: string;
}
| {
ok: false;
message: string;
resolvedPath?: string;
error?: unknown;
};
export function loadSecretFileSync(
filePath: string,
label: string,
options: SecretFileReadOptions = {},
): SecretFileReadResult {
const trimmedPath = filePath.trim();
const resolvedPath = resolveUserPath(trimmedPath);
if (!resolvedPath) {
return { ok: false, message: `${label} file path is empty.` };
}
const maxBytes = options.maxBytes ?? DEFAULT_SECRET_FILE_MAX_BYTES;
let previewStat: fs.Stats;
try {
previewStat = fs.lstatSync(resolvedPath);
} catch (error) {
return {
ok: false,
resolvedPath,
error,
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(error)}`,
};
}
if (options.rejectSymlink && previewStat.isSymbolicLink()) {
return {
ok: false,
resolvedPath,
message: `${label} file at ${resolvedPath} must not be a symlink.`,
};
}
if (!previewStat.isFile()) {
return {
ok: false,
resolvedPath,
message: `${label} file at ${resolvedPath} must be a regular file.`,
};
}
if (previewStat.size > maxBytes) {
return {
ok: false,
resolvedPath,
message: `${label} file at ${resolvedPath} exceeds ${maxBytes} bytes.`,
};
}
const opened = openVerifiedFileSync({
filePath: resolvedPath,
rejectPathSymlink: options.rejectSymlink,
maxBytes,
});
if (!opened.ok) {
const error =
opened.reason === "validation" ? new Error("security validation failed") : opened.error;
return {
ok: false,
resolvedPath,
error,
message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`,
};
}
try {
const raw = fs.readFileSync(opened.fd, "utf8");
const secret = raw.trim();
if (!secret) {
return {
ok: false,
resolvedPath,
message: `${label} file at ${resolvedPath} is empty.`,
};
}
return { ok: true, secret, resolvedPath };
} catch (error) {
return {
ok: false,
resolvedPath,
error,
message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`,
};
} finally {
fs.closeSync(opened.fd);
}
}
export function readSecretFileSync(
filePath: string,
label: string,
options: SecretFileReadOptions = {},
): string {
const result = loadSecretFileSync(filePath, label, options);
if (result.ok) {
return result.secret;
}
throw new Error(result.message, result.error ? { cause: result.error } : undefined);
}
export function tryReadSecretFileSync(
filePath: string | undefined,
label: string,
options: SecretFileReadOptions = {},
): string | undefined {
if (!filePath?.trim()) {
return undefined;
}
const result = loadSecretFileSync(filePath, label, options);
return result.ok ? result.secret : undefined;
}

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
@@ -97,6 +100,33 @@ describe("LINE accounts", () => {
expect(account.channelSecret).toBe("");
expect(account.tokenSource).toBe("none");
});
it.runIf(process.platform !== "win32")("rejects symlinked token and secret files", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-account-"));
const tokenFile = path.join(dir, "token.txt");
const tokenLink = path.join(dir, "token-link.txt");
const secretFile = path.join(dir, "secret.txt");
const secretLink = path.join(dir, "secret-link.txt");
fs.writeFileSync(tokenFile, "file-token\n", "utf8");
fs.writeFileSync(secretFile, "file-secret\n", "utf8");
fs.symlinkSync(tokenFile, tokenLink);
fs.symlinkSync(secretFile, secretLink);
const cfg: OpenClawConfig = {
channels: {
line: {
tokenFile: tokenLink,
secretFile: secretLink,
},
},
};
const account = resolveLineAccount({ cfg });
expect(account.channelAccessToken).toBe("");
expect(account.channelSecret).toBe("");
expect(account.tokenSource).toBe("none");
fs.rmSync(dir, { recursive: true, force: true });
});
});
describe("resolveDefaultLineAccountId", () => {

View File

@@ -1,5 +1,5 @@
import fs from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import { tryReadSecretFileSync } from "../infra/secret-file.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId as normalizeSharedAccountId,
@@ -16,14 +16,7 @@ import type {
export { DEFAULT_ACCOUNT_ID } from "../routing/account-id.js";
function readFileIfExists(filePath: string | undefined): string | undefined {
if (!filePath) {
return undefined;
}
try {
return fs.readFileSync(filePath, "utf-8").trim();
} catch {
return undefined;
}
return tryReadSecretFileSync(filePath, "LINE credential file", { rejectSymlink: true });
}
function resolveToken(params: {

View File

@@ -18,6 +18,13 @@ export {
listDevicePairing,
rejectDevicePairing,
} from "../infra/device-pairing.js";
export {
DEFAULT_SECRET_FILE_MAX_BYTES,
loadSecretFileSync,
readSecretFileSync,
tryReadSecretFileSync,
} from "../infra/secret-file.js";
export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secret-file.js";
export {
runPluginCommandWithTimeout,

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 type { OpenClawConfig } from "../config/config.js";
import { withEnv } from "../test-utils/env.js";
@@ -76,4 +79,29 @@ describe("inspectTelegramAccount SecretRef resolution", () => {
expect(account.token).toBe("");
});
});
it.runIf(process.platform !== "win32")(
"treats symlinked token files as configured_unavailable",
() => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-inspect-"));
const tokenFile = path.join(dir, "token.txt");
const tokenLink = path.join(dir, "token-link.txt");
fs.writeFileSync(tokenFile, "123:token\n", "utf8");
fs.symlinkSync(tokenFile, tokenLink);
const cfg: OpenClawConfig = {
channels: {
telegram: {
tokenFile: tokenLink,
},
},
};
const account = inspectTelegramAccount({ cfg, accountId: "default" });
expect(account.tokenSource).toBe("tokenFile");
expect(account.tokenStatus).toBe("configured_unavailable");
expect(account.token).toBe("");
fs.rmSync(dir, { recursive: true, force: true });
},
);
});

View File

@@ -1,4 +1,3 @@
import fs from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import {
coerceSecretRef,
@@ -6,6 +5,7 @@ import {
normalizeSecretInputString,
} from "../config/types.secrets.js";
import type { TelegramAccountConfig } from "../config/types.telegram.js";
import { tryReadSecretFileSync } from "../infra/secret-file.js";
import { resolveAccountWithDefaultFallback } from "../plugin-sdk/account-resolution.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
@@ -37,27 +37,14 @@ function inspectTokenFile(pathValue: unknown): {
if (!tokenFile) {
return null;
}
if (!fs.existsSync(tokenFile)) {
return {
token: "",
tokenSource: "tokenFile",
tokenStatus: "configured_unavailable",
};
}
try {
const token = fs.readFileSync(tokenFile, "utf-8").trim();
return {
token,
tokenSource: "tokenFile",
tokenStatus: token ? "available" : "configured_unavailable",
};
} catch {
return {
token: "",
tokenSource: "tokenFile",
tokenStatus: "configured_unavailable",
};
}
const token = tryReadSecretFileSync(tokenFile, "Telegram bot token", {
rejectSymlink: true,
});
return {
token: token ?? "",
tokenSource: "tokenFile",
tokenStatus: token ? "available" : "configured_unavailable",
};
}
function canResolveEnvSecretRefInReadOnlyPath(params: {

View File

@@ -48,6 +48,21 @@ describe("resolveTelegramToken", () => {
fs.rmSync(dir, { recursive: true, force: true });
});
it.runIf(process.platform !== "win32")("rejects symlinked tokenFile paths", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
const dir = withTempDir();
const tokenFile = path.join(dir, "token.txt");
const tokenLink = path.join(dir, "token-link.txt");
fs.writeFileSync(tokenFile, "file-token\n", "utf-8");
fs.symlinkSync(tokenFile, tokenLink);
const cfg = { channels: { telegram: { tokenFile: tokenLink } } } as OpenClawConfig;
const res = resolveTelegramToken(cfg);
expect(res.token).toBe("");
expect(res.source).toBe("none");
fs.rmSync(dir, { recursive: true, force: true });
});
it("falls back to config token when no env or tokenFile", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
const cfg = {

View File

@@ -1,8 +1,8 @@
import fs from "node:fs";
import type { BaseTokenResolution } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeResolvedSecretInputString } from "../config/types.secrets.js";
import type { TelegramAccountConfig } from "../config/types.telegram.js";
import { tryReadSecretFileSync } from "../infra/secret-file.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
@@ -46,23 +46,17 @@ export function resolveTelegramToken(
);
const accountTokenFile = accountCfg?.tokenFile?.trim();
if (accountTokenFile) {
if (!fs.existsSync(accountTokenFile)) {
opts.logMissingFile?.(
`channels.telegram.accounts.${accountId}.tokenFile not found: ${accountTokenFile}`,
);
return { token: "", source: "none" };
}
try {
const token = fs.readFileSync(accountTokenFile, "utf-8").trim();
if (token) {
return { token, source: "tokenFile" };
}
} catch (err) {
opts.logMissingFile?.(
`channels.telegram.accounts.${accountId}.tokenFile read failed: ${String(err)}`,
);
return { token: "", source: "none" };
const token = tryReadSecretFileSync(
accountTokenFile,
`channels.telegram.accounts.${accountId}.tokenFile`,
{ rejectSymlink: true },
);
if (token) {
return { token, source: "tokenFile" };
}
opts.logMissingFile?.(
`channels.telegram.accounts.${accountId}.tokenFile not found or unreadable: ${accountTokenFile}`,
);
return { token: "", source: "none" };
}
@@ -77,19 +71,14 @@ export function resolveTelegramToken(
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const tokenFile = telegramCfg?.tokenFile?.trim();
if (tokenFile) {
if (!fs.existsSync(tokenFile)) {
opts.logMissingFile?.(`channels.telegram.tokenFile not found: ${tokenFile}`);
return { token: "", source: "none" };
}
try {
const token = fs.readFileSync(tokenFile, "utf-8").trim();
if (token) {
return { token, source: "tokenFile" };
}
} catch (err) {
opts.logMissingFile?.(`channels.telegram.tokenFile read failed: ${String(err)}`);
return { token: "", source: "none" };
const token = tryReadSecretFileSync(tokenFile, "channels.telegram.tokenFile", {
rejectSymlink: true,
});
if (token) {
return { token, source: "tokenFile" };
}
opts.logMissingFile?.(`channels.telegram.tokenFile not found or unreadable: ${tokenFile}`);
return { token: "", source: "none" };
}
const configToken = normalizeResolvedSecretInputString({