Compare commits

...

10 Commits

Author SHA1 Message Date
Peter Steinberger
393f980ad3
test: stabilize directory guard coverage
Some checks failed
ci / Lint workflows (push) Has been cancelled
ci / Node ${{ matrix.node }} check (${{ matrix.os }}) (22, macos-latest) (push) Has been cancelled
ci / Node ${{ matrix.node }} check (${{ matrix.os }}) (22, ubuntu-latest) (push) Has been cancelled
ci / Node ${{ matrix.node }} check (${{ matrix.os }}) (22, windows-latest) (push) Has been cancelled
ci / Node ${{ matrix.node }} check (${{ matrix.os }}) (24, macos-latest) (push) Has been cancelled
ci / Node ${{ matrix.node }} check (${{ matrix.os }}) (24, ubuntu-latest) (push) Has been cancelled
ci / Node ${{ matrix.node }} check (${{ matrix.os }}) (24, windows-latest) (push) Has been cancelled
coverage / Node 22 coverage (push) Has been cancelled
2026-05-08 12:29:15 +01:00
Peter Steinberger
d3f37bc700
chore: release 0.2.1 2026-05-08 08:43:17 +01:00
Peter Steinberger
66a4dfba0e
fix: isolate prepack types and secret symlink reads 2026-05-08 08:41:57 +01:00
Peter Steinberger
638999ca7c
Merge pull request #15 from openclaw/codex/ci-all-oses-node-22-24
ci: test all OSes on Node 22 and 24
2026-05-08 02:50:23 +01:00
Peter Steinberger
b132712635
ci: test all supported OSes on Node 22 and 24 2026-05-08 02:47:56 +01:00
Peter Steinberger
b5ad5e9244
Merge pull request #14 from openclaw/ci/windows-runner
fix: align posix/windows path handling and enable windows ci
2026-05-08 02:46:38 +01:00
Peter Steinberger
edca51be7c
docs(changelog): note windows path handling fix 2026-05-08 02:45:02 +01:00
Peter Steinberger
36dd0888a3
fix(root): align pinned fallback path boundary checks 2026-05-08 02:43:24 +01:00
Sarah Fortune
ecefc5bd53 test: avoid windows 8.3 short-name diff in realpath assertions
On windows fs.realpathSync and fs.realpath (async) can disagree on
8.3 short-name canonicalization. The github actions windows runner
exposes this: fs.realpathSync returns "C:\Users\RUNNER~1\..."
while fs.realpath returns "C:\Users\runneradmin\...". Tests that
compare a sync helper's output against await fs.realpath fail with
the same path printed in two forms.

Compare against fs.realpathSync (imported as realpathSync from
node:fs) on both sides so the test exercises the same canonical
form regardless of which short-name configuration the runner has.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:23:32 -07:00
Sarah Fortune
4a1e5d8b72 fix(root): drop spurious ".." prefix rejection on posix write path
The posix write helper rejected any path whose computed
path.relative(root, resolved) string-began with "..", even when the
resolved path was fully inside the root. That matched literal
filenames whose first segment merely starts with the two characters
".." (e.g. "..%2fpwned.txt", "..%00/pwned.txt") and produced an
"outside-workspace" error for paths that do not actually escape
the root. The real boundary is already enforced upstream by
resolvePathInRoot's path.resolve + isPathInside check, so the
extra startsWith("..") guard added no security value while
introducing platform divergence (windows did not have it). Drop the
guard, keep the path.isAbsolute check.

Recategorize the three former SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS
test inputs so the test reflects what each platform actually does:

- "..%2fpwned.txt" and "..%00/pwned.txt" are literal names that
  resolve fully inside root on both platforms; move them to
  LITERAL_SUSPICIOUS_WRITE_PAYLOADS (accepted everywhere).
- "..\pwned.txt" is a real traversal on windows where "\\" is a
  separator, but a literal filename on posix where "\\" is a regular
  name character; move it to POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS
  (accepted on posix, rejected on windows).

