@openparachute/vault 0.6.4-rc.14 → 0.6.4-rc.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.4-rc.14",
3
+ "version": "0.6.4-rc.15",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/routes.ts CHANGED
@@ -2908,32 +2908,96 @@ async function handleRetryLegacyInBody(
2908
2908
 
2909
2909
  const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
2910
2910
 
2911
- // Storage allowlist policy:
2912
- // - audio + image + .pdf (knowledge-vault content: papers, scans, receipts)
2913
- // + .mp4 (mobile capture default; iOS records mp4, not webm).
2914
- // - .svg and .html are deliberately excluded both can embed `<script>`
2915
- // tags, which would turn an upload into a same-origin XSS vector when
2916
- // the asset is served back from /storage/. If a future use case needs
2917
- // SVG, sanitize on read (strip <script>/<foreignObject>) and revisit.
2918
- const ALLOWED_EXTENSIONS = new Set([
2919
- ".wav", ".mp3", ".m4a", ".ogg", ".webm",
2920
- ".png", ".jpg", ".jpeg", ".gif", ".webp",
2921
- ".pdf", ".mp4",
2911
+ // Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
2912
+ // arbitrary files ebooks, office docs, datasets, archives, binaries — so we
2913
+ // accept ANY upload EXCEPT the handful of types a browser can execute as
2914
+ // active content in our origin when served back from /storage/. (The prior
2915
+ // allowlist rejected the long tail: .epub/.csv/.zip/… all came back "File type
2916
+ // not allowed".)
2917
+ //
2918
+ // BLOCKED same-origin-XSS / active-content set:
2919
+ // .html/.htm/.xhtml/.shtml/.xht HTML embeds <script>
2920
+ // .svg XML image embeds <script>
2921
+ // .xml can carry XSLT / be parsed as XHTML
2922
+ // .js/.mjs/.cjs JavaScript
2923
+ // .css style-injection / UI-redress vector
2924
+ //
2925
+ // Two independent guards keep every STORED file inert when served:
2926
+ // 1. Only the curated MIME_TYPES below map to a real (always passive) type;
2927
+ // every other extension serves as application/octet-stream — a download,
2928
+ // never rendered.
2929
+ // 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
2930
+ // a browser can't sniff an octet-stream body into an executable type.
2931
+ // The blocklist is belt-and-suspenders on top of those: even if a future MIME
2932
+ // entry or an upstream proxy weakened (1) or (2), these extensions still never
2933
+ // land on disk. If a future use case needs SVG, sanitize on read (strip
2934
+ // <script>/<foreignObject>) and revisit.
2935
+ const BLOCKED_EXTENSIONS = new Set([
2936
+ ".html", ".htm", ".xhtml", ".shtml", ".xht",
2937
+ ".svg",
2938
+ ".xml",
2939
+ ".js", ".mjs", ".cjs",
2940
+ ".css",
2922
2941
  ]);
2923
2942
 
2943
+ // Explicit MIME types for the commonly-previewed formats. Anything accepted
2944
+ // but absent here serves as application/octet-stream — a download, never
2945
+ // rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
2946
+ // these map to an active type (text/html, image/svg+xml), so a served asset
2947
+ // can't execute script; `nosniff` on the GET response makes that ironclad.
2948
+ //
2949
+ // INVARIANT: never add an entry that maps to a browser-active type —
2950
+ // text/html, image/svg+xml, application/xhtml+xml, text/javascript,
2951
+ // application/wasm, text/css. Doing so re-enables same-origin execution for
2952
+ // that extension (and would mean it must also join BLOCKED_EXTENSIONS).
2924
2953
  const MIME_TYPES: Record<string, string> = {
2954
+ // Audio
2925
2955
  ".wav": "audio/wav",
2926
2956
  ".mp3": "audio/mpeg",
2927
2957
  ".m4a": "audio/mp4",
2928
2958
  ".ogg": "audio/ogg",
2959
+ ".oga": "audio/ogg",
2960
+ ".opus": "audio/opus",
2961
+ ".aac": "audio/aac",
2962
+ ".flac": "audio/flac",
2929
2963
  ".webm": "audio/webm",
2964
+ // Image
2930
2965
  ".png": "image/png",
2931
2966
  ".jpg": "image/jpeg",
2932
2967
  ".jpeg": "image/jpeg",
2933
2968
  ".gif": "image/gif",
2934
2969
  ".webp": "image/webp",
2935
- ".pdf": "application/pdf",
2970
+ ".bmp": "image/bmp",
2971
+ ".tiff": "image/tiff",
2972
+ ".tif": "image/tiff",
2973
+ ".heic": "image/heic",
2974
+ ".heif": "image/heif",
2975
+ ".avif": "image/avif",
2976
+ // Video
2936
2977
  ".mp4": "video/mp4",
2978
+ ".m4v": "video/x-m4v",
2979
+ ".mov": "video/quicktime",
2980
+ // Documents / ebooks / data
2981
+ ".pdf": "application/pdf",
2982
+ ".epub": "application/epub+zip",
2983
+ ".mobi": "application/x-mobipocket-ebook",
2984
+ ".txt": "text/plain; charset=utf-8",
2985
+ ".md": "text/markdown; charset=utf-8",
2986
+ ".markdown": "text/markdown; charset=utf-8",
2987
+ ".rtf": "application/rtf",
2988
+ ".csv": "text/csv; charset=utf-8",
2989
+ ".tsv": "text/tab-separated-values; charset=utf-8",
2990
+ ".json": "application/json; charset=utf-8",
2991
+ ".doc": "application/msword",
2992
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
2993
+ ".ppt": "application/vnd.ms-powerpoint",
2994
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
2995
+ ".xls": "application/vnd.ms-excel",
2996
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
2997
+ ".odt": "application/vnd.oasis.opendocument.text",
2998
+ ".ods": "application/vnd.oasis.opendocument.spreadsheet",
2999
+ ".odp": "application/vnd.oasis.opendocument.presentation",
3000
+ ".zip": "application/zip",
2937
3001
  };
2938
3002
 
2939
3003
  export async function handleStorage(
@@ -2954,9 +3018,17 @@ export async function handleStorage(
2954
3018
  if (file.size > MAX_UPLOAD_BYTES) {
2955
3019
  return json({ error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Max: 100MB` }, 413);
2956
3020
  }
2957
- const ext = extname(file.name).toLowerCase();
2958
- if (!ALLOWED_EXTENSIONS.has(ext)) {
2959
- return json({ error: `File type ${ext} not allowed` }, 400);
3021
+ // Strip trailing dots/whitespace before extracting the extension so a
3022
+ // `evil.html.` / `evil.svg ` can't slip past the blocklist
3023
+ // (extname("evil.html.") === "."). The blocklist is belt-and-suspenders;
3024
+ // anything that still gets through serves as octet-stream + nosniff anyway.
3025
+ const ext = extname(file.name.replace(/[.\s]+$/, "")).toLowerCase();
3026
+ if (BLOCKED_EXTENSIONS.has(ext)) {
3027
+ // Active-content types only — blocked because they execute as script when
3028
+ // served same-origin from /storage/ (see BLOCKED_EXTENSIONS). Everything
3029
+ // else (incl. unknown/arbitrary files) is accepted and served as a
3030
+ // download (octet-stream + nosniff).
3031
+ return json({ error: `File type ${ext} not allowed (active/executable content)` }, 400);
2960
3032
  }
2961
3033
 
2962
3034
  const date = new Date().toISOString().split("T")[0]!;
@@ -3057,6 +3129,12 @@ export async function handleStorage(
3057
3129
  headers: {
3058
3130
  "Content-Type": contentType,
3059
3131
  "Content-Length": String(stat.size),
3132
+ // Defense-in-depth: never let a browser MIME-sniff a stored asset into
3133
+ // an active type (e.g. an octet-stream body sniffed as text/html).
3134
+ // Combined with the upload blocklist (no .svg/.html) this closes the
3135
+ // same-origin XSS surface for served attachments. Mirrors routing.ts's
3136
+ // SPA-asset stance.
3137
+ "X-Content-Type-Options": "nosniff",
3060
3138
  },
3061
3139
  });
3062
3140
  }
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Storage upload allowlist tests (issue #127).
2
+ * Storage upload policy tests (issue #127 origin; vault#517 deny-list).
3
3
  *
4
- * The allowlist guards `POST /api/storage/upload` against turning user
5
- * uploads into XSS vectors when the asset is later served back from
6
- * `/storage/`. We pin both the accepted set and the deliberate exclusions
7
- * so a future widening doesn't quietly let SVG/HTML in.
4
+ * `POST /api/storage/upload` accepts ANY file EXCEPT the active-content set a
5
+ * browser can execute when the asset is served back same-origin from
6
+ * `/storage/` (.svg/.html/.htm/.xhtml/.xml/.js/.mjs/.cjs/.css). A knowledge
7
+ * vault stores arbitrary files ebooks, office docs, datasets, archives,
8
+ * binaries — so the long tail (.epub/.csv/.zip/.exe/…) is accepted, not
9
+ * rejected. We pin the accepted breadth AND the deliberate blocklist so a
10
+ * future edit can't quietly let SVG/HTML in (or quietly start rejecting docs).
11
+ * Two guards keep served files inert: every non-curated type serves as
12
+ * application/octet-stream, and the GET serve path pins
13
+ * `X-Content-Type-Options: nosniff`.
8
14
  */
9
15
 
10
16
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
@@ -91,6 +97,43 @@ describe("storage upload allowlist", () => {
91
97
  }
92
98
  });
93
99
 
100
+ test("accepts .epub — the reported gap (ebooks are knowledge content)", async () => {
101
+ const res = await handleStorage(uploadRequest("book.epub", "application/epub+zip"), "/upload", "default", uploadStore);
102
+ expect(res.status).toBe(201);
103
+ const body = (await res.json()) as { mimeType: string; path: string };
104
+ expect(body.mimeType).toBe("application/epub+zip");
105
+ expect(body.path).toMatch(/\.epub$/);
106
+ });
107
+
108
+ test("accepts common document / text / data / archive types", async () => {
109
+ for (const [name, mime, expected] of [
110
+ ["notes.txt", "text/plain", "text/plain; charset=utf-8"],
111
+ ["readme.md", "text/markdown", "text/markdown; charset=utf-8"],
112
+ ["data.csv", "text/csv", "text/csv; charset=utf-8"],
113
+ ["data.json", "application/json", "application/json; charset=utf-8"],
114
+ ["doc.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
115
+ ["archive.zip", "application/zip", "application/zip"],
116
+ ["clip.mov", "video/quicktime", "video/quicktime"],
117
+ ["photo.heic", "image/heic", "image/heic"],
118
+ ] as const) {
119
+ const res = await handleStorage(uploadRequest(name, mime), "/upload", "default", uploadStore);
120
+ expect(res.status).toBe(201);
121
+ const body = (await res.json()) as { mimeType: string };
122
+ expect(body.mimeType).toBe(expected);
123
+ }
124
+ });
125
+
126
+ test("accepts arbitrary / unknown files — served as octet-stream (download), never run", async () => {
127
+ // Deny-list policy (vault#517): anything not in the active-content blocklist
128
+ // is accepted. Unknown/no-MIME extensions serve as octet-stream — a download.
129
+ for (const name of ["deck.pages", "tool.exe", "data.bin", "Makefile", "archive.tar.gz"] as const) {
130
+ const res = await handleStorage(uploadRequest(name, "application/octet-stream"), "/upload", "default", uploadStore);
131
+ expect(res.status).toBe(201);
132
+ const body = (await res.json()) as { mimeType: string };
133
+ expect(body.mimeType).toBe("application/octet-stream");
134
+ }
135
+ });
136
+
94
137
  test("rejects .svg — XSS vector via inline <script> (#127)", async () => {
95
138
  const res = await handleStorage(uploadRequest("evil.svg", "image/svg+xml"), "/upload", "default", uploadStore);
96
139
  expect(res.status).toBe(400);
@@ -105,9 +148,31 @@ describe("storage upload allowlist", () => {
105
148
  expect(body.error).toContain(".html");
106
149
  });
107
150
 
108
- test("rejects unknown extensions (default-deny)", async () => {
109
- const res = await handleStorage(uploadRequest("payload.exe", "application/octet-stream"), "/upload", "default", uploadStore);
110
- expect(res.status).toBe(400);
151
+ test("rejects the active-content set (.js/.mjs/.cjs/.xhtml/.htm/.xml/.css/.shtml)", async () => {
152
+ for (const [name, mime] of [
153
+ ["script.js", "text/javascript"],
154
+ ["mod.mjs", "text/javascript"],
155
+ ["mod.cjs", "text/javascript"],
156
+ ["page.xhtml", "application/xhtml+xml"],
157
+ ["page.xht", "application/xhtml+xml"],
158
+ ["page.htm", "text/html"],
159
+ ["page.shtml", "text/html"],
160
+ ["feed.xml", "application/xml"],
161
+ ["style.css", "text/css"],
162
+ ] as const) {
163
+ const res = await handleStorage(uploadRequest(name, mime), "/upload", "default", uploadStore);
164
+ expect(res.status).toBe(400);
165
+ const body = (await res.json()) as { error: string };
166
+ expect(body.error).toContain("not allowed");
167
+ }
168
+ });
169
+
170
+ test("trailing-dot / trailing-space can't slip a blocked type past the guard", async () => {
171
+ // extname("evil.html.") === "." — normalize the trailing run first.
172
+ for (const name of ["evil.html.", "evil.svg ", "evil.js."] as const) {
173
+ const res = await handleStorage(uploadRequest(name, "text/plain"), "/upload", "default", uploadStore);
174
+ expect(res.status).toBe(400);
175
+ }
111
176
  });
112
177
  });
113
178
 
@@ -166,6 +231,8 @@ describe("storage GET tag-scope enforcement", () => {
166
231
  const res = await handleStorage(getReq(inScopePath), `/${inScopePath}`, VAULT, store, ctx);
167
232
  expect(res.status).toBe(200);
168
233
  expect(res.headers.get("Content-Type")).toBe("application/pdf");
234
+ // Served bytes pin nosniff so a browser can't sniff them into an active type.
235
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
169
236
  expect((await res.arrayBuffer()).byteLength).toBe(4);
170
237
  });
171
238