@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 +1 -1
- package/src/routes.ts +93 -15
- package/src/storage.test.ts +75 -8
package/package.json
CHANGED
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
|
|
2912
|
-
//
|
|
2913
|
-
//
|
|
2914
|
-
//
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
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
|
-
".
|
|
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
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
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
|
}
|
package/src/storage.test.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storage upload
|
|
2
|
+
* Storage upload policy tests (issue #127 origin; vault#517 deny-list).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `/storage
|
|
7
|
-
*
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|