@openparachute/vault 0.7.1 → 0.7.2-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vault.test.ts CHANGED
@@ -11,7 +11,7 @@ import { BunStore } from "./vault-store.ts";
11
11
  import { generateMcpTools } from "../core/src/mcp.ts";
12
12
  import { getLinksHydrated } from "../core/src/links.ts";
13
13
  import { buildVaultProjection } from "../core/src/vault-projection.ts";
14
- import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks } from "./routes.ts";
14
+ import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks, MAX_JSON_BODY_BYTES } from "./routes.ts";
15
15
  import { expandTokenTagScope } from "./tag-scope.ts";
16
16
  import type { TagScopeCtx } from "./routes.ts";
17
17
  import { extractApiKey } from "./auth.ts";
@@ -539,6 +539,37 @@ describe("MCP tools", async () => {
539
539
  expect(result.tags).toContain("daily");
540
540
  });
541
541
 
542
+ // FIX 2 (vault#589) — the MCP door rejects illegal paths too. The core tool
543
+ // throws a `PathValidationError` (error_type invalid_path) exactly like
544
+ // ExtensionValidationError; mcp-http.ts's generic error_type mapping turns it
545
+ // into a structured domain error at the transport. Assert the MCP tool path
546
+ // itself refuses the write (parity with the REST-door tests below).
547
+ test("create-note MCP tool rejects a '..' path with error_type invalid_path", async () => {
548
+ const tools = generateMcpTools(store);
549
+ const createNote = tools.find((t) => t.name === "create-note")!;
550
+ let thrown: any;
551
+ try {
552
+ await createNote.execute({ content: "x", path: "../escape" });
553
+ } catch (e) { thrown = e; }
554
+ expect(thrown).toBeTruthy();
555
+ expect(thrown.error_type).toBe("invalid_path");
556
+ expect(thrown.code).toBe("INVALID_PATH");
557
+ // Nothing written.
558
+ expect(await store.getNoteByPath("escape")).toBeNull();
559
+ });
560
+
561
+ test("create-note MCP tool rejects a NUL path with error_type invalid_path", async () => {
562
+ const NUL = String.fromCharCode(0);
563
+ const tools = generateMcpTools(store);
564
+ const createNote = tools.find((t) => t.name === "create-note")!;
565
+ let thrown: any;
566
+ try {
567
+ await createNote.execute({ content: "x", path: `bad${NUL}path` });
568
+ } catch (e) { thrown = e; }
569
+ expect(thrown).toBeTruthy();
570
+ expect(thrown.error_type).toBe("invalid_path");
571
+ });
572
+
542
573
  test("every tool has inputSchema and execute", () => {
543
574
  const tools = generateMcpTools(store);
544
575
  for (const tool of tools) {
@@ -2664,6 +2695,63 @@ describe("HTTP /notes", async () => {
2664
2695
  expect(body.error_type).toBe("invalid_extension");
2665
2696
  });
2666
2697
 
2698
+ // FIX 2 (vault#589) — a note path with a NUL byte or a `..` segment is
2699
+ // rejected at the write surface (400 invalid_path), never persisted. A
2700
+ // NUL-in-path note otherwise slips the export traversal guard and then
2701
+ // aborts the entire vault export; a `..` note is silently un-round-trippable.
2702
+ test("POST /notes rejects a NUL-byte path with 400 invalid_path (not 201)", async () => {
2703
+ const NUL = String.fromCharCode(0);
2704
+ const res = await handleNotes(
2705
+ mkReq("POST", "/notes", { content: "x", path: `bad${NUL}path` }),
2706
+ store,
2707
+ "",
2708
+ );
2709
+ expect(res.status).toBe(400);
2710
+ const body = await res.json() as any;
2711
+ expect(body.error_type).toBe("invalid_path");
2712
+ // Nothing was written.
2713
+ expect(await store.getNoteByPath("bad")).toBeNull();
2714
+ });
2715
+
2716
+ test("POST /notes rejects a '..' path with 400 invalid_path", async () => {
2717
+ const res = await handleNotes(
2718
+ mkReq("POST", "/notes", { content: "x", path: "../escape" }),
2719
+ store,
2720
+ "",
2721
+ );
2722
+ expect(res.status).toBe(400);
2723
+ const body = await res.json() as any;
2724
+ expect(body.error_type).toBe("invalid_path");
2725
+ });
2726
+
2727
+ test("POST /notes still accepts a legitimate path with dots (regression)", async () => {
2728
+ const res = await handleNotes(
2729
+ mkReq("POST", "/notes", { content: "ok", path: "Projects/v1.2/notes" }),
2730
+ store,
2731
+ "",
2732
+ );
2733
+ expect(res.status).toBeLessThan(400);
2734
+ const body = await res.json() as any;
2735
+ expect(body.path).toBe("Projects/v1.2/notes");
2736
+ });
2737
+
2738
+ test("PATCH /notes/:id rejects a '..' path with 400 invalid_path", async () => {
2739
+ const note = await store.createNote("hi", { id: "path-bad", path: "p" });
2740
+ const res = await handleNotes(
2741
+ mkReq("PATCH", "/notes/path-bad", {
2742
+ path: "../../etc/passwd",
2743
+ if_updated_at: note.updatedAt,
2744
+ }),
2745
+ store,
2746
+ "/path-bad",
2747
+ );
2748
+ expect(res.status).toBe(400);
2749
+ const body = await res.json() as any;
2750
+ expect(body.error_type).toBe("invalid_path");
2751
+ // The note's original path is untouched.
2752
+ expect((await store.getNote("path-bad"))!.path).toBe("p");
2753
+ });
2754
+
2667
2755
  test("GET /notes?extension=csv filters by extension", async () => {
2668
2756
  await store.createNote("md note", { path: "a" });
2669
2757
  await store.createNote("csv note", { path: "b", extension: "csv" });
@@ -4110,6 +4198,144 @@ describe("HTTP /notes", async () => {
4110
4198
  });
4111
4199
  });
4112
4200
 
4201
+ // ---------------------------------------------------------------------------
4202
+ // REST transport hardening — malformed/wrong-shape/oversize JSON bodies
4203
+ // (LB7). Before the fix: malformed JSON on POST /notes, POST
4204
+ // /notes/:id/attachments, and PUT /tags/:name threw past the handler into
4205
+ // server.ts's generic top-level catch — a 500 with no `error_type` (LB7a),
4206
+ // unlike the already-hardened /tags/merge and /tags/:name/rename routes,
4207
+ // which returned a clean 400 `invalid_json`. A syntactically-valid but
4208
+ // wrong-shape body (`null`, `42`, `[]`) either threw a TypeError deep in the
4209
+ // handler or sailed through property access as `undefined`, silently
4210
+ // creating a blank note / no-op update (LB7b). And nothing capped request
4211
+ // body size, so an oversized `content` field reached the store unbounded
4212
+ // (LB7c). Every assertion here MUST fail without the fix (parseJsonBody).
4213
+ // ---------------------------------------------------------------------------
4214
+ describe("REST transport hardening — malformed/wrong-shape/oversize JSON bodies (LB7)", async () => {
4215
+ /** Raw (non-JSON.stringify'd) request body — for malformed-JSON cases mkReq can't express. */
4216
+ function mkRawReq(method: string, path: string, rawBody: string): Request {
4217
+ return new Request(`${BASE}${path}`, {
4218
+ method,
4219
+ body: rawBody,
4220
+ headers: { "Content-Type": "application/json" },
4221
+ });
4222
+ }
4223
+
4224
+ describe("malformed JSON -> clean 400 invalid_json, not a 500 (LB7a)", () => {
4225
+ test("POST /notes", async () => {
4226
+ const res = await handleNotes(mkRawReq("POST", "/notes", "{not valid json"), store, "");
4227
+ expect(res.status).toBe(400);
4228
+ const body = await res.json() as any;
4229
+ expect(body.error_type).toBe("invalid_json");
4230
+ });
4231
+
4232
+ test("POST /notes/:id/attachments", async () => {
4233
+ await store.createNote("x", { id: "att-malformed" });
4234
+ const res = await handleNotes(
4235
+ mkRawReq("POST", "/notes/att-malformed/attachments", "{not valid json"),
4236
+ store,
4237
+ "/att-malformed/attachments",
4238
+ );
4239
+ expect(res.status).toBe(400);
4240
+ const body = await res.json() as any;
4241
+ expect(body.error_type).toBe("invalid_json");
4242
+ });
4243
+
4244
+ test("PATCH /notes/:idOrPath", async () => {
4245
+ await store.createNote("x", { id: "patch-malformed" });
4246
+ const res = await handleNotes(
4247
+ mkRawReq("PATCH", "/notes/patch-malformed", "{not valid json"),
4248
+ store,
4249
+ "/patch-malformed",
4250
+ );
4251
+ expect(res.status).toBe(400);
4252
+ const body = await res.json() as any;
4253
+ expect(body.error_type).toBe("invalid_json");
4254
+ });
4255
+
4256
+ test("PUT /tags/:name — parity with the already-hardened /tags/merge shape", async () => {
4257
+ const res = await handleTags(mkRawReq("PUT", "/tags/malformed", "{not valid json"), store, "/malformed");
4258
+ expect(res.status).toBe(400);
4259
+ const body = await res.json() as any;
4260
+ expect(body.error_type).toBe("invalid_json");
4261
+ });
4262
+
4263
+ test("PATCH /vault-info", async () => {
4264
+ const cfg = { name: "default" } as { name: string };
4265
+ const res = await handleVault(mkRawReq("PATCH", "/vault", "{not valid json"), store, cfg as any);
4266
+ expect(res.status).toBe(400);
4267
+ const body = await res.json() as any;
4268
+ expect(body.error_type).toBe("invalid_json");
4269
+ });
4270
+ });
4271
+
4272
+ // Wrong-SHAPE bodies parse fine as JSON but aren't the object a route
4273
+ // expects — the taxonomy calls that `invalid_request` (parallel to
4274
+ // /tags/merge's sources/target shape errors), NOT `invalid_json` (which is
4275
+ // reserved for a genuine parse failure, asserted in the LB7a block above).
4276
+ describe("wrong-shape (but syntactically valid) JSON body -> 400 invalid_request, not 500 or a silent blank write (LB7b)", () => {
4277
+ test("POST /notes with a `null` body -> 400 invalid_request, not a 500 TypeError", async () => {
4278
+ const res = await handleNotes(mkReq("POST", "/notes", null), store, "");
4279
+ expect(res.status).toBe(400);
4280
+ const body = await res.json() as any;
4281
+ expect(body.error_type).toBe("invalid_request");
4282
+ });
4283
+
4284
+ test("POST /notes with a bare number body -> 400 invalid_request, not a silently-created blank note", async () => {
4285
+ const res = await handleNotes(mkReq("POST", "/notes", 42), store, "");
4286
+ expect(res.status).toBe(400);
4287
+ const body = await res.json() as any;
4288
+ expect(body.error_type).toBe("invalid_request");
4289
+ const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
4290
+ expect(after).toHaveLength(0); // no blank note landed
4291
+ });
4292
+
4293
+ test("POST /notes with a bare array body -> 400 invalid_request, not a silently-created blank note", async () => {
4294
+ const res = await handleNotes(mkReq("POST", "/notes", []), store, "");
4295
+ expect(res.status).toBe(400);
4296
+ const body = await res.json() as any;
4297
+ expect(body.error_type).toBe("invalid_request");
4298
+ const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
4299
+ expect(after).toHaveLength(0);
4300
+ });
4301
+
4302
+ test("POST /notes with notes: \"not-an-array\" -> 400 invalid_request, not per-character blank notes", async () => {
4303
+ const res = await handleNotes(mkReq("POST", "/notes", { notes: "oops" }), store, "");
4304
+ expect(res.status).toBe(400);
4305
+ const body = await res.json() as any;
4306
+ expect(body.error_type).toBe("invalid_request");
4307
+ expect(body.field).toBe("notes");
4308
+ const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
4309
+ expect(after).toHaveLength(0);
4310
+ });
4311
+
4312
+ test("POST /notes with a non-object item inside notes[] -> 400 invalid_request", async () => {
4313
+ const res = await handleNotes(
4314
+ mkReq("POST", "/notes", { notes: [{ content: "ok", path: "ok-one" }, 42] }),
4315
+ store,
4316
+ "",
4317
+ );
4318
+ expect(res.status).toBe(400);
4319
+ const body = await res.json() as any;
4320
+ expect(body.error_type).toBe("invalid_request");
4321
+ const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
4322
+ expect(after).toHaveLength(0); // the whole batch is rejected, not a partial write
4323
+ });
4324
+ });
4325
+
4326
+ describe("oversize request body -> 413, not an unbounded write (LB7c)", () => {
4327
+ test("POST /notes with an oversized content field is rejected", async () => {
4328
+ const huge = "x".repeat(MAX_JSON_BODY_BYTES + 1024);
4329
+ const res = await handleNotes(mkReq("POST", "/notes", { content: huge, path: "huge-note" }), store, "");
4330
+ expect(res.status).toBe(413);
4331
+ const body = await res.json() as any;
4332
+ expect(body.error_type).toBe("payload_too_large");
4333
+ const stored = await store.getNoteByPath("huge-note");
4334
+ expect(stored).toBeNull();
4335
+ });
4336
+ });
4337
+ });
4338
+
4113
4339
  // ---------------------------------------------------------------------------
4114
4340
  // REST tag-scope confidentiality (security review). expand_links must not
4115
4341
  // inline out-of-scope wikilinked content; include_links must not hydrate