mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-29 19:55:04 +00:00
Secrets: reject exec SecretRef traversal ids across schema/runtime/gateway (#42370)
* Secrets: harden exec SecretRef validation and reload LKG coverage * Tests: harden exec fast-exit stdin regression case * Tests: align lifecycle daemon test formatting with oxfmt 0.36
This commit is contained in:
34
src/gateway/protocol/primitives.secretref.test.ts
Normal file
34
src/gateway/protocol/primitives.secretref.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import AjvPkg from "ajv";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
INVALID_EXEC_SECRET_REF_IDS,
|
||||
VALID_EXEC_SECRET_REF_IDS,
|
||||
} from "../../test-utils/secret-ref-test-vectors.js";
|
||||
import { SecretInputSchema, SecretRefSchema } from "./schema/primitives.js";
|
||||
|
||||
describe("gateway protocol SecretRef schema", () => {
|
||||
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
const validateSecretRef = ajv.compile(SecretRefSchema);
|
||||
const validateSecretInput = ajv.compile(SecretInputSchema);
|
||||
|
||||
it("accepts valid source-specific refs", () => {
|
||||
expect(validateSecretRef({ source: "env", provider: "default", id: "OPENAI_API_KEY" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
validateSecretRef({ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }),
|
||||
).toBe(true);
|
||||
for (const id of VALID_EXEC_SECRET_REF_IDS) {
|
||||
expect(validateSecretRef({ source: "exec", provider: "vault", id }), id).toBe(true);
|
||||
expect(validateSecretInput({ source: "exec", provider: "vault", id }), id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid exec refs", () => {
|
||||
for (const id of INVALID_EXEC_SECRET_REF_IDS) {
|
||||
expect(validateSecretRef({ source: "exec", provider: "vault", id }), id).toBe(false);
|
||||
expect(validateSecretInput({ source: "exec", provider: "vault", id }), id).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { ENV_SECRET_REF_ID_RE } from "../../../config/types.secrets.js";
|
||||
import {
|
||||
EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN,
|
||||
FILE_SECRET_REF_ID_PATTERN,
|
||||
SECRET_PROVIDER_ALIAS_PATTERN,
|
||||
} from "../../../secrets/ref-contract.js";
|
||||
import { SESSION_LABEL_MAX_LENGTH } from "../../../sessions/session-label.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../client-info.js";
|
||||
|
||||
@@ -27,13 +33,41 @@ export const SecretRefSourceSchema = Type.Union([
|
||||
Type.Literal("exec"),
|
||||
]);
|
||||
|
||||
export const SecretRefSchema = Type.Object(
|
||||
const SecretProviderAliasString = Type.String({
|
||||
pattern: SECRET_PROVIDER_ALIAS_PATTERN.source,
|
||||
});
|
||||
|
||||
const EnvSecretRefSchema = Type.Object(
|
||||
{
|
||||
source: SecretRefSourceSchema,
|
||||
provider: NonEmptyString,
|
||||
id: NonEmptyString,
|
||||
source: Type.Literal("env"),
|
||||
provider: SecretProviderAliasString,
|
||||
id: Type.String({ pattern: ENV_SECRET_REF_ID_RE.source }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const FileSecretRefSchema = Type.Object(
|
||||
{
|
||||
source: Type.Literal("file"),
|
||||
provider: SecretProviderAliasString,
|
||||
id: Type.String({ pattern: FILE_SECRET_REF_ID_PATTERN.source }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const ExecSecretRefSchema = Type.Object(
|
||||
{
|
||||
source: Type.Literal("exec"),
|
||||
provider: SecretProviderAliasString,
|
||||
id: Type.String({ pattern: EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SecretRefSchema = Type.Union([
|
||||
EnvSecretRefSchema,
|
||||
FileSecretRefSchema,
|
||||
ExecSecretRefSchema,
|
||||
]);
|
||||
|
||||
export const SecretInputSchema = Type.Union([Type.String(), SecretRefSchema]);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
withGatewayServer,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
@@ -242,6 +243,94 @@ describe("gateway hot reload", () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function writeTalkApiKeyEnvRefConfig(refId = "TALK_API_KEY_REF") {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is not set");
|
||||
}
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: refId },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function writeGatewayTraversalExecRefConfig() {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is not set");
|
||||
}
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "exec", provider: "vault", id: "a/../b" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
vault: {
|
||||
source: "exec",
|
||||
command: process.execPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function writeGatewayTokenExecRefConfig(params: {
|
||||
resolverScriptPath: string;
|
||||
modePath: string;
|
||||
tokenValue: string;
|
||||
}) {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is not set");
|
||||
}
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "exec", provider: "vault", id: "gateway/token" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
vault: {
|
||||
source: "exec",
|
||||
command: process.execPath,
|
||||
allowSymlinkCommand: true,
|
||||
args: [params.resolverScriptPath, params.modePath, params.tokenValue],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function writeDisabledSurfaceRefConfig() {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
@@ -485,6 +574,13 @@ describe("gateway hot reload", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fails startup when an active exec ref id contains traversal segments", async () => {
|
||||
await writeGatewayTraversalExecRefConfig();
|
||||
await expect(withGatewayServer(async () => {})).rejects.toThrow(
|
||||
/must not include "\." or "\.\." path segments/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows startup when unresolved refs exist only on disabled surfaces", async () => {
|
||||
await writeDisabledSurfaceRefConfig();
|
||||
delete process.env.DISABLED_TELEGRAM_STARTUP_REF;
|
||||
@@ -650,6 +746,154 @@ describe("gateway hot reload", () => {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps last-known-good snapshot active when secrets.reload fails over RPC", async () => {
|
||||
const refId = "RUNTIME_LKG_TALK_API_KEY";
|
||||
const previousRefValue = process.env[refId];
|
||||
process.env[refId] = "talk-key-before-reload-failure"; // pragma: allowlist secret
|
||||
await writeTalkApiKeyEnvRefConfig(refId);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
try {
|
||||
await connectOk(ws);
|
||||
const preResolve = await rpcReq<{
|
||||
assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>;
|
||||
}>(ws, "secrets.resolve", {
|
||||
commandName: "runtime-lkg-test",
|
||||
targetIds: ["talk.apiKey"],
|
||||
});
|
||||
expect(preResolve.ok).toBe(true);
|
||||
expect(preResolve.payload?.assignments?.[0]?.path).toBe("talk.apiKey");
|
||||
expect(preResolve.payload?.assignments?.[0]?.value).toBe("talk-key-before-reload-failure");
|
||||
|
||||
delete process.env[refId];
|
||||
const reload = await rpcReq<{ warningCount?: number }>(ws, "secrets.reload", {});
|
||||
expect(reload.ok).toBe(false);
|
||||
expect(reload.error?.code).toBe("UNAVAILABLE");
|
||||
expect(reload.error?.message ?? "").toContain(refId);
|
||||
|
||||
const postResolve = await rpcReq<{
|
||||
assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>;
|
||||
}>(ws, "secrets.resolve", {
|
||||
commandName: "runtime-lkg-test",
|
||||
targetIds: ["talk.apiKey"],
|
||||
});
|
||||
expect(postResolve.ok).toBe(true);
|
||||
expect(postResolve.payload?.assignments?.[0]?.path).toBe("talk.apiKey");
|
||||
expect(postResolve.payload?.assignments?.[0]?.value).toBe("talk-key-before-reload-failure");
|
||||
} finally {
|
||||
if (previousRefValue === undefined) {
|
||||
delete process.env[refId];
|
||||
} else {
|
||||
process.env[refId] = previousRefValue;
|
||||
}
|
||||
ws.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps last-known-good auth snapshot active when gateway auth token exec reload fails", async () => {
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
throw new Error("OPENCLAW_STATE_DIR is not set");
|
||||
}
|
||||
const resolverScriptPath = path.join(stateDir, "gateway-auth-token-resolver.cjs");
|
||||
const modePath = path.join(stateDir, "gateway-auth-token-resolver.mode");
|
||||
const tokenValue = "gateway-auth-exec-token";
|
||||
await fs.mkdir(path.dirname(resolverScriptPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
resolverScriptPath,
|
||||
`const fs = require("node:fs");
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
const modePath = process.argv[2];
|
||||
const token = process.argv[3];
|
||||
const mode = fs.existsSync(modePath) ? fs.readFileSync(modePath, "utf8").trim() : "ok";
|
||||
let ids = ["gateway/token"];
|
||||
try {
|
||||
const parsed = JSON.parse(input || "{}");
|
||||
if (Array.isArray(parsed.ids) && parsed.ids.length > 0) {
|
||||
ids = parsed.ids.map((entry) => String(entry));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (mode === "fail") {
|
||||
const errors = {};
|
||||
for (const id of ids) {
|
||||
errors[id] = { message: "forced failure" };
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ protocolVersion: 1, values: {}, errors }) + "\\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const values = {};
|
||||
for (const id of ids) {
|
||||
values[id] = token;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ protocolVersion: 1, values }) + "\\n");
|
||||
});
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(modePath, "ok\n", "utf8");
|
||||
await writeGatewayTokenExecRefConfig({
|
||||
resolverScriptPath,
|
||||
modePath,
|
||||
tokenValue,
|
||||
});
|
||||
|
||||
const previousGatewayAuth = testState.gatewayAuth;
|
||||
const previousGatewayTokenEnv = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
testState.gatewayAuth = undefined;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
|
||||
const started = await startServerWithClient();
|
||||
const { server, ws, envSnapshot } = started;
|
||||
try {
|
||||
await connectOk(ws, {
|
||||
token: tokenValue,
|
||||
});
|
||||
const preResolve = await rpcReq<{
|
||||
assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>;
|
||||
}>(ws, "secrets.resolve", {
|
||||
commandName: "runtime-lkg-auth-test",
|
||||
targetIds: ["gateway.auth.token"],
|
||||
});
|
||||
expect(preResolve.ok).toBe(true);
|
||||
expect(preResolve.payload?.assignments?.[0]?.path).toBe("gateway.auth.token");
|
||||
expect(preResolve.payload?.assignments?.[0]?.value).toBe(tokenValue);
|
||||
|
||||
await fs.writeFile(modePath, "fail\n", "utf8");
|
||||
const reload = await rpcReq<{ warningCount?: number }>(ws, "secrets.reload", {});
|
||||
expect(reload.ok).toBe(false);
|
||||
expect(reload.error?.code).toBe("UNAVAILABLE");
|
||||
expect(reload.error?.message ?? "").toContain("forced failure");
|
||||
|
||||
const postResolve = await rpcReq<{
|
||||
assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>;
|
||||
}>(ws, "secrets.resolve", {
|
||||
commandName: "runtime-lkg-auth-test",
|
||||
targetIds: ["gateway.auth.token"],
|
||||
});
|
||||
expect(postResolve.ok).toBe(true);
|
||||
expect(postResolve.payload?.assignments?.[0]?.path).toBe("gateway.auth.token");
|
||||
expect(postResolve.payload?.assignments?.[0]?.value).toBe(tokenValue);
|
||||
} finally {
|
||||
testState.gatewayAuth = previousGatewayAuth;
|
||||
if (previousGatewayTokenEnv === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayTokenEnv;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
ws.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway agents", () => {
|
||||
|
||||
Reference in New Issue
Block a user