fix(windows): normalize namespaced path containment checks

This commit is contained in:
Shakker 2026-02-26 18:49:26 +00:00
parent dc6e4a5b13
commit f7041fbee3
No known key found for this signature in database
6 changed files with 45 additions and 13 deletions

View File

@ -1,12 +1,34 @@
import { posix } from "node:path";
import { resolvePathViaExistingAncestorSync } from "../../infra/boundary-path.js";
function stripWindowsNamespacePrefix(input: string): string {
if (input.startsWith("\\\\?\\")) {
const withoutPrefix = input.slice(4);
if (withoutPrefix.toUpperCase().startsWith("UNC\\")) {
return `\\\\${withoutPrefix.slice(4)}`;
}
return withoutPrefix;
}
if (input.startsWith("//?/")) {
const withoutPrefix = input.slice(4);
if (withoutPrefix.toUpperCase().startsWith("UNC/")) {
return `//${withoutPrefix.slice(4)}`;
}
return withoutPrefix;
}
return input;
}
/**
* Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
*/
export function normalizeSandboxHostPath(raw: string): string {
const trimmed = raw.trim();
return posix.normalize(trimmed).replace(/\/+$/, "") || "/";
const trimmed = stripWindowsNamespacePrefix(raw.trim());
if (!trimmed) {
return "/";
}
const normalized = posix.normalize(trimmed.replaceAll("\\", "/"));
return normalized.replace(/\/+$/, "") || "/";
}
/**

View File

@ -1,3 +1,4 @@
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
@ -99,7 +100,7 @@ describe("onboardCommand", () => {
expect(mocks.handleReset).toHaveBeenCalledWith(
"config+creds+sessions",
"/tmp/openclaw-custom-workspace",
path.resolve("/tmp/openclaw-custom-workspace"),
runtime,
);
});

View File

@ -630,7 +630,7 @@ describe("security: path traversal protection (CWE-22)", () => {
"{ logging: { redactSensitive: 'tools' } }\n",
"utf-8",
);
await fs.symlink(realRoot, linkRoot);
await fs.symlink(realRoot, linkRoot, process.platform === "win32" ? "junction" : undefined);
const result = resolveConfigIncludes(
{ $include: "./includes/extra.json5" },

View File

@ -3,6 +3,17 @@ import path from "node:path";
const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]);
const SYMLINK_OPEN_CODES = new Set(["ELOOP", "EINVAL", "ENOTSUP"]);
function normalizeWindowsPathForComparison(input: string): string {
let normalized = path.win32.normalize(input);
if (normalized.startsWith("\\\\?\\")) {
normalized = normalized.slice(4);
if (normalized.toUpperCase().startsWith("UNC\\")) {
normalized = `\\\\${normalized.slice(4)}`;
}
}
return normalized.replaceAll("/", "\\").toLowerCase();
}
export function isNodeError(value: unknown): value is NodeJS.ErrnoException {
return Boolean(
value && typeof value === "object" && "code" in (value as Record<string, unknown>),
@ -26,7 +37,9 @@ export function isPathInside(root: string, target: string): boolean {
const resolvedTarget = path.resolve(target);
if (process.platform === "win32") {
const relative = path.win32.relative(resolvedRoot.toLowerCase(), resolvedTarget.toLowerCase());
const rootForCompare = normalizeWindowsPathForComparison(resolvedRoot);
const targetForCompare = normalizeWindowsPathForComparison(resolvedTarget);
const relative = path.win32.relative(rootForCompare, targetForCompare);
return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative));
}

View File

@ -37,7 +37,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("findGatewayPidsOnPortSync", () => {
describe.runIf(process.platform !== "win32")("findGatewayPidsOnPortSync", () => {
it("parses lsof output and filters non-openclaw/current processes", () => {
spawnSyncMock.mockReturnValue({
error: undefined,
@ -76,7 +76,7 @@ describe("findGatewayPidsOnPortSync", () => {
});
});
describe("cleanStaleGatewayProcessesSync", () => {
describe.runIf(process.platform !== "win32")("cleanStaleGatewayProcessesSync", () => {
it("kills stale gateway pids discovered on the gateway port", () => {
spawnSyncMock.mockReturnValue({
error: undefined,

View File

@ -1,12 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { isPathInside as isBoundaryPathInside } from "../infra/path-guards.js";
export function isPathInside(baseDir: string, targetPath: string): boolean {
const rel = path.relative(baseDir, targetPath);
if (!rel) {
return true;
}
return !rel.startsWith("..") && !path.isAbsolute(rel);
return isBoundaryPathInside(baseDir, targetPath);
}
export function safeRealpathSync(targetPath: string, cache?: Map<string, string>): string | null {