


























@@ -1,6 +1,7 @@
1+import nodeFs from "node:fs";
12import fs from "node:fs/promises";
23import path from "node:path";
3-import { describe, expect, it } from "vitest";
4+import { describe, expect, it, vi } from "vitest";
45import { withTempDir } from "../test-helpers/temp-dir.js";
56import {
67CircularIncludeError,
@@ -614,6 +615,33 @@ describe("security: path traversal protection (CWE-22)", () => {
614615});
615616});
616617618+it("fails closed when include realpath resolution fails for reasons other than ENOENT", () => {
619+const includePath = configPath("denied.json");
620+const originalRealpathSync = nodeFs.realpathSync;
621+const realpathSpy = vi.spyOn(nodeFs, "realpathSync").mockImplementation((target) => {
622+if (path.normalize(String(target)) === includePath) {
623+const error = new Error("permission denied") as NodeJS.ErrnoException;
624+error.code = "EACCES";
625+throw error;
626+}
627+return originalRealpathSync(target);
628+});
629+630+try {
631+expectResolveIncludeError(
632+() =>
633+resolveConfigIncludes(
634+{ $include: "./denied.json" },
635+DEFAULT_BASE_PATH,
636+createMockResolver({ [includePath]: { leaked: true } }),
637+),
638+/Failed to resolve include file realpath/,
639+);
640+} finally {
641+realpathSpy.mockRestore();
642+}
643+});
644+617645it("rejects include files that are hardlinked aliases", async () => {
618646if (process.platform === "win32") {
619647return;
@@ -659,3 +687,113 @@ describe("security: path traversal protection (CWE-22)", () => {
659687});
660688});
661689});
690+691+describe("OPENCLAW_INCLUDE_ROOTS allowlist", () => {
692+it("permits an include outside the config directory when its root is allowed", () => {
693+const sharedFile = sharedPath("common.json");
694+const files = { [sharedFile]: { shared: true } };
695+expect(
696+resolveConfigIncludes(
697+{ $include: sharedFile },
698+DEFAULT_BASE_PATH,
699+createMockResolver(files),
700+{ allowedRoots: [SHARED_DIR] },
701+),
702+).toEqual({ shared: true });
703+});
704+705+it("still rejects include paths that fall outside every allowed root", () => {
706+const obj = { $include: etcOpenClawPath("agents.json") };
707+expect(() =>
708+resolveConfigIncludes(obj, DEFAULT_BASE_PATH, createMockResolver({}), {
709+allowedRoots: [SHARED_DIR],
710+}),
711+).toThrow(/escapes config directory/);
712+});
713+714+it.each([
715+{ name: "unset", allowedRoots: undefined },
716+{ name: "empty", allowedRoots: [] as string[] },
717+])(
718+"preserves the original config-directory boundary when allowedRoots is $name",
719+({ allowedRoots }) => {
720+const obj = { $include: sharedPath("common.json") };
721+expect(() =>
722+resolveConfigIncludes(obj, DEFAULT_BASE_PATH, createMockResolver({}), { allowedRoots }),
723+).toThrow(/escapes config directory/);
724+},
725+);
726+727+it("ignores non-absolute or empty allowedRoots entries while honoring valid ones", () => {
728+const sharedFile = sharedPath("common.json");
729+const files = { [sharedFile]: { shared: true } };
730+expect(
731+resolveConfigIncludes(
732+{ $include: sharedFile },
733+DEFAULT_BASE_PATH,
734+createMockResolver(files),
735+{ allowedRoots: ["", "./relative", SHARED_DIR] },
736+),
737+).toEqual({ shared: true });
738+});
739+740+it("resolves a symlinked include whose realpath lands inside an allowed root", async () => {
741+await withTempDir({ prefix: "openclaw-includes-allowed-symlink-" }, async (tempRoot) => {
742+const configDir = path.join(tempRoot, "config");
743+const sharedDir = path.join(tempRoot, "shared");
744+await fs.mkdir(configDir, { recursive: true });
745+await fs.mkdir(sharedDir, { recursive: true });
746+const sharedTarget = path.join(sharedDir, "extra.json5");
747+await fs.writeFile(sharedTarget, "{ logging: { redactSensitive: 'tools' } }\n", "utf-8");
748+const linkInConfig = path.join(configDir, "extra.json5");
749+await fs.symlink(
750+sharedTarget,
751+linkInConfig,
752+process.platform === "win32" ? "file" : undefined,
753+);
754+755+const result = resolveConfigIncludes(
756+{ $include: "./extra.json5" },
757+path.join(configDir, "openclaw.json"),
758+undefined,
759+{ allowedRoots: [sharedDir] },
760+);
761+expect(result).toEqual({ logging: { redactSensitive: "tools" } });
762+});
763+});
764+765+it("rejects a symlinked include that escapes both the config directory and every allowed root", async () => {
766+await withTempDir({ prefix: "openclaw-includes-allowed-escape-" }, async (tempRoot) => {
767+const configDir = path.join(tempRoot, "config");
768+const allowedDir = path.join(tempRoot, "allowed");
769+const offRootDir = path.join(tempRoot, "off-limits");
770+await fs.mkdir(configDir, { recursive: true });
771+await fs.mkdir(allowedDir, { recursive: true });
772+await fs.mkdir(offRootDir, { recursive: true });
773+const offRootTarget = path.join(offRootDir, "secret.json5");
774+await fs.writeFile(offRootTarget, "{ leaked: true }\n", "utf-8");
775+const linkInConfig = path.join(configDir, "secret.json5");
776+try {
777+await fs.symlink(
778+offRootTarget,
779+linkInConfig,
780+process.platform === "win32" ? "file" : undefined,
781+);
782+} catch (err) {
783+if ((err as NodeJS.ErrnoException).code === "EPERM") {
784+return;
785+}
786+throw err;
787+}
788+789+expect(() =>
790+resolveConfigIncludes(
791+{ $include: "./secret.json5" },
792+path.join(configDir, "openclaw.json"),
793+undefined,
794+{ allowedRoots: [allowedDir] },
795+),
796+).toThrow(/resolves outside config directory/);
797+});
798+});
799+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。