The literal-directory check in the same test uses fsp.stat on
windows since safeRoot.list goes through the pinned helper that is
unavailable on win32, matching the pattern already used a few lines
up. Bump the per-test timeout to 15s for slow windows fs under
parallel test load.

Drop a stale explanatory comment in expandHomePrefix.

After: 254 passed, 0 failed, 66 skipped on windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:16:50 -07:00
16 changed files with 197 additions and 54 deletions

View File

@ -23,14 +23,18 @@ jobs:
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
check:
name: Node 22 check (${{ matrix.os }})
name: Node ${{ matrix.node }} check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
node:
- 22
- 24
os:
- ubuntu-latest
- macos-latest
- windows-latest
steps:
- name: Check out
@ -45,7 +49,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
node-version: ${{ matrix.node }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

View File

@ -1,5 +1,15 @@
# Changelog
## Unreleased
## 0.2.1 - 2026-05-08
### Fixes
- Align POSIX and Windows handling for literal `..`-prefixed write targets, preserve whitespace in direct home-relative path inputs, and run the check suite on Windows CI. (#14; thanks @sjf)
- Keep source prepack builds isolated from parent monorepo ambient type packages such as Bun typings. (#13; thanks @Kaspre)
- Let secret-file reads follow symlink paths through the pinned real target unless callers opt into `rejectSymlink: true`.
## 0.2.0 - 2026-05-07
### Features

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/fs-safe",
"version": "0.2.0",
"version": "0.2.1",
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
"license": "MIT",
"repository": {

View File

@ -6,9 +6,9 @@ const LINE_BUDGETS = new Map([
["src/file-store.ts", 580],
["src/permissions.ts", 566],
["src/pinned-python.ts", 655],
["src/root-impl.ts", 1744],
["src/root-impl.ts", 1750],
["src/root-path.ts", 862],
["test/api-coverage.test.ts", 982],
["test/api-coverage.test.ts", 983],
["test/new-primitives.test.ts", 1500],
]);

View File

@ -31,18 +31,21 @@ export function resolveOsHomeDir(
function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined {
const explicitHome = normalize(env.OPENCLAW_HOME);
if (explicitHome) {
if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) {
const fallbackHome = resolveRawOsHomeDir(env, homedir);
if (fallbackHome) {
return explicitHome.replace(/^~(?=$|[\\/])/, fallbackHome);
}
return undefined;
}
if (!explicitHome) {
return resolveRawOsHomeDir(env, homedir);
}
const segments = path.normalize(explicitHome).split(path.sep);
if (segments[0] !== "~") {
return explicitHome;
}
return resolveRawOsHomeDir(env, homedir);
// OPENCLAW_HOME starts with "~"; expand against the os home dir. Fall
// back to undefined when there is no os home to expand against rather
// than returning a raw "~"-prefixed path the caller cannot use.
const fallbackHome = resolveRawOsHomeDir(env, homedir);
if (!fallbackHome) {
return undefined;
}
return expandHomePrefix(explicitHome, { home: fallbackHome });
}
function resolveRawOsHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined {
@ -87,9 +90,6 @@ export function expandHomePrefix(
homedir?: () => string;
},
): string {
// Normalize and split into path segments. path.normalize converts "/"
// to the native separator on Windows and leaves "\" as a literal name
// character on POSIX, so the segment check is platform-correct.
const segments = path.normalize(input).split(path.sep);
if (segments[0] !== "~") {
return input;

View File

@ -6,7 +6,6 @@ import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]);
const SYMLINK_OPEN_CODES = new Set(["ELOOP", "EINVAL", "ENOTSUP"]);
const PARENT_SEGMENT_PREFIX = /^\.\.(?:[\\/]|$)/u;
const POSIX_SEPARATOR_CHAR_CODE = 0x2f;
export function normalizeWindowsPathForComparison(input: string): string {
@ -49,8 +48,9 @@ export function isPathInside(root: string, target: string): boolean {
const rootForCompare = normalizeWindowsPathForComparison(path.win32.resolve(root));
const targetForCompare = normalizeWindowsPathForComparison(path.win32.resolve(target));
const relative = path.win32.relative(rootForCompare, targetForCompare);
const firstSegment = relative.split(path.win32.sep)[0];
return (
relative === "" || (!PARENT_SEGMENT_PREFIX.test(relative) && !path.win32.isAbsolute(relative))
relative === "" || (firstSegment !== ".." && !path.win32.isAbsolute(relative))
);
}
@ -69,7 +69,8 @@ export function isPathInside(root: string, target: string): boolean {
const resolvedRoot = path.resolve(root);
const resolvedTarget = path.resolve(target);
const relative = path.relative(resolvedRoot, resolvedTarget);
return relative === "" || (!PARENT_SEGMENT_PREFIX.test(relative) && !path.isAbsolute(relative));
const firstSegment = relative.split(path.posix.sep)[0];
return relative === "" || (firstSegment !== ".." && !path.isAbsolute(relative));
}
export function resolveSafeBaseDir(rootDir: string): string {

View File

@ -1250,8 +1250,10 @@ async function resolvePinnedWriteTargetInRoot(
throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
}
// resolvePathInRoot already enforces isPathInside, so any actual escape
// is rejected upstream.
const relativeResolved = path.relative(rootReal, resolved);
if (relativeResolved.startsWith("..") || path.isAbsolute(relativeResolved)) {
if (path.isAbsolute(relativeResolved)) {
throw new FsSafeError("outside-workspace", "file is outside workspace root");
}
const relativePosix = relativeResolved
@ -1332,10 +1334,11 @@ async function resolvePinnedOperationPathInRoot(
if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
}
const firstSegment = relativeResolved.split(path.sep)[0];
if (
relativeResolved === "" ||
relativeResolved === "." ||
relativeResolved.startsWith("..") ||
firstSegment === ".." ||
path.isAbsolute(relativeResolved)
) {
throw new FsSafeError("outside-workspace", "file is outside workspace root");

View File

@ -55,12 +55,26 @@ function readSecretFileOutcomeSync(
};
}
if (options.rejectSymlink && previewStat.isSymbolicLink()) {
return {
ok: false,
code: "symlink",
message: `${label} file at ${resolvedPath} must not be a symlink.`,
};
if (previewStat.isSymbolicLink()) {
if (!options.rejectSymlink) {
try {
previewStat = fs.statSync(resolvedPath);
} catch (error) {
const normalized = normalizeSecretReadError(error);
return {
ok: false,
code: (error as NodeJS.ErrnoException).code === "ENOENT" ? "not-found" : "invalid-path",
error: normalized,
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
};
}
} else {
return {
ok: false,
code: "symlink",
message: `${label} file at ${resolvedPath} must not be a symlink.`,
};
}
}
if (!previewStat.isFile()) {
return {

View File

@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import { realpathSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
@ -242,8 +243,11 @@ describe("path helpers", () => {
expect(isSymlinkOpenError(Object.assign(new Error("x"), { code: "ELOOP" }))).toBe(true);
expect(isPathInside(root, file)).toBe(true);
expect(resolveSafeBaseDir(root)).toBe(`${path.resolve(root)}${path.sep}`);
expect(safeRealpathSync(file, cache)).toBe(await fs.realpath(file));
expect(safeRealpathSync(file, cache)).toBe(await fs.realpath(file));
// Use the sync realpath to compare against safeRealpathSync. On windows
// fs.realpathSync and fs.realpath (async) sometimes disagree on 8.3
// short-name canonicalization (e.g. "RUNNER~1" vs "runneradmin").
expect(safeRealpathSync(file, cache)).toBe(realpathSync(file));
expect(safeRealpathSync(file, cache)).toBe(realpathSync(file));
expect(safeRealpathSync(path.join(root, "missing"), cache)).toBeNull();
expect(isPathInsideWithRealpath(root, file, { cache })).toBe(true);
expect(isPathInsideWithRealpath(root, path.join(root, "missing"), { requireRealpath: false }))
@ -457,7 +461,7 @@ describe("URL, install, and local-root helpers", () => {
label: "media roots",
requireFile: true,
}),
).toMatchObject({ path: await fs.realpath(file) });
).toMatchObject({ path: realpathSync(file) });
expect(() =>
resolveLocalPathFromRootsSync({
filePath: "bad\0path",

View File

@ -2,7 +2,17 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createBoundedReadStream, createMaxBytesTransform } from "../src/bounded-read-stream.js";
import {
assertAsyncDirectoryGuard,
assertSyncDirectoryGuard,
createAsyncDirectoryGuard,
createNearestExistingDirectoryGuard,
createNearestExistingSyncDirectoryGuard,
createSyncDirectoryGuard,
} from "../src/directory-guard.js";
import { drainFileLockManagerForTest, resetFileLockManagerForTest } from "../src/file-lock.js";
import { sameFileIdentity } from "../src/file-identity.js";
import { readLocalFileFromRoots, resolveLocalPathFromRootsSync } from "../src/local-roots.js";
@ -138,6 +148,53 @@ describe("small identity and lock wrappers", () => {
});
});
describe("bounded streams and directory guard coverage", () => {
it("returns raw streams without limits and rejects oversized limited streams", async () => {
const raw = Readable.from(["ok"]);
const returned = createBoundedReadStream({ handle: { createReadStream: () => raw } }, undefined);
expect(returned).toBe(raw);
await expect(async () => {
for await (const _chunk of Readable.from(["ab", "cd"]).pipe(createMaxBytesTransform(3))) {
// Drain the stream so transform errors surface.
}
}).rejects.toMatchObject({ code: "too-large" });
});
it("detects changed or invalid directory guards", async () => {
const root = await tempRoot("fs-safe-dir-guard-more-");
const nested = path.join(root, "nested");
const filePath = path.join(root, "file.txt");
await fs.mkdir(nested);
await fs.writeFile(filePath, "not a dir", "utf8");
await expect(createAsyncDirectoryGuard(filePath)).rejects.toMatchObject({ code: "not-file" });
expect(() => createSyncDirectoryGuard(filePath)).toThrow("directory component");
const asyncGuard = await createAsyncDirectoryGuard(nested);
const syncGuard = createSyncDirectoryGuard(nested);
await expect(assertAsyncDirectoryGuard({ ...asyncGuard, realPath: root })).rejects.toMatchObject({
code: "path-mismatch",
});
expect(() => assertSyncDirectoryGuard({ ...syncGuard, realPath: root })).toThrow(
"directory changed",
);
await fs.rm(nested, { recursive: true });
await fs.writeFile(nested, "not a dir", "utf8");
await expect(assertAsyncDirectoryGuard(asyncGuard)).rejects.toMatchObject({
code: "not-file",
});
expect(() => assertSyncDirectoryGuard(syncGuard)).toThrow("directory component");
const nearest = await createNearestExistingDirectoryGuard(root, path.join(root, "missing", "x"));
expect(nearest.dir).toBe(root);
expect(createNearestExistingSyncDirectoryGuard(root, path.join(root, "missing", "x")).dir)
.toBe(root);
});
});
describe("sibling temp coverage", () => {
it("syncs temp files and parent dirs when requested", async () => {
const root = await tempRoot("fs-safe-sibling-more-");

View File

@ -56,17 +56,19 @@ export const LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [
"%2e%2e/pwned.txt",
"%2e%2e%2fpwned.txt",
"%252e%252e%252fpwned.txt",
// ".." prefix without an actual separator: a single literal filename
// ("..%2fpwned.txt") or two literal segments ("..%00", "pwned.txt") that
// resolve fully inside root. Accepted on both platforms.
"..%2fpwned.txt",
"..%00/pwned.txt",
] as const;
export const POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [
"nested\\..\\..\\pwned.txt",
"C:\\Windows\\win.ini",
"\\\\server\\share\\pwned.txt",
] as const;
export const SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS = [
"..%2fpwned.txt",
"..%00/pwned.txt",
// "..\\" is a real traversal on Windows (separator) but a literal filename
// on POSIX (where "\\" is a regular name character).
"..\\pwned.txt",
] as const;
@ -98,10 +100,15 @@ export async function makeTempLayout(
return { outside, outsideFile, root };
}
export function expectFsSafeCode(error: unknown, codes: readonly string[]): void {
export function expectFsSafeCode(
error: unknown,
codes: readonly string[],
opts: { allowUnsupportedPlatformOnWindows?: boolean } = {},
): void {
expect(error).toBeInstanceOf(FsSafeError);
const accepted =
process.platform === "win32" ? [...codes, "unsupported-platform"] : codes;
const accepted = process.platform === "win32" && opts.allowUnsupportedPlatformOnWindows
? [...codes, "unsupported-platform"]
: codes;
expect(accepted).toContain((error as FsSafeError).code);
}

View File

@ -72,11 +72,15 @@ describe("read boundary bypass attempts", () => {
return true;
});
await expect(safeRoot.stat("../secret.txt")).rejects.toSatisfy((error: unknown) => {
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]);
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"], {
allowUnsupportedPlatformOnWindows: true,
});
return true;
});
await expect(safeRoot.list(".." as string)).rejects.toSatisfy((error: unknown) => {
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]);
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"], {
allowUnsupportedPlatformOnWindows: true,
});
return true;
});
await expect(scope.files(["../secret.txt"])).resolves.toMatchObject({ ok: false });
@ -96,11 +100,15 @@ describe("read boundary bypass attempts", () => {
return true;
});
await expect(safeRoot.stat("link/secret.txt")).rejects.toSatisfy((error: unknown) => {
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"], {
allowUnsupportedPlatformOnWindows: true,
});
return true;
});
await expect(safeRoot.list("link")).rejects.toSatisfy((error: unknown) => {
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"], {
allowUnsupportedPlatformOnWindows: true,
});
return true;
});
});
@ -120,7 +128,9 @@ describe("read boundary bypass attempts", () => {
return true;
});
await expect(safeRoot.stat("secret-link.txt")).rejects.toSatisfy((error: unknown) => {
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"], {
allowUnsupportedPlatformOnWindows: true,
});
return true;
});
@ -168,7 +178,9 @@ describe("read boundary bypass attempts", () => {
return true;
});
await expect(safeRoot.stat(layout.outsideFile)).rejects.toSatisfy((error: unknown) => {
expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]);
expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"], {
allowUnsupportedPlatformOnWindows: true,
});
return true;
});

