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>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user