test: add OpenClaw bypass parity coverage
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
This commit is contained in:
parent
f457c69c27
commit
83c10323a8
36
.github/workflows/ci.yml
vendored
Normal file
36
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Node 22 check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Enable pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check
|
||||
run: pnpm check
|
||||
316
test/openclaw-read-bypass-parity.test.ts
Normal file
316
test/openclaw-read-bypass-parity.test.ts
Normal file
@ -0,0 +1,316 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
FsSafeError,
|
||||
openPinnedFileSync,
|
||||
openRootFile,
|
||||
openRootFileSync,
|
||||
pathScope,
|
||||
resolveAbsolutePathForRead,
|
||||
root as openRoot,
|
||||
} from "../src/index.js";
|
||||
|
||||
type TempLayout = {
|
||||
outside: string;
|
||||
outsideFile: string;
|
||||
root: string;
|
||||
};
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const TRAVERSAL_PAYLOADS = [
|
||||
"../secret.txt",
|
||||
"../../secret.txt",
|
||||
"nested/../../secret.txt",
|
||||
"nested/../../../secret.txt",
|
||||
"./../secret.txt",
|
||||
"nested/..//../secret.txt",
|
||||
"nested/%2e%2e/secret.txt",
|
||||
"%2e%2e/secret.txt",
|
||||
"%2e%2e%2fsecret.txt",
|
||||
"..%2fsecret.txt",
|
||||
"%252e%252e%252fsecret.txt",
|
||||
"..%00/secret.txt",
|
||||
"..\\secret.txt",
|
||||
"nested\\..\\..\\secret.txt",
|
||||
"C:\\Windows\\win.ini",
|
||||
"\\\\server\\share\\secret.txt",
|
||||
] as const;
|
||||
|
||||
const LIST_TRAVERSAL_PAYLOADS = [
|
||||
"..",
|
||||
"../",
|
||||
"../../",
|
||||
"nested/../..",
|
||||
"nested/../../outside",
|
||||
"%2e%2e",
|
||||
"%2e%2e%2f",
|
||||
"..\\",
|
||||
"C:\\Windows",
|
||||
"\\\\server\\share",
|
||||
] as const;
|
||||
|
||||
async function makeTempLayout(prefix: string): Promise<TempLayout> {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-root-`));
|
||||
const outside = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-outside-`));
|
||||
tempDirs.push(root, outside);
|
||||
const outsideFile = path.join(outside, "secret.txt");
|
||||
await fsp.writeFile(outsideFile, "outside secret");
|
||||
return { outside, outsideFile, root };
|
||||
}
|
||||
|
||||
async function closeIfOpen(value: unknown): Promise<void> {
|
||||
if (typeof value === "object" && value !== null && "handle" in value) {
|
||||
const handle = (value as { handle?: { close(): Promise<void> } }).handle;
|
||||
if (handle) {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function expectFsSafeCode(error: unknown, codes: readonly string[]): void {
|
||||
expect(error).toBeInstanceOf(FsSafeError);
|
||||
expect(codes).toContain((error as FsSafeError).code);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
describe("OpenClaw read bypass parity", () => {
|
||||
it("rejects a payload corpus of traversal, encoded, NUL, Windows, and UNC read attempts", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-read-payloads");
|
||||
await fsp.mkdir(path.join(layout.root, "nested"), { recursive: true });
|
||||
await fsp.writeFile(path.join(layout.root, "nested", "safe.txt"), "safe");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
for (const payload of TRAVERSAL_PAYLOADS) {
|
||||
await expect(safeRoot.read(payload), `read(${payload})`).rejects.toBeTruthy();
|
||||
await expect(safeRoot.open(payload), `open(${payload})`).rejects.toBeTruthy();
|
||||
await expect(safeRoot.stat(payload), `stat(${payload})`).rejects.toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a payload corpus of traversal, encoded, Windows, and UNC directory listing attempts", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-list-payloads");
|
||||
await fsp.mkdir(path.join(layout.root, "nested"), { recursive: true });
|
||||
await fsp.writeFile(path.join(layout.root, "nested", "safe.txt"), "safe");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
for (const payload of LIST_TRAVERSAL_PAYLOADS) {
|
||||
await expect(safeRoot.list(payload), `list(${payload})`).rejects.toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects traversal across root read, open, stat, list, and path scope APIs", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-read-traversal");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
const scope = pathScope(layout.root, { label: "test root" });
|
||||
|
||||
await expect(safeRoot.read("../secret.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.open("../secret.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.stat("../secret.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.list(".." as string)).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]);
|
||||
return true;
|
||||
});
|
||||
await expect(scope.files(["../secret.txt"])).resolves.toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("rejects symlink parents across root read/open/stat/list APIs", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-read-symlink-parent");
|
||||
await fsp.symlink(layout.outside, path.join(layout.root, "link"), "dir");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
await expect(safeRoot.read("link/secret.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.open("link/secret.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.stat("link/secret.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.list("link")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects final symlink leaves for root read/open/stat and direct root-file APIs", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-read-symlink-leaf");
|
||||
const linkPath = path.join(layout.root, "secret-link.txt");
|
||||
await fsp.symlink(layout.outsideFile, linkPath, "file");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
await expect(safeRoot.read("secret-link.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.open("secret-link.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.stat("secret-link.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
return true;
|
||||
});
|
||||
|
||||
const rootRealPath = await fsp.realpath(layout.root);
|
||||
const syncOpened = openRootFileSync({
|
||||
absolutePath: linkPath,
|
||||
boundaryLabel: "root",
|
||||
rootPath: layout.root,
|
||||
rootRealPath,
|
||||
});
|
||||
expect(syncOpened.ok).toBe(false);
|
||||
if (syncOpened.ok) {
|
||||
fs.closeSync(syncOpened.fd);
|
||||
}
|
||||
|
||||
const asyncOpened = await openRootFile({
|
||||
absolutePath: linkPath,
|
||||
boundaryLabel: "root",
|
||||
rootPath: layout.root,
|
||||
rootRealPath,
|
||||
});
|
||||
expect(asyncOpened.ok).toBe(false);
|
||||
if (asyncOpened.ok) {
|
||||
await asyncOpened.handle.close();
|
||||
}
|
||||
|
||||
const pinnedOpened = openPinnedFileSync({ filePath: linkPath, rejectPathSymlink: true });
|
||||
expect(pinnedOpened.ok).toBe(false);
|
||||
if (pinnedOpened.ok) {
|
||||
fs.closeSync(pinnedOpened.fd);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects absolute outside files across root read, open, stat, and direct root-file APIs", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-absolute-outside");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
const rootRealPath = await fsp.realpath(layout.root);
|
||||
|
||||
await expect(safeRoot.read(layout.outsideFile)).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.open(layout.outsideFile)).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.stat(layout.outsideFile)).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]);
|
||||
return true;
|
||||
});
|
||||
|
||||
const syncOpened = openRootFileSync({
|
||||
absolutePath: layout.outsideFile,
|
||||
boundaryLabel: "root",
|
||||
rootPath: layout.root,
|
||||
rootRealPath,
|
||||
});
|
||||
expect(syncOpened.ok).toBe(false);
|
||||
if (syncOpened.ok) {
|
||||
fs.closeSync(syncOpened.fd);
|
||||
}
|
||||
|
||||
const asyncOpened = await openRootFile({
|
||||
absolutePath: layout.outsideFile,
|
||||
boundaryLabel: "root",
|
||||
rootPath: layout.root,
|
||||
rootRealPath,
|
||||
});
|
||||
expect(asyncOpened.ok).toBe(false);
|
||||
if (asyncOpened.ok) {
|
||||
await asyncOpened.handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects hardlinked read targets when hardlink rejection is enabled", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-read-hardlink");
|
||||
const source = path.join(layout.root, "source.txt");
|
||||
const hardlink = path.join(layout.root, "hardlink.txt");
|
||||
await fsp.writeFile(source, "shared");
|
||||
await fsp.link(source, hardlink);
|
||||
const safeRoot = await openRoot(layout.root, { hardlinks: "reject" });
|
||||
|
||||
await expect(safeRoot.read("hardlink.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["hardlink", "invalid-path"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.open("hardlink.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["hardlink", "invalid-path"]);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects absolute read paths that traverse symlinks by default", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-absolute-read");
|
||||
const linkPath = path.join(layout.root, "absolute-link.txt");
|
||||
await fsp.symlink(layout.outsideFile, linkPath, "file");
|
||||
|
||||
await expect(resolveAbsolutePathForRead(linkPath)).rejects.toMatchObject({ code: "symlink" });
|
||||
const outsideFileReal = await fsp.realpath(layout.outsideFile);
|
||||
await expect(resolveAbsolutePathForRead(linkPath, { symlinks: "follow" })).resolves.toMatchObject({
|
||||
canonicalPath: outsideFileReal,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps encoded traversal payloads literal instead of URL-decoding into an escape", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-encoded-literal");
|
||||
await fsp.writeFile(path.join(layout.root, "%2e%2e%2fsecret.txt"), "literal");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
await expect(safeRoot.readText("%2e%2e%2fsecret.txt")).resolves.toBe("literal");
|
||||
await expect(safeRoot.read("%2e%2e/secret.txt")).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects pathScope payload batches when any member escapes", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-pathscope-payloads");
|
||||
const scope = pathScope(layout.root, { label: "test root" });
|
||||
|
||||
for (const payload of TRAVERSAL_PAYLOADS) {
|
||||
await expect(scope.files(["safe.txt", payload]), `pathScope.files(${payload})`).resolves.toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not return outside bytes when root read APIs reject unsafe paths", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-read-no-leak");
|
||||
await fsp.symlink(layout.outside, path.join(layout.root, "link"), "dir");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
for (const attempt of [
|
||||
() => safeRoot.read("../outside/secret.txt"),
|
||||
() => safeRoot.read("link/secret.txt"),
|
||||
() => safeRoot.open("link/secret.txt"),
|
||||
]) {
|
||||
let opened: unknown;
|
||||
try {
|
||||
opened = await attempt();
|
||||
await closeIfOpen(opened);
|
||||
throw new Error("unsafe read unexpectedly succeeded");
|
||||
} catch (error) {
|
||||
await closeIfOpen(opened);
|
||||
expect(error).not.toMatchObject({ message: "unsafe read unexpectedly succeeded" });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
261
test/openclaw-write-bypass-parity.test.ts
Normal file
261
test/openclaw-write-bypass-parity.test.ts
Normal file
@ -0,0 +1,261 @@
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { FsSafeError, root as openRoot } from "../src/index.js";
|
||||
|
||||
type TempLayout = {
|
||||
outside: string;
|
||||
outsideFile: string;
|
||||
root: string;
|
||||
};
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const ESCAPING_WRITE_PAYLOADS = [
|
||||
"../pwned.txt",
|
||||
"../../pwned.txt",
|
||||
"nested/../../pwned.txt",
|
||||
"nested/../../../pwned.txt",
|
||||
"./../pwned.txt",
|
||||
"nested/..//../pwned.txt",
|
||||
] as const;
|
||||
|
||||
const LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [
|
||||
"nested/%2e%2e/pwned.txt",
|
||||
"%2e%2e/pwned.txt",
|
||||
"%2e%2e%2fpwned.txt",
|
||||
"%252e%252e%252fpwned.txt",
|
||||
] as const;
|
||||
|
||||
const POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [
|
||||
"nested\\..\\..\\pwned.txt",
|
||||
"C:\\Windows\\win.ini",
|
||||
"\\\\server\\share\\pwned.txt",
|
||||
] as const;
|
||||
|
||||
const SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS = [
|
||||
"..%2fpwned.txt",
|
||||
"..%00/pwned.txt",
|
||||
"..\\pwned.txt",
|
||||
] as const;
|
||||
|
||||
const ESCAPING_DIRECTORY_PAYLOADS = [
|
||||
"..",
|
||||
"../",
|
||||
"../../",
|
||||
"nested/../..",
|
||||
"nested/../../outside",
|
||||
] as const;
|
||||
|
||||
const LITERAL_SUSPICIOUS_DIRECTORY_PAYLOADS = ["%2e%2e", "%2e%2e%2f"] as const;
|
||||
|
||||
const SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS = ["..\\"] as const;
|
||||
|
||||
const WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS = [
|
||||
"C:\\Windows",
|
||||
"\\\\server\\share",
|
||||
] as const;
|
||||
|
||||
async function makeTempLayout(prefix: string): Promise<TempLayout> {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-root-`));
|
||||
const outside = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-outside-`));
|
||||
tempDirs.push(root, outside);
|
||||
const outsideFile = path.join(outside, "secret.txt");
|
||||
await fsp.writeFile(outsideFile, "outside secret");
|
||||
return { outside, outsideFile, root };
|
||||
}
|
||||
|
||||
function expectFsSafeCode(error: unknown, codes: readonly string[]): void {
|
||||
expect(error).toBeInstanceOf(FsSafeError);
|
||||
expect(codes).toContain((error as FsSafeError).code);
|
||||
}
|
||||
|
||||
async function expectNoOutsideWrite(layout: TempLayout, expected = "outside secret"): Promise<void> {
|
||||
await expect(fsp.readFile(layout.outsideFile, "utf8")).resolves.toBe(expected);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
describe("OpenClaw write/move/delete bypass parity", () => {
|
||||
it("rejects payload corpus write/create/append/openWritable attempts without touching outside files", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-write-payloads");
|
||||
await fsp.mkdir(path.join(layout.root, "nested"), { recursive: true });
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
for (const payload of ESCAPING_WRITE_PAYLOADS) {
|
||||
await expect(safeRoot.write(payload, "pwned"), `write(${payload})`).rejects.toBeTruthy();
|
||||
await expect(safeRoot.create(payload, "pwned"), `create(${payload})`).rejects.toBeTruthy();
|
||||
await expect(safeRoot.append(payload, "pwned"), `append(${payload})`).rejects.toBeTruthy();
|
||||
await expect(safeRoot.openWritable(payload), `openWritable(${payload})`).rejects.toBeTruthy();
|
||||
}
|
||||
await expectNoOutsideWrite(layout);
|
||||
});
|
||||
|
||||
it("rejects payload corpus mkdir and remove attempts", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-dir-payloads");
|
||||
await fsp.mkdir(path.join(layout.root, "nested"), { recursive: true });
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
for (const payload of ESCAPING_DIRECTORY_PAYLOADS) {
|
||||
await expect(safeRoot.mkdir(payload), `mkdir(${payload})`).rejects.toBeTruthy();
|
||||
await expect(safeRoot.remove(payload), `remove(${payload})`).rejects.toBeTruthy();
|
||||
}
|
||||
await expectNoOutsideWrite(layout);
|
||||
});
|
||||
|
||||
it("rejects absolute outside write/create/append/openWritable/copy destinations", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-write-absolute");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
const source = path.join(layout.root, "source.txt");
|
||||
await fsp.writeFile(source, "source");
|
||||
|
||||
await expect(safeRoot.write(layout.outsideFile, "pwned")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.create(layout.outsideFile, "pwned")).rejects.toBeTruthy();
|
||||
await expect(safeRoot.append(layout.outsideFile, "pwned")).rejects.toBeTruthy();
|
||||
await expect(safeRoot.openWritable(layout.outsideFile)).rejects.toBeTruthy();
|
||||
await expect(safeRoot.copyIn(layout.outsideFile, source)).rejects.toBeTruthy();
|
||||
await expectNoOutsideWrite(layout);
|
||||
});
|
||||
|
||||
it("rejects symlink parent write/create/append/openWritable/copy/mkdir/remove destinations", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-write-symlink-parent");
|
||||
await fsp.symlink(layout.outside, path.join(layout.root, "link"), "dir");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
const source = path.join(layout.root, "source.txt");
|
||||
await fsp.writeFile(source, "source");
|
||||
|
||||
for (const action of [
|
||||
() => safeRoot.write("link/secret.txt", "pwned"),
|
||||
() => safeRoot.create("link/secret.txt", "pwned"),
|
||||
() => safeRoot.append("link/secret.txt", "pwned"),
|
||||
() => safeRoot.openWritable("link/secret.txt"),
|
||||
() => safeRoot.copyIn("link/secret.txt", source),
|
||||
() => safeRoot.mkdir("link/nested"),
|
||||
() => safeRoot.remove("link/secret.txt"),
|
||||
]) {
|
||||
await expect(action()).rejects.toBeTruthy();
|
||||
}
|
||||
await expectNoOutsideWrite(layout);
|
||||
});
|
||||
|
||||
it("rejects final symlink leaf write/append/openWritable/copy targets without clobbering their target", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-write-symlink-leaf");
|
||||
await fsp.symlink(layout.outsideFile, path.join(layout.root, "link.txt"), "file");
|
||||
const source = path.join(layout.root, "source.txt");
|
||||
await fsp.writeFile(source, "source");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
for (const action of [
|
||||
() => safeRoot.write("link.txt", "pwned"),
|
||||
() => safeRoot.append("link.txt", "pwned"),
|
||||
() => safeRoot.openWritable("link.txt"),
|
||||
() => safeRoot.copyIn("link.txt", source),
|
||||
]) {
|
||||
await expect(action()).rejects.toBeTruthy();
|
||||
}
|
||||
await expectNoOutsideWrite(layout);
|
||||
});
|
||||
|
||||
it("rejects hardlinked write and append targets", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-write-hardlink");
|
||||
const source = path.join(layout.root, "source.txt");
|
||||
const hardlink = path.join(layout.root, "hardlink.txt");
|
||||
await fsp.writeFile(source, "shared");
|
||||
await fsp.link(source, hardlink);
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
await expect(safeRoot.write("hardlink.txt", "pwned")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["hardlink", "invalid-path", "path-alias"]);
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.append("hardlink.txt", "pwned")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["hardlink", "invalid-path", "path-alias"]);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects move payloads for escaping sources and destinations", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-move-payloads");
|
||||
await fsp.writeFile(path.join(layout.root, "from.txt"), "from");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
for (const payload of ESCAPING_WRITE_PAYLOADS) {
|
||||
await expect(safeRoot.move(payload, "to.txt"), `move-from(${payload})`).rejects.toBeTruthy();
|
||||
await expect(safeRoot.move("from.txt", payload), `move-to(${payload})`).rejects.toBeTruthy();
|
||||
}
|
||||
await expectNoOutsideWrite(layout);
|
||||
await expect(fsp.readFile(path.join(layout.root, "from.txt"), "utf8")).resolves.toBe("from");
|
||||
});
|
||||
|
||||
it("rejects symlink move source and destination endpoints without touching outside targets", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-move-symlink");
|
||||
await fsp.writeFile(path.join(layout.root, "from.txt"), "from");
|
||||
await fsp.symlink(layout.outsideFile, path.join(layout.root, "source-link.txt"), "file");
|
||||
await fsp.symlink(layout.outsideFile, path.join(layout.root, "dest-link.txt"), "file");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
await expect(safeRoot.move("source-link.txt", "moved.txt")).rejects.toBeTruthy();
|
||||
await safeRoot.move("from.txt", "dest-link.txt", { overwrite: true });
|
||||
await expectNoOutsideWrite(layout);
|
||||
await expect(fsp.readFile(path.join(layout.root, "dest-link.txt"), "utf8")).resolves.toBe("from");
|
||||
});
|
||||
|
||||
it("rejects remove through symlink parents but removes final symlink entries without following", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-remove-symlink");
|
||||
await fsp.symlink(layout.outside, path.join(layout.root, "parent-link"), "dir");
|
||||
await fsp.symlink(layout.outsideFile, path.join(layout.root, "leaf-link.txt"), "file");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
await expect(safeRoot.remove("parent-link/secret.txt")).rejects.toBeTruthy();
|
||||
await safeRoot.remove("leaf-link.txt");
|
||||
await expect(fsp.lstat(path.join(layout.root, "leaf-link.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expectNoOutsideWrite(layout);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const literalWritePayloads = process.platform === "win32"
|
||||
? LITERAL_SUSPICIOUS_WRITE_PAYLOADS
|
||||
: [...LITERAL_SUSPICIOUS_WRITE_PAYLOADS, ...POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS];
|
||||
for (const payload of literalWritePayloads) {
|
||||
await safeRoot.write(payload, "literal");
|
||||
await expect(safeRoot.readText(payload), `read literal ${payload}`).resolves.toBe("literal");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
for (const payload of POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS) {
|
||||
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();
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
for (const payload of WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS) {
|
||||
await expect(safeRoot.mkdir(payload), `mkdir safely rejects ${payload}`).rejects.toBeTruthy();
|
||||
}
|
||||
} else {
|
||||
for (const payload of WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS) {
|
||||
await safeRoot.mkdir(payload);
|
||||
await expect(fsp.stat(path.join(layout.root, payload)), `created literal ${payload}`).resolves.toSatisfy((stat) =>
|
||||
stat.isDirectory()
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const payload of LITERAL_SUSPICIOUS_DIRECTORY_PAYLOADS) {
|
||||
await safeRoot.mkdir(payload);
|
||||
await expect(safeRoot.list(payload), `list literal ${payload}`).resolves.toBeInstanceOf(Array);
|
||||
}
|
||||
await expectNoOutsideWrite(layout);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user