View File

@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import { realpathSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
@ -173,7 +174,10 @@ describe("local roots helpers", () => {
const missingPath = path.join(uploadsDir, "new", "later.txt");
const outsidePath = path.join(baseDir, "outside.txt");
await fs.writeFile(filePath, "ok", "utf8");
const uploadsReal = await fs.realpath(uploadsDir);
// Use the sync realpath to compare against resolveLocalPathFromRootsSync.
// On windows fs.realpathSync and fs.realpath (async) sometimes disagree
// on 8.3 short-name canonicalization (e.g. "RUNNER~1" vs "runneradmin").
const uploadsReal = realpathSync(uploadsDir);
expect(
resolveLocalPathFromRootsSync({
@ -182,7 +186,7 @@ describe("local roots helpers", () => {
label: "media roots",
requireFile: true,
}),
).toEqual({ path: await fs.realpath(filePath), root: uploadsReal });
).toEqual({ path: realpathSync(filePath), root: uploadsReal });
expect(
resolveLocalPathFromRootsSync({
filePath: missingPath,

View File

@ -68,9 +68,15 @@ describe("secret file helpers", () => {
const root = await tempRoot("fs-safe-secret-");
const target = path.join(root, "target.txt");
const link = path.join(root, "link.txt");
const broken = path.join(root, "broken.txt");
await fs.writeFile(target, "secret", "utf8");
await fs.symlink(target, link);
await fs.symlink(path.join(root, "missing.txt"), broken);
expect(readSecretFileSync(link, "API token")).toBe("secret");
expect(tryReadSecretFileSync(link, "API token")).toBe("secret");
expectSecretReadCode(() => readSecretFileSync(broken, "API token"), "not-found");
expect(tryReadSecretFileSync(broken, "API token")).toBeUndefined();
expect(() => readSecretFileSync(link, "API token", { rejectSymlink: true })).toThrow(
`API token file at ${link} must not be a symlink.`,
);

View File

@ -3,7 +3,7 @@ import path from "node:path";
import { Readable } from "node:stream";
import { afterEach, describe, expect, it } from "vitest";
import { fileStore, fileStoreSync } from "../src/file-store.js";
import { root as openRoot } from "../src/index.js";
import { configureFsSafePython, root as openRoot } from "../src/index.js";
import {
ESCAPING_DIRECTORY_PAYLOADS,
ESCAPING_WRITE_PAYLOADS,
@ -14,7 +14,6 @@ import {
makeTempLayout as makeSecurityTempLayout,
POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS,
SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS,
SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS,
WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS,
} from "./helpers/security.js";
@ -25,6 +24,7 @@ async function makeTempLayout(prefix: string) {
}
afterEach(async () => {
configureFsSafePython({ mode: "auto", pythonPath: undefined });
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { force: true, recursive: true })));
});
@ -197,6 +197,8 @@ describe("write, move, and delete boundary bypass attempts", () => {
await expectNoOutsideWrite(layout);
});
// This test exercises many fs writes/reads/mkdirs; bump the timeout for
// slow windows fs under parallel test load (default 5s is sometimes tight).
it("keeps encoded, backslash, Windows, and UNC-looking write payloads literal and inside root", async () => {
const layout = await makeTempLayout("fs-safe-write-encoded-literal");
const safeRoot = await openRoot(layout.root);
@ -213,9 +215,6 @@ describe("write, move, and delete boundary bypass attempts", () => {
await expect(safeRoot.write(payload, "rejected"), `write safely rejects ${payload}`).rejects.toBeTruthy();
}
}
for (const payload of SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS) {
await expect(safeRoot.write(payload, "rejected"), `write safely rejects ${payload}`).rejects.toBeTruthy();
}
for (const payload of SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS) {
await expect(safeRoot.mkdir(payload), `mkdir safely rejects ${payload}`).rejects.toBeTruthy();
}
@ -233,8 +232,29 @@ describe("write, move, and delete boundary bypass attempts", () => {
}
for (const payload of LITERAL_SUSPICIOUS_DIRECTORY_PAYLOADS) {
await safeRoot.mkdir(payload);
await expect(safeRoot.list(payload), `list literal ${payload}`).resolves.toBeInstanceOf(Array);
if (process.platform === "win32") {
// safeRoot.list uses the pinned helper which is unavailable on
// windows; verify the directory exists via fsp.stat instead.
await expect(fsp.stat(path.join(layout.root, payload)), `created literal ${payload}`)
.resolves.toSatisfy((stat) => stat.isDirectory());
} else {
await expect(safeRoot.list(payload), `list literal ${payload}`).resolves.toBeInstanceOf(Array);
}
}
await expectNoOutsideWrite(layout);
}, 15000);
it.runIf(process.platform !== "win32")("keeps literal '..'-prefixed paths available when the helper is disabled", async () => {
configureFsSafePython({ mode: "off" });
const layout = await makeTempLayout("fs-safe-write-helper-off-literal");
const safeRoot = await openRoot(layout.root);
await safeRoot.write("..%2fpwned.txt", "literal");
await expect(safeRoot.stat("..%2fpwned.txt")).resolves.toMatchObject({ isFile: true });
await safeRoot.remove("..%2fpwned.txt");
await expect(fsp.stat(path.join(layout.root, "..%2fpwned.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
await expectNoOutsideWrite(layout);
});
});

View File

@ -11,6 +11,7 @@
"rootDir": "src",
"strict": true,
"target": "ES2022",
"types": ["node"],
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]