Files
nexu-io--open-design/apps/packaged/tests/desktop-project-root-gate.test.ts
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

373 lines
16 KiB
TypeScript

/**
* Coverage for the path-validation primitives that the new
* shell.openPath IPC handler in `apps/desktop/src/main/runtime.ts`
* relies on, and for the HMAC token mint helper introduced in PR #974
* to bind `POST /api/import/folder` to the desktop main process. The
* packaged workspace hosts the test because `apps/desktop` itself has
* no vitest setup yet — same reasoning as the existing
* `desktop-url-allowlist.test.ts` next to this file.
*
* @see https://github.com/nexu-io/open-design/pull/974
* lefarcen + mrcfps round-3 reviews on runtime.ts: path-allowlist
* gate must be daemon-controlled, `.app` bundles must be rejected,
* and `openPath(projectId)` must only forward projects whose
* resolvedDir came from the trusted-picker flow.
*/
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
validateExistingDirectory,
fetchResolvedProjectDir,
isOpenPathAllowedForProject,
signDesktopImportToken,
} from "@open-design/desktop/main";
let tempRoot = "";
beforeEach(() => {
tempRoot = mkdtempSync(path.join(tmpdir(), "od-desktop-validate-"));
});
afterEach(() => {
if (tempRoot) {
rmSync(tempRoot, { recursive: true, force: true });
tempRoot = "";
}
});
describe("validateExistingDirectory", () => {
it("rejects empty / non-string input", async () => {
const empty = await validateExistingDirectory("");
expect(empty.ok).toBe(false);
if (!empty.ok) expect(empty.reason).toMatch(/non-empty string/i);
});
it("rejects relative paths", async () => {
const relative = await validateExistingDirectory("relative/site");
expect(relative.ok).toBe(false);
if (!relative.ok) expect(relative.reason).toMatch(/absolute/i);
});
it("rejects non-existent absolute paths", async () => {
const ghost = path.join(tempRoot, "does-not-exist");
const result = await validateExistingDirectory(ghost);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/exist/i);
});
it("rejects absolute paths that point at files rather than directories", async () => {
const file = path.join(tempRoot, "file.txt");
writeFileSync(file, "not a directory");
const result = await validateExistingDirectory(file);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/directory/i);
});
it("accepts an existing absolute directory and returns the realpath", async () => {
const result = await validateExistingDirectory(tempRoot);
expect(result.ok).toBe(true);
if (result.ok) expect(result.resolved).toBe(await realpath(tempRoot));
});
it("realpath-resolves symlinks so attackers cannot register one path and reach another", async () => {
const realDir = path.join(tempRoot, "real");
await mkdir(realDir);
const linkDir = path.join(tempRoot, "link");
symlinkSync(realDir, linkDir, "dir");
const result = await validateExistingDirectory(linkDir);
expect(result.ok).toBe(true);
if (result.ok) expect(result.resolved).toBe(await realpath(realDir));
});
it("rejects macOS .app bundles even though they are technically directories", async () => {
// Construct a fake .app bundle on disk; it's just a directory
// whose name ends in `.app`. shell.openPath would *launch* this
// as an application, so the path gate must short-circuit here
// regardless of platform (the suffix-based check is portable).
const bundle = path.join(tempRoot, "Foo.app");
await mkdir(bundle);
const result = await validateExistingDirectory(bundle);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/application bundles/i);
});
it("rejects symlinks whose realpath resolves to a .app bundle", async () => {
// Defense in depth: a renderer or malicious project metadata
// could try to launder a `.app` bundle via a symlink whose name
// doesn't end in `.app`. The realpath check before the suffix
// test catches that.
const realApp = path.join(tempRoot, "Real.app");
await mkdir(realApp);
const linkDir = path.join(tempRoot, "innocent-name");
symlinkSync(realApp, linkDir, "dir");
const result = await validateExistingDirectory(linkDir);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/application bundles/i);
});
});
describe("fetchResolvedProjectDir", () => {
it("rejects empty project ids without sending a request", async () => {
const fetchImpl = vi.fn();
const result = await fetchResolvedProjectDir("http://localhost:1234", "", fetchImpl);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/non-empty/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("rejects project ids containing disallowed characters (path traversal guard)", async () => {
const fetchImpl = vi.fn();
// `/` is not in the daemon's `isSafeId` regex `[A-Za-z0-9._-]{1,128}`,
// so a path-traversal attempt is rejected before the request is
// built (no leakage to the daemon, no fetch attempted).
const result = await fetchResolvedProjectDir("http://localhost:1234", "../escape", fetchImpl);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/disallowed characters/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("accepts dotted project ids that the daemon also accepts", async () => {
// PR #974 round-4 mrcfps: the prior regex was stricter than
// `apps/daemon/src/projects.ts#isSafeId` (which allows `.`), so
// legitimate ids like `my-project.v2` regressed Continue in CLI /
// Finalize even though the backend created them happily.
const fetchImpl = vi.fn(async () =>
new Response(
JSON.stringify({ project: { id: "my-project.v2" }, resolvedDir: "/p" }),
{ status: 200 },
),
);
const result = await fetchResolvedProjectDir("http://localhost:1234", "my-project.v2", fetchImpl);
expect(result.ok).toBe(true);
expect(fetchImpl).toHaveBeenCalledWith("http://localhost:1234/api/projects/my-project.v2");
});
it("rejects project ids longer than the daemon's 128-char cap", async () => {
const fetchImpl = vi.fn();
const tooLong = "a".repeat(129);
const result = await fetchResolvedProjectDir("http://localhost:1234", tooLong, fetchImpl);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/disallowed characters/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("returns the daemon's resolvedDir when the project-detail endpoint succeeds", async () => {
const fetchImpl = vi.fn(async () =>
new Response(
JSON.stringify({
project: { id: "p1", name: "fixture" },
resolvedDir: "/tmp/projects/p1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const result = await fetchResolvedProjectDir("http://localhost:1234", "p1", fetchImpl);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.context.resolvedDir).toBe("/tmp/projects/p1");
// Native project (no metadata.baseDir) — always safe to forward to
// shell.openPath because the resolvedDir lives under the daemon's
// own projects root, not a user-controlled location.
expect(result.context.hasBaseDir).toBe(false);
expect(result.context.fromTrustedPicker).toBe(false);
}
expect(fetchImpl).toHaveBeenCalledWith("http://localhost:1234/api/projects/p1");
});
it("flags folder-imported projects without the trusted-picker marker", async () => {
// PR #974 mrcfps follow-up: the desktop main process refuses to
// forward `shell.openPath` for folder-imported projects whose
// metadata lacks `fromTrustedPicker: true`, even though the
// resolvedDir is technically valid.
const fetchImpl = vi.fn(async () =>
new Response(
JSON.stringify({
project: {
id: "legacy",
name: "legacy folder import",
metadata: { kind: "prototype", baseDir: "/Users/u/legacy" },
},
resolvedDir: "/Users/u/legacy",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const result = await fetchResolvedProjectDir("http://localhost:1234", "legacy", fetchImpl);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.context.hasBaseDir).toBe(true);
expect(result.context.fromTrustedPicker).toBe(false);
}
});
it("trusts folder-imported projects whose metadata has fromTrustedPicker", async () => {
const fetchImpl = vi.fn(async () =>
new Response(
JSON.stringify({
project: {
id: "trusted",
name: "trusted import",
metadata: {
kind: "prototype",
baseDir: "/Users/u/trusted",
fromTrustedPicker: true,
},
},
resolvedDir: "/Users/u/trusted",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const result = await fetchResolvedProjectDir("http://localhost:1234", "trusted", fetchImpl);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.context.hasBaseDir).toBe(true);
expect(result.context.fromTrustedPicker).toBe(true);
}
});
it("strips trailing slashes from the web URL when constructing the request", async () => {
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ project: {}, resolvedDir: "/x" }), { status: 200 }),
);
await fetchResolvedProjectDir("http://localhost:1234/", "p1", fetchImpl);
expect(fetchImpl).toHaveBeenCalledWith("http://localhost:1234/api/projects/p1");
});
it("returns an error when the daemon responds non-2xx", async () => {
const fetchImpl = vi.fn(async () =>
new Response("nope", { status: 404 }),
);
const result = await fetchResolvedProjectDir("http://localhost:1234", "missing", fetchImpl);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/HTTP 404/);
});
it("returns an error when the daemon response is missing resolvedDir", async () => {
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ project: { id: "p1" } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await fetchResolvedProjectDir("http://localhost:1234", "p1", fetchImpl);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/resolvedDir/);
});
it("returns an error when fetch itself rejects (network error)", async () => {
const fetchImpl = vi.fn(async () => {
throw new TypeError("ECONNREFUSED");
});
const result = await fetchResolvedProjectDir("http://localhost:1234", "p1", fetchImpl);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/daemon fetch failed/i);
});
it("encodes the project id in the URL so reserved characters round-trip safely", async () => {
// Project ids that pass the regex include alphanumerics, `_`, and
// `-`; encodeURIComponent is a no-op for those, but pin the
// contract anyway.
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ project: {}, resolvedDir: "/x" }), { status: 200 }),
);
await fetchResolvedProjectDir("http://localhost:1234", "abc-123_xyz", fetchImpl);
expect(fetchImpl).toHaveBeenCalledWith("http://localhost:1234/api/projects/abc-123_xyz");
});
});
describe("isOpenPathAllowedForProject", () => {
// PR #974 mrcfps follow-up: the desktop main process refuses to
// forward `shell.openPath` for folder-imported projects whose
// metadata lacks the trusted-picker marker. These three cases pin
// the literal interpretation of his round-3 ask.
it("allows native projects (no baseDir → daemon-owned resolvedDir)", () => {
const result = isOpenPathAllowedForProject({
fromTrustedPicker: false,
hasBaseDir: false,
resolvedDir: "/tmp/od-projects/abc123",
});
expect(result.ok).toBe(true);
});
it("allows folder-imported projects whose metadata is fromTrustedPicker", () => {
const result = isOpenPathAllowedForProject({
fromTrustedPicker: true,
hasBaseDir: true,
resolvedDir: "/Users/u/trusted-import",
});
expect(result.ok).toBe(true);
});
it("refuses folder-imported projects without the trusted-picker marker", () => {
const result = isOpenPathAllowedForProject({
fromTrustedPicker: false,
hasBaseDir: true,
resolvedDir: "/Users/u/legacy-import",
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/trusted picker/i);
});
});
describe("signDesktopImportToken", () => {
// The desktop main process mints these tokens for `POST
// /api/import/folder`. The daemon recomputes the same HMAC and
// accepts only matching signatures, so token shape is part of the
// wire contract between desktop and daemon (PR #974). Field
// separator is `~` (not `.`) because ISO 8601 expiry strings embed
// dots — drift between the two sides would silently reject every
// real token.
const SECRET_A = Buffer.from("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJyg=", "base64");
const SECRET_B = Buffer.from("/9j/4AAQSkZJRgABAQAAAQABAAD/4QAYRXhpZgAATU0AKgAAAAgAAQAAA==", "base64");
it("produces a token shaped `${nonce}~${exp}~${signature}` with three non-empty parts", () => {
const token = signDesktopImportToken(SECRET_A, "/Users/u/proj", {
nonce: "n1",
exp: "2026-05-08T20:00:00.000Z",
});
const parts = token.split("~");
expect(parts).toHaveLength(3);
for (const part of parts) expect(part.length).toBeGreaterThan(0);
expect(parts[0]).toBe("n1");
expect(parts[1]).toBe("2026-05-08T20:00:00.000Z");
});
it("is deterministic for identical (secret, baseDir, nonce, exp) tuples", () => {
const args = { nonce: "n2", exp: "2026-05-08T20:01:00.000Z" } as const;
expect(signDesktopImportToken(SECRET_A, "/Users/u/proj", args)).toEqual(
signDesktopImportToken(SECRET_A, "/Users/u/proj", args),
);
});
it("changes the signature when the baseDir changes", () => {
const args = { nonce: "n3", exp: "2026-05-08T20:02:00.000Z" } as const;
const a = signDesktopImportToken(SECRET_A, "/Users/u/proj-a", args).split("~")[2];
const b = signDesktopImportToken(SECRET_A, "/Users/u/proj-b", args).split("~")[2];
expect(a).not.toEqual(b);
});
it("changes the signature when the nonce changes", () => {
const a = signDesktopImportToken(SECRET_A, "/p", { nonce: "n4", exp: "2026-05-08T20:03:00.000Z" });
const b = signDesktopImportToken(SECRET_A, "/p", { nonce: "n5", exp: "2026-05-08T20:03:00.000Z" });
expect(a.split("~")[2]).not.toEqual(b.split("~")[2]);
});
it("changes the signature when the expiry changes", () => {
const a = signDesktopImportToken(SECRET_A, "/p", { nonce: "n6", exp: "2026-05-08T20:04:00.000Z" });
const b = signDesktopImportToken(SECRET_A, "/p", { nonce: "n6", exp: "2026-05-08T20:05:00.000Z" });
expect(a.split("~")[2]).not.toEqual(b.split("~")[2]);
});
it("changes the signature when the secret changes", () => {
const args = { nonce: "n7", exp: "2026-05-08T20:06:00.000Z" } as const;
const a = signDesktopImportToken(SECRET_A, "/p", args).split("~")[2];
const b = signDesktopImportToken(SECRET_B, "/p", args).split("~")[2];
expect(a).not.toEqual(b);
});
});