Skip to content

Commit 5d34ea7

Browse files
cxbAsDevsteipete
andauthored
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 <[email protected]>
1 parent e93d193 commit 5d34ea7

2 files changed

Lines changed: 36 additions & 3 deletions

File tree

src/hooks/install.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,34 @@ describe("installHooksFromPath", () => {
466466
expect(fs.existsSync(path.join(hooksDir, "my-hook"))).toBe(false);
467467
});
468468

469+
it("rejects an oversized HOOK.md to prevent OOM during frontmatter parsing", async () => {
470+
const stateDir = makeTempDir();
471+
const workDir = makeTempDir();
472+
const hookDir = path.join(workDir, "my-hook");
473+
fs.mkdirSync(hookDir, { recursive: true });
474+
fs.writeFileSync(path.join(hookDir, "HOOK.md"), "x".repeat(1024 * 1024 + 1), "utf8");
475+
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
476+
477+
await expect(
478+
installHooksFromPath({ path: hookDir, hooksDir: path.join(stateDir, "hooks") }),
479+
).rejects.toThrow(/File exceeds 1048576 bytes/);
480+
});
481+
482+
it.runIf(process.platform !== "win32")("rejects a symlinked HOOK.md", async () => {
483+
const stateDir = makeTempDir();
484+
const workDir = makeTempDir();
485+
const hookDir = path.join(workDir, "my-hook");
486+
const externalHookMd = path.join(workDir, "external-HOOK.md");
487+
fs.mkdirSync(hookDir, { recursive: true });
488+
fs.writeFileSync(externalHookMd, "---\nname: external\n---\n", "utf8");
489+
fs.symlinkSync(externalHookMd, path.join(hookDir, "HOOK.md"), "file");
490+
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
491+
492+
await expect(
493+
installHooksFromPath({ path: hookDir, hooksDir: path.join(stateDir, "hooks") }),
494+
).rejects.toThrow(/path must be a regular file/);
495+
});
496+
469497
it("classifies hook packages that also declare plugin extensions", async () => {
470498
const stateDir = makeTempDir();
471499
const pkgDir = makeTempDir();

src/hooks/install.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// Hook install service installs hook packages from archives and local sources.
2-
import fs from "node:fs/promises";
2+
33
import path from "node:path";
44
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
55
import { MANIFEST_KEY } from "../compat/legacy-names.js";
66
import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-safe-path.js";
77
import type { NpmIntegrityDrift, NpmSpecResolution } from "../infra/install-source-utils.js";
8+
import { readRegularFile } from "../infra/regular-file.js";
89
import { detectBundleManifestFormat } from "../plugins/bundle-manifest.js";
910
import {
1011
scanPackageInstallSource,
@@ -17,6 +18,10 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
1718
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
1819
import { parseFrontmatter } from "./frontmatter.js";
1920

21+
// HOOK.md is only parsed for frontmatter; a small cap prevents a malicious or
22+
// malformed hook package from OOMing the install path.
23+
const HOOK_MD_MAX_BYTES = 1024 * 1024;
24+
2025
const loadHookInstallRuntime = createLazyRuntimeModule(() => import("./install.runtime.js"));
2126

2227
/** Logger contract used by hook install and update operations. */
@@ -358,8 +363,8 @@ async function resolveHookNameFromDir(hookDir: string): Promise<string> {
358363
if (!(await runtime.fileExists(hookMdPath))) {
359364
throw new Error(`HOOK.md missing in ${hookDir}`);
360365
}
361-
const raw = await fs.readFile(hookMdPath, "utf-8");
362-
const frontmatter = parseFrontmatter(raw);
366+
const { buffer } = await readRegularFile({ filePath: hookMdPath, maxBytes: HOOK_MD_MAX_BYTES });
367+
const frontmatter = parseFrontmatter(buffer.toString("utf-8"));
363368
return frontmatter.name || path.basename(hookDir);
364369
}
365370

0 commit comments

Comments
 (0)