mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 07:01:40 +03:00
fix(security): restrict hook transform module loading
This commit is contained in:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Docs: https://docs.openclaw.ai
|
Docs: https://docs.openclaw.ai
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config).
|
||||||
|
|
||||||
## 2026.2.13
|
## 2026.2.13
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Notes:
|
|||||||
To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`.
|
To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`.
|
||||||
|
|
||||||
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
|
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
|
||||||
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
|
under `~/.openclaw/hooks/transforms` (see [Webhooks](/automation/webhook)).
|
||||||
|
|
||||||
## Wizard (recommended)
|
## Wizard (recommended)
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ Mapping options (summary):
|
|||||||
|
|
||||||
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
|
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
|
||||||
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
|
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
|
||||||
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
|
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic (restricted to `~/.openclaw/hooks/transforms`).
|
||||||
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
|
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
|
||||||
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
|
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
|
||||||
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
|||||||
path: "/hooks",
|
path: "/hooks",
|
||||||
token: "shared-secret",
|
token: "shared-secret",
|
||||||
presets: ["gmail"],
|
presets: ["gmail"],
|
||||||
transformsDir: "~/.openclaw/hooks",
|
transformsDir: "~/.openclaw/hooks/transforms",
|
||||||
mappings: [
|
mappings: [
|
||||||
{
|
{
|
||||||
id: "gmail-hook",
|
id: "gmail-hook",
|
||||||
@@ -380,7 +380,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 300,
|
timeoutSeconds: 300,
|
||||||
transform: {
|
transform: {
|
||||||
module: "./transforms/gmail.js",
|
module: "gmail.js",
|
||||||
export: "transformGmail",
|
export: "transformGmail",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1987,7 +1987,7 @@ See [Multiple Gateways](/gateway/multiple-gateways).
|
|||||||
allowedSessionKeyPrefixes: ["hook:"],
|
allowedSessionKeyPrefixes: ["hook:"],
|
||||||
allowedAgentIds: ["hooks", "main"],
|
allowedAgentIds: ["hooks", "main"],
|
||||||
presets: ["gmail"],
|
presets: ["gmail"],
|
||||||
transformsDir: "~/.openclaw/hooks",
|
transformsDir: "~/.openclaw/hooks/transforms",
|
||||||
mappings: [
|
mappings: [
|
||||||
{
|
{
|
||||||
match: { path: "gmail" },
|
match: { path: "gmail" },
|
||||||
|
|||||||
@@ -62,16 +62,18 @@ describe("hooks mapping", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("runs transform module", async () => {
|
it("runs transform module", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-"));
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-"));
|
||||||
const modPath = path.join(dir, "transform.mjs");
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
||||||
|
fs.mkdirSync(transformsRoot, { recursive: true });
|
||||||
|
const modPath = path.join(transformsRoot, "transform.mjs");
|
||||||
const placeholder = "${payload.name}";
|
const placeholder = "${payload.name}";
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
modPath,
|
modPath,
|
||||||
`export default ({ payload }) => ({ kind: "wake", text: \`Ping ${placeholder}\` });`,
|
`export default ({ payload }) => ({ kind: "wake", text: \`Ping ${placeholder}\` });`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mappings = resolveHookMappings({
|
const mappings = resolveHookMappings(
|
||||||
transformsDir: dir,
|
{
|
||||||
mappings: [
|
mappings: [
|
||||||
{
|
{
|
||||||
match: { path: "custom" },
|
match: { path: "custom" },
|
||||||
@@ -79,7 +81,9 @@ describe("hooks mapping", () => {
|
|||||||
transform: { module: "transform.mjs" },
|
transform: { module: "transform.mjs" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
},
|
||||||
|
{ configDir },
|
||||||
|
);
|
||||||
|
|
||||||
const result = await applyHookMappings(mappings, {
|
const result = await applyHookMappings(mappings, {
|
||||||
payload: { name: "Ada" },
|
payload: { name: "Ada" },
|
||||||
@@ -97,13 +101,98 @@ describe("hooks mapping", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats null transform as a handled skip", async () => {
|
it("rejects transform module traversal outside transformsDir", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-skip-"));
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-traversal-"));
|
||||||
const modPath = path.join(dir, "transform.mjs");
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
||||||
fs.writeFileSync(modPath, "export default () => null;");
|
fs.mkdirSync(transformsRoot, { recursive: true });
|
||||||
|
expect(() =>
|
||||||
|
resolveHookMappings(
|
||||||
|
{
|
||||||
|
mappings: [
|
||||||
|
{
|
||||||
|
match: { path: "custom" },
|
||||||
|
action: "agent",
|
||||||
|
transform: { module: "../evil.mjs" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ configDir },
|
||||||
|
),
|
||||||
|
).toThrow(/must be within/);
|
||||||
|
});
|
||||||
|
|
||||||
const mappings = resolveHookMappings({
|
it("rejects absolute transform module path outside transformsDir", () => {
|
||||||
transformsDir: dir,
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-abs-"));
|
||||||
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
||||||
|
fs.mkdirSync(transformsRoot, { recursive: true });
|
||||||
|
const outside = path.join(os.tmpdir(), "evil.mjs");
|
||||||
|
expect(() =>
|
||||||
|
resolveHookMappings(
|
||||||
|
{
|
||||||
|
mappings: [
|
||||||
|
{
|
||||||
|
match: { path: "custom" },
|
||||||
|
action: "agent",
|
||||||
|
transform: { module: outside },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ configDir },
|
||||||
|
),
|
||||||
|
).toThrow(/must be within/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects transformsDir traversal outside the transforms root", () => {
|
||||||
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-xformdir-trav-"));
|
||||||
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
||||||
|
fs.mkdirSync(transformsRoot, { recursive: true });
|
||||||
|
expect(() =>
|
||||||
|
resolveHookMappings(
|
||||||
|
{
|
||||||
|
transformsDir: "..",
|
||||||
|
mappings: [
|
||||||
|
{
|
||||||
|
match: { path: "custom" },
|
||||||
|
action: "agent",
|
||||||
|
transform: { module: "transform.mjs" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ configDir },
|
||||||
|
),
|
||||||
|
).toThrow(/Hook transformsDir/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects transformsDir absolute path outside the transforms root", () => {
|
||||||
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-xformdir-abs-"));
|
||||||
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
||||||
|
fs.mkdirSync(transformsRoot, { recursive: true });
|
||||||
|
expect(() =>
|
||||||
|
resolveHookMappings(
|
||||||
|
{
|
||||||
|
transformsDir: os.tmpdir(),
|
||||||
|
mappings: [
|
||||||
|
{
|
||||||
|
match: { path: "custom" },
|
||||||
|
action: "agent",
|
||||||
|
transform: { module: "transform.mjs" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ configDir },
|
||||||
|
),
|
||||||
|
).toThrow(/Hook transformsDir/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts transformsDir subdirectory within the transforms root", async () => {
|
||||||
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-xformdir-ok-"));
|
||||||
|
const transformsSubdir = path.join(configDir, "hooks", "transforms", "subdir");
|
||||||
|
fs.mkdirSync(transformsSubdir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(transformsSubdir, "transform.mjs"), "export default () => null;");
|
||||||
|
|
||||||
|
const mappings = resolveHookMappings(
|
||||||
|
{
|
||||||
|
transformsDir: "subdir",
|
||||||
mappings: [
|
mappings: [
|
||||||
{
|
{
|
||||||
match: { path: "skip" },
|
match: { path: "skip" },
|
||||||
@@ -111,8 +200,44 @@ describe("hooks mapping", () => {
|
|||||||
transform: { module: "transform.mjs" },
|
transform: { module: "transform.mjs" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
{ configDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await applyHookMappings(mappings, {
|
||||||
|
payload: {},
|
||||||
|
headers: {},
|
||||||
|
url: new URL("http://127.0.0.1:18789/hooks/skip"),
|
||||||
|
path: "skip",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result?.ok).toBe(true);
|
||||||
|
if (result?.ok) {
|
||||||
|
expect(result.action).toBeNull();
|
||||||
|
expect("skipped" in result).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats null transform as a handled skip", async () => {
|
||||||
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-skip-"));
|
||||||
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
||||||
|
fs.mkdirSync(transformsRoot, { recursive: true });
|
||||||
|
const modPath = path.join(transformsRoot, "transform.mjs");
|
||||||
|
fs.writeFileSync(modPath, "export default () => null;");
|
||||||
|
|
||||||
|
const mappings = resolveHookMappings(
|
||||||
|
{
|
||||||
|
mappings: [
|
||||||
|
{
|
||||||
|
match: { path: "skip" },
|
||||||
|
action: "agent",
|
||||||
|
transform: { module: "transform.mjs" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ configDir },
|
||||||
|
);
|
||||||
|
|
||||||
const result = await applyHookMappings(mappings, {
|
const result = await applyHookMappings(mappings, {
|
||||||
payload: {},
|
payload: {},
|
||||||
headers: {},
|
headers: {},
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ type HookTransformFn = (
|
|||||||
ctx: HookMappingContext,
|
ctx: HookMappingContext,
|
||||||
) => HookTransformResult | Promise<HookTransformResult>;
|
) => HookTransformResult | Promise<HookTransformResult>;
|
||||||
|
|
||||||
export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[] {
|
export function resolveHookMappings(
|
||||||
|
hooks?: HooksConfig,
|
||||||
|
opts?: { configDir?: string },
|
||||||
|
): HookMappingResolved[] {
|
||||||
const presets = hooks?.presets ?? [];
|
const presets = hooks?.presets ?? [];
|
||||||
const gmailAllowUnsafe = hooks?.gmail?.allowUnsafeExternalContent;
|
const gmailAllowUnsafe = hooks?.gmail?.allowUnsafeExternalContent;
|
||||||
const mappings: HookMappingConfig[] = [];
|
const mappings: HookMappingConfig[] = [];
|
||||||
@@ -129,10 +132,13 @@ export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[]
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const configDir = path.dirname(CONFIG_PATH);
|
const configDir = path.resolve(opts?.configDir ?? path.dirname(CONFIG_PATH));
|
||||||
const transformsDir = hooks?.transformsDir
|
const transformsRootDir = path.join(configDir, "hooks", "transforms");
|
||||||
? resolvePath(configDir, hooks.transformsDir)
|
const transformsDir = resolveOptionalContainedPath(
|
||||||
: configDir;
|
transformsRootDir,
|
||||||
|
hooks?.transformsDir,
|
||||||
|
"Hook transformsDir",
|
||||||
|
);
|
||||||
|
|
||||||
return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir));
|
return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir));
|
||||||
}
|
}
|
||||||
@@ -187,7 +193,7 @@ function normalizeHookMapping(
|
|||||||
const wakeMode = mapping.wakeMode ?? "now";
|
const wakeMode = mapping.wakeMode ?? "now";
|
||||||
const transform = mapping.transform
|
const transform = mapping.transform
|
||||||
? {
|
? {
|
||||||
modulePath: resolvePath(transformsDir, mapping.transform.module),
|
modulePath: resolveContainedPath(transformsDir, mapping.transform.module, "Hook transform"),
|
||||||
exportName: mapping.transform.export?.trim() || undefined,
|
exportName: mapping.transform.export?.trim() || undefined,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -340,12 +346,35 @@ function resolveTransformFn(mod: Record<string, unknown>, exportName?: string):
|
|||||||
|
|
||||||
function resolvePath(baseDir: string, target: string): string {
|
function resolvePath(baseDir: string, target: string): string {
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return baseDir;
|
return path.resolve(baseDir);
|
||||||
}
|
}
|
||||||
if (path.isAbsolute(target)) {
|
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(baseDir, target);
|
||||||
return target;
|
|
||||||
}
|
}
|
||||||
return path.join(baseDir, target);
|
|
||||||
|
function resolveContainedPath(baseDir: string, target: string, label: string): string {
|
||||||
|
const base = path.resolve(baseDir);
|
||||||
|
const trimmed = target?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error(`${label} module path is required`);
|
||||||
|
}
|
||||||
|
const resolved = resolvePath(base, trimmed);
|
||||||
|
const relative = path.relative(base, resolved);
|
||||||
|
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||||
|
throw new Error(`${label} module path must be within ${base}: ${target}`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOptionalContainedPath(
|
||||||
|
baseDir: string,
|
||||||
|
target: string | undefined,
|
||||||
|
label: string,
|
||||||
|
): string {
|
||||||
|
const trimmed = target?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return path.resolve(baseDir);
|
||||||
|
}
|
||||||
|
return resolveContainedPath(baseDir, trimmed, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMatchPath(raw?: string): string | undefined {
|
function normalizeMatchPath(raw?: string): string | undefined {
|
||||||
|
|||||||
Reference in New Issue
Block a user