Ctrl K
ComunidadGuías…

tests/paths.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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);
  });
});