Blocking (review id 2514): - [security] Forbid symlinks in vaults. ensureServable now sets core.symlinks=false in each vault's local git config (a pushed symlink is checked out as a plain file, never a real link), and the engine cycle wraps every read/write/mkdir in an lstat/realpath guard (new path-guard.ts) that refuses a path that is — or traverses — a symlink, or whose realpath escapes the vault root. Prevents a writer from publishing /etc/passwd or the server .env, or writing outside the vault. Adds unit tests (path-guard.test.ts) + a read-guard integration test (cycle.test.ts) + real lstat/realpath in the roundtrip integration test. - [simplification] Delete dead lib/diff.ts + test/diff.test.ts and drop the now-unused @fellow/prosemirror-recreate-transform dependency. - [documentation] Add a CHANGELOG [Unreleased] → Added entry for git-sync. Warnings: - [test-coverage] Cover the CREATE-branch conflict-markers guard (a new .md with markers and no gitmost_id is recorded as a create failure, never created). Suggestions: - [stability] Bound each `git config` in ensureServable with a timeout. - [authz] Trigger endpoint resolves spaceId workspace-scoped and 404s a foreign space before any vault directory is created. - [stability] Attribute git-initiated moves to the service account (lastUpdatedById), via an optional actor param on PageService.movePage. - [documentation] Document the per-space autoMergeConflicts toggle in AGENTS.md. - [test-coverage] Cover the unterminated `:::` callout fence fallback. - [simplification] Move test-only roundtrip-helpers.ts out of src/ into test/. Architecture: - Move the Yjs/ProseMirror merge primitives (yjs-body-merge, three-way-merge, lcs + specs) into collaboration/merge/, breaking the collaboration → integrations/git-sync dependency cycle this PR introduced. - Port the schema-surface drift gate to packages/mcp (the mcp schema mirror had none); pins 52 entries. Deferred (with rationale in the review thread): the incremental-pull perf warning (correctness-neutral; needs a high-water-mark design + its own tests on the data-loss-critical path) and the redis-sync rolling-deploy mixed-version edge (the deficient behavior is in already-released old-instance code; the new code is correct on both sides; impact is a transient rollout-window artifact). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
assertVaultPathSafe,
|
|
isWithinRoot,
|
|
VaultPathUnsafeError,
|
|
type PathGuardIo,
|
|
} from "../src/engine/path-guard";
|
|
|
|
const VAULT = "/srv/git-sync/space-1";
|
|
|
|
/**
|
|
* Build a fake PathGuardIo from a model of the filesystem:
|
|
* - `symlinks`: absolute paths that ARE symlinks (lstat -> isSymbolicLink).
|
|
* - `existing`: absolute paths that EXIST (anything not listed is ENOENT/null).
|
|
* The vault root is always treated as existing.
|
|
* - `realpaths`: optional realpath overrides (default: identity for existing).
|
|
*/
|
|
function fakeIo(model: {
|
|
symlinks?: string[];
|
|
existing?: string[];
|
|
realpaths?: Record<string, string>;
|
|
}): PathGuardIo {
|
|
const symlinks = new Set(model.symlinks ?? []);
|
|
const existing = new Set([VAULT, ...(model.existing ?? []), ...symlinks]);
|
|
return {
|
|
lstat: vi.fn(async (p: string) =>
|
|
existing.has(p) ? { isSymbolicLink: symlinks.has(p) } : null,
|
|
),
|
|
realpath: vi.fn(async (p: string) =>
|
|
existing.has(p) ? (model.realpaths?.[p] ?? p) : null,
|
|
),
|
|
};
|
|
}
|
|
|
|
describe("isWithinRoot", () => {
|
|
it("accepts the root itself and nested paths", () => {
|
|
expect(isWithinRoot(VAULT, VAULT)).toBe(true);
|
|
expect(isWithinRoot(VAULT, `${VAULT}/a/b.md`)).toBe(true);
|
|
});
|
|
it("rejects siblings, ancestors and `..` traversal", () => {
|
|
expect(isWithinRoot(VAULT, "/srv/git-sync/space-2/x.md")).toBe(false);
|
|
expect(isWithinRoot(VAULT, "/srv/git-sync")).toBe(false);
|
|
expect(isWithinRoot(VAULT, `${VAULT}/../space-2/x.md`)).toBe(false);
|
|
expect(isWithinRoot(VAULT, "/etc/passwd")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("assertVaultPathSafe", () => {
|
|
it("allows a normal nested file with no symlinks on its chain", async () => {
|
|
const io = fakeIo({ existing: [`${VAULT}/Folder`, `${VAULT}/Folder/Page.md`] });
|
|
await expect(
|
|
assertVaultPathSafe(io, VAULT, `${VAULT}/Folder/Page.md`),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("allows a NOT-YET-EXISTING leaf (the normal write/mkdir case)", async () => {
|
|
// Folder exists, the .md does not yet — the walk stops at the absent leaf.
|
|
const io = fakeIo({ existing: [`${VAULT}/Folder`] });
|
|
await expect(
|
|
assertVaultPathSafe(io, VAULT, `${VAULT}/Folder/New.md`),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("rejects a TARGET that is itself a symlink (the leak.md -> /etc/passwd attack)", async () => {
|
|
const io = fakeIo({ symlinks: [`${VAULT}/leak.md`] });
|
|
await expect(
|
|
assertVaultPathSafe(io, VAULT, `${VAULT}/leak.md`),
|
|
).rejects.toBeInstanceOf(VaultPathUnsafeError);
|
|
await expect(
|
|
assertVaultPathSafe(io, VAULT, `${VAULT}/leak.md`),
|
|
).rejects.toMatchObject({ reason: "symlink" });
|
|
});
|
|
|
|
it("rejects a path whose ANCESTOR directory is a symlink (write-outside-vault primitive)", async () => {
|
|
// `escape` is a symlinked dir; writing `escape/x.md` would land outside.
|
|
const io = fakeIo({
|
|
symlinks: [`${VAULT}/escape`],
|
|
existing: [`${VAULT}/escape/x.md`],
|
|
});
|
|
await expect(
|
|
assertVaultPathSafe(io, VAULT, `${VAULT}/escape/x.md`),
|
|
).rejects.toMatchObject({ reason: "symlink" });
|
|
});
|
|
|
|
it("rejects a `..` traversal lexically, before any IO", async () => {
|
|
const io = fakeIo({});
|
|
await expect(
|
|
assertVaultPathSafe(io, VAULT, `${VAULT}/../space-2/steal.md`),
|
|
).rejects.toMatchObject({ reason: "escape" });
|
|
expect(io.lstat).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects when the deepest existing ancestor's realpath escapes the vault", async () => {
|
|
// No symlink flagged by lstat (e.g. the data dir was relocated under a link
|
|
// the lexical/lstat checks below the root cannot see), but realpath resolves
|
|
// the existing ancestor outside the vault's realpath.
|
|
const io = fakeIo({
|
|
existing: [`${VAULT}/sub`],
|
|
realpaths: { [VAULT]: VAULT, [`${VAULT}/sub`]: "/elsewhere/sub" },
|
|
});
|
|
await expect(
|
|
assertVaultPathSafe(io, VAULT, `${VAULT}/sub/page.md`),
|
|
).rejects.toMatchObject({ reason: "escape" });
|
|
});
|
|
|
|
it("allows the vault root path itself", async () => {
|
|
const io = fakeIo({});
|
|
await expect(assertVaultPathSafe(io, VAULT, VAULT)).resolves.toBeUndefined();
|
|
});
|
|
});
|