tests/paths.test.ts
Rama main · réplica de lectura del repositorio original.
// Path safety (server/vault.ts): every path a client can name has to land
// inside the vault, and the parts of the vault the app pretends do not exist
// (.obsidian, .trash, dotfiles) must stay unreachable through every route.
import assert from "node:assert/strict";
import { symlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { after, describe, it } from "node:test";
import {
assertMarkdown,
initVault,
isIgnoredRel,
isIgnoredSegment,
normalizeRel,
readNote,
VaultError,
safeAbs,
} from "../server/vault.ts";
import { makeDir, makeVault, removeVault } from "./helpers/vault.ts";
const outside = makeDir();
writeFileSync(path.join(outside, "secret.md"), "# outside the vault\n");
const root = makeVault({
"Note.md": "# Note\n",
"folder/Inner.md": "# Inner\n",
".obsidian/workspace.json": "{}",
".trash/Old.md": "# Old\n",
".hidden.md": "# Hidden\n",
"attachments/img.png": "png",
});
symlinkSync(path.join(outside, "secret.md"), path.join(root, "escape.md"));
symlinkSync(outside, path.join(root, "escapedir"));
initVault(root);
after(() => {
removeVault(root);
removeVault(outside);
});
/** The status a safeAbs()-style call rejects `rel` with, or "" when it allows it. */
function reject(rel: string): number | "" {
try {
safeAbs(rel);
return "";
} catch (err) {
assert.ok(err instanceof VaultError, `expected VaultError for ${JSON.stringify(rel)}`);
return err.status;
}
}
describe("normalizeRel", () => {
it("canonicalizes separators, leading ./ and trailing /", () => {
assert.equal(normalizeRel("a\\b\\c.md"), "a/b/c.md");
assert.equal(normalizeRel("./a/b.md"), "a/b.md");
assert.equal(normalizeRel("//a/b.md"), "a/b.md");
assert.equal(normalizeRel("a//b.md"), "a/b.md");
assert.equal(normalizeRel("a/./b.md"), "a/b.md");
assert.equal(normalizeRel("folder/"), "folder");
assert.equal(normalizeRel(""), "");
assert.equal(normalizeRel("."), "");
});
it("keeps dots that are part of a NAME (a note may be called 'Jr..md')", () => {
assert.equal(normalizeRel("Jr..md"), "Jr..md");
assert.equal(normalizeRel("a/..b.md"), "a/..b.md");
assert.equal(normalizeRel("a/b...md"), "a/b...md");
});
});
describe("safeAbs traversal", () => {
it("refuses .. as a whole segment (400)", () => {
for (const rel of [
"../secret.md",
"a/../../secret.md",
"..",
"a/b/../../../secret.md",
"..\\..\\secret.md",
"./../secret.md",
"folder/../../secret.md",
]) {
assert.equal(reject(rel), 400, `should reject ${JSON.stringify(rel)}`);
}
});
it("never escapes on percent-encoded separators (the layer above decodes once)", () => {
// Hono has already URL-decoded the query param, so what arrives here is
// literal text: "%2e%2e" is a NAME, not a traversal, and must resolve
// inside the vault (as a missing file) rather than climbing out of it.
for (const rel of ["a/%2e%2e/b.md", "%2e%2e/secret.md", "..%2f..%2fsecret.md", "%252e%252e/x.md"]) {
const status = reject(rel);
if (status === "") {
assert.ok(safeAbs(rel).startsWith(root + path.sep), `${rel} escaped the vault`);
} else {
// A refusal is fine too — "..%2f…" opens with a dot, so the dotfile
// rule hides it (404) before the traversal rule can 400 it.
assert.ok(status === 400 || status === 404, `unexpected status ${status} for ${rel}`);
}
}
});
it("refuses NUL bytes", () => {
const NUL = String.fromCharCode(0);
assert.equal(reject(`Note.md${NUL}.png`), 400);
assert.equal(reject(`folder/${NUL}/x.md`), 400);
});
it("treats an absolute-looking path as vault-relative, never as absolute", () => {
assert.equal(safeAbs("/Note.md"), path.join(root, "Note.md"));
assert.equal(safeAbs("/etc/passwd"), path.join(root, "etc/passwd"));
});
it("resolves ordinary paths inside the vault", () => {
assert.equal(safeAbs("Note.md"), path.join(root, "Note.md"));
assert.equal(safeAbs("folder/Inner.md"), path.join(root, "folder/Inner.md"));
assert.equal(safeAbs(""), root);
});
});
describe("safeAbs invisibility rules", () => {
it("404s dotfiles and the ignored trees (existence is never revealed)", () => {
for (const rel of [
".obsidian/workspace.json",
".obsidian",
".trash/Old.md",
".trash",
".hidden.md",
"folder/.git/config",
"node_modules/pkg/index.js",
"NODE_MODULES/pkg/index.js",
]) {
assert.equal(reject(rel), 404, `should hide ${JSON.stringify(rel)}`);
}
});
it("uses the same rule for a single segment and a whole path", () => {
assert.equal(isIgnoredSegment(".obsidian"), true);
assert.equal(isIgnoredSegment(".Trash"), true);
assert.equal(isIgnoredSegment("node_modules"), true);
assert.equal(isIgnoredSegment("Node_Modules"), true);
assert.equal(isIgnoredSegment("notes"), false);
assert.equal(isIgnoredRel(""), false);
assert.equal(isIgnoredRel("a/.obsidian/b"), true);
assert.equal(isIgnoredRel("a/b/c.md"), false);
});
});
describe("assertMarkdown", () => {
it("accepts .md in any case and rejects everything else", () => {
assert.equal(assertMarkdown("a/b.md"), "a/b.md");
assert.equal(assertMarkdown("a/b.MD"), "a/b.MD");
assert.equal(assertMarkdown("./a/b.md"), "a/b.md");
for (const rel of ["", "a/b.txt", "a/b.markdown", "a/b.md.png", "folder"]) {
assert.throws(() => assertMarkdown(rel), VaultError, `should reject ${JSON.stringify(rel)}`);
}
});
});
describe("unicode normalisation", () => {
// A macOS vault stores "café.md" decomposed (NFD); a link typed on Linux is
// composed (NFC). Neither vault.ts nor the indexer normalises, so the two
// spellings are different paths — the reason a "file exists but the link is
// broken" report is almost always a Mac-authored note.
const nfc = "caf\u00e9.md"; // é as one codepoint
const nfd = "cafe\u0301.md"; // e + combining acute
it("does NOT fold NFC and NFD spellings together (known gap)", () => {
assert.notEqual(nfc, nfd);
assert.equal(nfc.normalize("NFD"), nfd, "fixture sanity: same grapheme, two encodings");
assert.notEqual(safeAbs(nfc), safeAbs(nfd));
assert.equal(normalizeRel(nfd), nfd, "normalizeRel is path-shape only, not unicode");
});
});
describe("symlinks", () => {
// This block used to DOCUMENT the opposite: safeAbs was a lexical check, so
// "escape.md" — a symlink pointing out of the vault — was inside the vault
// as a STRING and reading it happily returned a file from elsewhere on the
// disk. One `ln -s /etc evil` turned an anonymous reader into a filesystem
// reader. Containment is now checked against the FILESYSTEM (realpath), on
// reads as well as writes, so the gap the old test described is closed and
// these assertions are its inverse.
it("refuses a symlink that escapes the vault, on read", async () => {
assert.throws(() => safeAbs("escape.md"), VaultError, "the link itself is not resolvable");
await assert.rejects(() => readNote("escape.md"), VaultError);
});
it("refuses a path THROUGH a symlinked directory", async () => {
assert.throws(() => safeAbs("escapedir/secret.md"), VaultError);
await assert.rejects(() => readNote("escapedir/secret.md"), VaultError);
});
});