fix: harden secret-file readers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -87,6 +87,8 @@ Token/secret files:
|
||||
}
|
||||
```
|
||||
|
||||
`tokenFile` and `secretFile` must point to regular files. Symlinks are rejected.
|
||||
|
||||
Multiple accounts:
|
||||
|
||||
```json5
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
30
extensions/nextcloud-talk/src/accounts.test.ts
Normal file
30
extensions/nextcloud-talk/src/accounts.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
70
src/infra/secret-file.test.ts
Normal file
70
src/infra/secret-file.test.ts
Normal 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
133
src/infra/secret-file.ts
Normal 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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user