fix(hooks): bound HOOK.md reads during hook install validation (#101469)

* fix(hooks): bound HOOK.md reads during hook install validation

* fix(hooks): remove unused fs import after switching to bounded reader

* test(hooks): cover symlinked install metadata

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
cxbAsDev
2026-07-13 01:11:37 +08:00
committed by GitHub
parent e93d1934f1
commit 5d34ea774a
2 changed files with 36 additions and 3 deletions
+28
View File
@@ -466,6 +466,34 @@ describe("installHooksFromPath", () => {
expect(fs.existsSync(path.join(hooksDir, "my-hook"))).toBe(false);
});
it("rejects an oversized HOOK.md to prevent OOM during frontmatter parsing", async () => {
const stateDir = makeTempDir();
const workDir = makeTempDir();
const hookDir = path.join(workDir, "my-hook");
fs.mkdirSync(hookDir, { recursive: true });
fs.writeFileSync(path.join(hookDir, "HOOK.md"), "x".repeat(1024 * 1024 + 1), "utf8");
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
await expect(
installHooksFromPath({ path: hookDir, hooksDir: path.join(stateDir, "hooks") }),
).rejects.toThrow(/File exceeds 1048576 bytes/);
});
it.runIf(process.platform !== "win32")("rejects a symlinked HOOK.md", async () => {
const stateDir = makeTempDir();
const workDir = makeTempDir();
const hookDir = path.join(workDir, "my-hook");
const externalHookMd = path.join(workDir, "external-HOOK.md");
fs.mkdirSync(hookDir, { recursive: true });
fs.writeFileSync(externalHookMd, "---\nname: external\n---\n", "utf8");
fs.symlinkSync(externalHookMd, path.join(hookDir, "HOOK.md"), "file");
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
await expect(
installHooksFromPath({ path: hookDir, hooksDir: path.join(stateDir, "hooks") }),
).rejects.toThrow(/path must be a regular file/);
});
it("classifies hook packages that also declare plugin extensions", async () => {
const stateDir = makeTempDir();
const pkgDir = makeTempDir();
+8 -3
View File
@@ -1,10 +1,11 @@
// Hook install service installs hook packages from archives and local sources.
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-safe-path.js";
import type { NpmIntegrityDrift, NpmSpecResolution } from "../infra/install-source-utils.js";
import { readRegularFile } from "../infra/regular-file.js";
import { detectBundleManifestFormat } from "../plugins/bundle-manifest.js";
import {
scanPackageInstallSource,
@@ -17,6 +18,10 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { parseFrontmatter } from "./frontmatter.js";
// HOOK.md is only parsed for frontmatter; a small cap prevents a malicious or
// malformed hook package from OOMing the install path.
const HOOK_MD_MAX_BYTES = 1024 * 1024;
const loadHookInstallRuntime = createLazyRuntimeModule(() => import("./install.runtime.js"));
/** Logger contract used by hook install and update operations. */
@@ -358,8 +363,8 @@ async function resolveHookNameFromDir(hookDir: string): Promise<string> {
if (!(await runtime.fileExists(hookMdPath))) {
throw new Error(`HOOK.md missing in ${hookDir}`);
}
const raw = await fs.readFile(hookMdPath, "utf-8");
const frontmatter = parseFrontmatter(raw);
const { buffer } = await readRegularFile({ filePath: hookMdPath, maxBytes: HOOK_MD_MAX_BYTES });
const frontmatter = parseFrontmatter(buffer.toString("utf-8"));
return frontmatter.name || path.basename(hookDir);
}