Files
gitmost/packages/git-sync/src/engine/path-guard.ts
T
claude code agent 227 24b903aaf3 build(git-sync): land the @docmost/git-sync package into develop, code-only (#326 step 1 / PR-A)
The git-sync converter + engine source lived only on the #119 branch; develop
had just the dead compiled build/. Bring the whole package (src + ~700 tests)
onto develop under CI, with NO consumer wired — git-sync stays fully inert in
develop (nothing in apps/server imports it), so runtime behavior is unchanged.
This unblocks #293 (extract the shared converter package from the landed source)
and lets #119's functionality land LAST, already writing the canonical format
(per the #326 landing order).

- packages/git-sync: src (lib converter + engine) + test corpus + configs.
- Remove develop's dead committed packages/git-sync/build/; gitignore it
  (built in CI/Docker via pnpm build, never committed — no src/build drift).
- pnpm-lock.yaml: add the @docmost/git-sync importer (a missing workspace
  package in the lock is a CI blocker). `pnpm install --frozen-lockfile` passes.
- NO server integration / loader / Dockerfile runtime changes (those come with
  #119 at step 6).

Verified: tsc clean; vitest 711 passed | 1 expected-fail, 0 failures, 0 type
errors; pnpm --frozen-lockfile EXIT 0; apps/server has no git-sync import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 06:21:41 +03:00

133 lines
5.4 KiB
TypeScript

/**
* Vault path guard (security, defense-in-depth).
*
* A user with push access to a git-sync space could commit a `.md` entry that is
* a SYMLINK (e.g. `leak.md -> /etc/passwd` or `-> <server>/.env`). On the next
* cycle a naive `fs.readFile` would follow the link and PUBLISH the target's
* contents as a Docmost page (a read primitive that escalates a writer to
* arbitrary server-file disclosure — including the JWT secret / DB creds in
* `.env`); a symlinked DIRECTORY gives the inverse write-outside-the-vault
* primitive on pull. The primary defense is `core.symlinks=false` in each
* vault's git config (git then materializes a pushed symlink as a PLAIN FILE
* holding the link text, never a real link). This module is the second layer:
* before every engine read/write/mkdir we reject a path that IS — or traverses —
* a symlink, or whose real location escapes the vault root.
*
* IO-free by construction: the `lstat`/`realpath` primitives are injected
* (mirroring the rest of the engine) so the rules are unit-testable with fakes
* and the engine never imports `node:fs`. Path math uses `node:path`, which is
* pure.
*/
import { isAbsolute, relative, resolve, sep } from "node:path";
/** Why a path was refused. */
export type VaultPathUnsafeReason = "symlink" | "escape";
/**
* Thrown when a path is refused by the guard. Engine read/write loops already
* isolate per-file errors (skip + log), so throwing here yields the review's
* required "skip+log" behavior without a separate control channel.
*/
export class VaultPathUnsafeError extends Error {
constructor(
readonly absPath: string,
readonly reason: VaultPathUnsafeReason,
readonly vaultRoot: string,
) {
super(
reason === "symlink"
? `git-sync: refusing to access '${absPath}' — it is (or traverses) a ` +
`symlink under vault '${vaultRoot}' (symlink guard)`
: `git-sync: refusing to access '${absPath}' — it resolves outside ` +
`vault '${vaultRoot}' (symlink guard)`,
);
this.name = "VaultPathUnsafeError";
}
}
/**
* The injected IO the guard needs. Both MUST resolve to `null` on ENOENT (the
* normal case for a not-yet-created file on a write/mkdir) and reject on any
* other error.
*/
export interface PathGuardIo {
/** lstat WITHOUT following the final symlink. `null` when the path is absent. */
lstat: (absPath: string) => Promise<{ isSymbolicLink: boolean } | null>;
/** realpath (follows symlinks). `null` when the path is absent. */
realpath: (absPath: string) => Promise<string | null>;
}
/**
* Lexical containment: is `target` EQUAL to, or NESTED under, `root`? Catches a
* `..` traversal baked into a relPath before any IO. Both operands are resolved
* first so `.`/`..` segments are normalized.
*/
export function isWithinRoot(root: string, target: string): boolean {
const r = resolve(root);
const t = resolve(target);
if (t === r) return true;
const rel = relative(r, t);
return rel.length > 0 && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
}
/**
* Reject `absPath` (resolving silently when it is safe) if it:
* - escapes `vaultRoot` lexically (a `..` traversal), OR
* - IS, or traverses, a symlink at any EXISTING segment from the root down
* (a symlinked ancestor dir, or the target file/dir itself), OR
* - resolves (realpath of its deepest existing ancestor) outside the vault.
*
* Absent leaf segments — the normal case when writing/mkdir'ing a NEW file — are
* safe: the walk stops at the first non-existent segment (nothing to follow).
*/
export async function assertVaultPathSafe(
io: PathGuardIo,
vaultRoot: string,
absPath: string,
): Promise<void> {
const root = resolve(vaultRoot);
const target = resolve(absPath);
// 1. Lexical containment — a `..` in a relPath never even reaches an lstat.
if (!isWithinRoot(root, target)) {
throw new VaultPathUnsafeError(absPath, "escape", vaultRoot);
}
// 2. lstat-walk: reject a symlink at ANY existing level between the root and
// the target (inclusive). A symlinked ancestor or a symlinked target both
// let a follow-the-link read/write escape; rejecting the link itself is the
// surgical guard.
if (target !== root) {
const segments = relative(root, target)
.split(sep)
.filter((s) => s.length > 0);
let cur = root;
for (const segment of segments) {
cur = resolve(cur, segment);
const st = await io.lstat(cur);
if (st === null) break; // absent from here down — nothing left to follow
if (st.isSymbolicLink) {
throw new VaultPathUnsafeError(cur, "symlink", vaultRoot);
}
}
}
// 3. realpath belt-and-suspenders: the deepest EXISTING ancestor must resolve
// inside the vault root's realpath. Catches an ancestor relocated via a
// symlink the lexical check would miss (e.g. the data dir itself being a
// link farm) and bounds the lstat→use TOCTOU window.
const realRoot = await io.realpath(root);
if (realRoot === null) return; // root absent — ensureRepo creates it first
let probe = target;
let realProbe = await io.realpath(probe);
while (realProbe === null && probe !== root) {
const parent = resolve(probe, "..");
if (parent === probe) break; // reached the filesystem root
probe = parent;
realProbe = await io.realpath(probe);
}
if (realProbe !== null && !isWithinRoot(realRoot, realProbe)) {
throw new VaultPathUnsafeError(absPath, "escape", vaultRoot);
}
}