partial-content 1.0.0
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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +601 -0
- package/SECURITY.md +92 -0
- package/dist/azure.d.ts +79 -0
- package/dist/azure.d.ts.map +1 -0
- package/dist/azure.js +251 -0
- package/dist/azure.js.map +1 -0
- package/dist/content-disposition.d.ts +74 -0
- package/dist/content-disposition.d.ts.map +1 -0
- package/dist/content-disposition.js +253 -0
- package/dist/content-disposition.js.map +1 -0
- package/dist/fs.d.ts +86 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +375 -0
- package/dist/fs.js.map +1 -0
- package/dist/gcs.d.ts +72 -0
- package/dist/gcs.d.ts.map +1 -0
- package/dist/gcs.js +202 -0
- package/dist/gcs.js.map +1 -0
- package/dist/hono.d.ts +92 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +61 -0
- package/dist/hono.js.map +1 -0
- package/dist/http.d.ts +70 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +281 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel.d.ts +541 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1218 -0
- package/dist/kernel.js.map +1 -0
- package/dist/memory.d.ts +55 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +107 -0
- package/dist/memory.js.map +1 -0
- package/dist/mime.d.ts +49 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +150 -0
- package/dist/mime.js.map +1 -0
- package/dist/node.d.ts +84 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +215 -0
- package/dist/node.js.map +1 -0
- package/dist/object-store.d.ts +472 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +335 -0
- package/dist/object-store.js.map +1 -0
- package/dist/r2.d.ts +94 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +150 -0
- package/dist/r2.js.map +1 -0
- package/dist/s3.d.ts +49 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +263 -0
- package/dist/s3.js.map +1 -0
- package/dist/web.d.ts +336 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +1094 -0
- package/dist/web.js.map +1 -0
- package/docs/DESIGN.md +426 -0
- package/package.json +182 -0
- package/src/azure.ts +329 -0
- package/src/content-disposition.ts +300 -0
- package/src/fs.ts +469 -0
- package/src/gcs.ts +290 -0
- package/src/hono.ts +123 -0
- package/src/http.ts +351 -0
- package/src/index.ts +85 -0
- package/src/kernel.ts +1498 -0
- package/src/memory.ts +148 -0
- package/src/mime.ts +160 -0
- package/src/node.ts +261 -0
- package/src/object-store.ts +665 -0
- package/src/r2.ts +232 -0
- package/src/s3.ts +324 -0
- package/src/web.ts +1603 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Disposition header builder (RFC 6266 + RFC 8187).
|
|
3
|
+
*
|
|
4
|
+
* Builds Content-Disposition headers for HTTP responses that serve files.
|
|
5
|
+
* Handles both inline (in-app preview) and attachment (download) dispositions
|
|
6
|
+
* with proper RFC compliance for non-ASCII filenames.
|
|
7
|
+
*
|
|
8
|
+
* Security: Prevents CRLF header injection, path traversal, bidi override
|
|
9
|
+
* spoofing, and control character injection in filenames from untrusted
|
|
10
|
+
* sources (integration APIs, user uploads).
|
|
11
|
+
*
|
|
12
|
+
* Emits dual filename parameters for cross-browser compatibility:
|
|
13
|
+
* - `filename="ascii-safe.pdf"` for legacy browsers
|
|
14
|
+
* - `filename*=UTF-8''%C3%85rlig.pdf` for modern browsers (RFC 8187)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface ContentDispositionOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Disposition type.
|
|
22
|
+
* - `"attachment"`: Force download (safe default for untrusted content)
|
|
23
|
+
* - `"inline"`: Render in browser (only for previewable types: PDF, image, video, audio)
|
|
24
|
+
* @default "attachment"
|
|
25
|
+
*/
|
|
26
|
+
type?: "attachment" | "inline";
|
|
27
|
+
/**
|
|
28
|
+
* Fallback filename when input is null, undefined, or empty after sanitization.
|
|
29
|
+
* @default "document"
|
|
30
|
+
*/
|
|
31
|
+
fallback?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* RFC 2616 token characters. A filename that matches this pattern can be
|
|
38
|
+
* emitted unquoted in the `filename=` parameter (e.g. `filename=plans.pdf`).
|
|
39
|
+
* Anything outside this set requires quoted-string encoding.
|
|
40
|
+
*
|
|
41
|
+
* token = 1*<any CHAR except CTLs or separators>
|
|
42
|
+
* separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <">
|
|
43
|
+
* | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT
|
|
44
|
+
*/
|
|
45
|
+
const TOKEN_REGEXP = /^[!#$%&'*+.0-9A-Z^_`a-z|~-]+$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Characters that are valid US-ASCII text (printable range 0x20-0x7E).
|
|
49
|
+
* Filenames matching this can use the simple `filename=` parameter with
|
|
50
|
+
* quoted-string encoding. Non-ASCII filenames need `filename*=` (RFC 8187).
|
|
51
|
+
*/
|
|
52
|
+
const ASCII_TEXT_REGEXP = /^[\x20-\x7e]*$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Characters that need escaping inside an RFC 2616 quoted-string.
|
|
56
|
+
* Per Section 2.2: quoted-pair = "\" CHAR, and the only characters
|
|
57
|
+
* that MUST be escaped are `\` and `"`.
|
|
58
|
+
*/
|
|
59
|
+
const QUOTED_PAIR_REGEXP = /[\\"]/g;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Characters that are not valid in RFC 8187 attr-char, applied AFTER
|
|
63
|
+
* encodeURIComponent (so `%` is already handled). This catches characters
|
|
64
|
+
* that encodeURIComponent leaves unescaped but RFC 8187 requires encoded.
|
|
65
|
+
*/
|
|
66
|
+
const ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g; // eslint-disable-line no-control-regex
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Non-US-ASCII characters. Replaced with `?` in the ASCII fallback
|
|
70
|
+
* for maximum compatibility with legacy HTTP clients.
|
|
71
|
+
*/
|
|
72
|
+
const NON_ASCII_REGEXP = /[^\x20-\x7e]/g;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Filenames containing percent-encoded sequences (like `the%20plans.pdf`)
|
|
76
|
+
* should not use the simple `filename=` parameter because legacy clients
|
|
77
|
+
* may decode them. Force `filename*` for these.
|
|
78
|
+
*/
|
|
79
|
+
const HEX_ESCAPE_REGEXP = /%[0-9A-Fa-f]{2}/;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Matches lone surrogate code units (unpaired high or low surrogates).
|
|
83
|
+
* These occur when filenames are truncated mid-emoji with .slice().
|
|
84
|
+
* encodeURIComponent throws URIError on lone surrogates, so we must
|
|
85
|
+
* strip them before encoding.
|
|
86
|
+
*/
|
|
87
|
+
const LONE_SURROGATE_REGEXP = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
88
|
+
|
|
89
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a Content-Disposition header value with RFC compliance and
|
|
93
|
+
* security sanitization.
|
|
94
|
+
*
|
|
95
|
+
* Accepts untrusted filenames from integration APIs, user uploads, and
|
|
96
|
+
* database records. Sanitizes for:
|
|
97
|
+
* - CRLF injection (strips \r, \n, and all control characters)
|
|
98
|
+
* - Path traversal (strips ../ and ..\\ sequences)
|
|
99
|
+
* - Basename extraction (strips directory paths)
|
|
100
|
+
* - Bidi override stripping (prevents RLO filename spoofing)
|
|
101
|
+
* - Double-quote and backslash escaping (RFC 2616 quoted-pair)
|
|
102
|
+
*
|
|
103
|
+
* Emits dual filename parameters for cross-browser compatibility:
|
|
104
|
+
* - `filename="ascii-safe.pdf"` for legacy browsers (IE, old Safari)
|
|
105
|
+
* - `filename*=UTF-8''%C3%85rlig.pdf` for modern browsers (RFC 8187)
|
|
106
|
+
*
|
|
107
|
+
* Token optimization: Simple ASCII filenames like `plans.pdf` are emitted
|
|
108
|
+
* unquoted (`filename=plans.pdf`).
|
|
109
|
+
*
|
|
110
|
+
* @param filename - Raw filename from untrusted source (upload, API, DB)
|
|
111
|
+
* @param options - Disposition type and fallback configuration
|
|
112
|
+
* @returns Complete Content-Disposition header value string
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* // Simple ASCII attachment
|
|
116
|
+
* buildContentDisposition("report.pdf")
|
|
117
|
+
* // => 'attachment; filename=report.pdf'
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* // Non-ASCII filename (Danish)
|
|
121
|
+
* buildContentDisposition("Årlig_Rapport.pdf")
|
|
122
|
+
* // => 'attachment; filename="?rlig_Rapport.pdf"; filename*=UTF-8\'\'%C3%85rlig_Rapport.pdf'
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* // Inline disposition for preview
|
|
126
|
+
* buildContentDisposition("slides.pdf", { type: "inline" })
|
|
127
|
+
* // => 'inline; filename=slides.pdf'
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* // Null input with custom fallback
|
|
131
|
+
* buildContentDisposition(null, { fallback: "export.csv" })
|
|
132
|
+
* // => 'attachment; filename=export.csv'
|
|
133
|
+
*/
|
|
134
|
+
export function buildContentDisposition(
|
|
135
|
+
filename: string | null | undefined,
|
|
136
|
+
options?: ContentDispositionOptions,
|
|
137
|
+
): string {
|
|
138
|
+
// Never trust the disposition type: a JS caller (or a mistyped
|
|
139
|
+
// `disposition` extractor returning a computed string) could otherwise
|
|
140
|
+
// smuggle header parameters through it. Anything but "inline" is
|
|
141
|
+
// "attachment" -- the safe default.
|
|
142
|
+
const type = options?.type === "inline" ? "inline" : "attachment";
|
|
143
|
+
|
|
144
|
+
// Sanitize BOTH the untrusted filename and the fallback through the same
|
|
145
|
+
// cleaner, then fall back to a constant. A filename that reduces to
|
|
146
|
+
// nothing (only controls/bidi/path components) must NOT emit a raw
|
|
147
|
+
// fallback into filename* -- that path leaked un-neutralized bidi
|
|
148
|
+
// overrides, defeating the anti-spoofing guarantee.
|
|
149
|
+
const sanitized =
|
|
150
|
+
sanitizeFilename(filename ?? "")
|
|
151
|
+
|| sanitizeFilename(options?.fallback ?? "")
|
|
152
|
+
|| "document";
|
|
153
|
+
|
|
154
|
+
return `${type}${buildFilenameParams(sanitized)}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sanitize an untrusted filename for safe use in HTTP headers.
|
|
161
|
+
*
|
|
162
|
+
* Applied BEFORE any RFC encoding. This is the security layer that
|
|
163
|
+
* prevents header injection and path traversal attacks.
|
|
164
|
+
*/
|
|
165
|
+
function sanitizeFilename(raw: string): string {
|
|
166
|
+
// 1. Strip control characters and Unicode formatting controls.
|
|
167
|
+
// C0 controls (\x00-\x1F), DEL (\x7F): prevents CRLF header injection.
|
|
168
|
+
// C1 controls (\x80-\x9F): invisible formatting, no valid use in filenames.
|
|
169
|
+
// Bidi overrides (U+202A-202E, U+2066-2069): prevents RLO filename
|
|
170
|
+
// spoofing where "report\u202Efdp.exe" renders as "reportexe.pdf".
|
|
171
|
+
// Zero-width chars (U+200B-200F): invisible joiners/marks, strips
|
|
172
|
+
// directional marks that could confuse filename display.
|
|
173
|
+
// NBSP (U+00A0): often mistaken for regular space, normalize away.
|
|
174
|
+
const noControls = raw.replace(
|
|
175
|
+
/[\r\n\x00-\x1F\x7F\x80-\x9F\u00A0\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g,
|
|
176
|
+
"",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// 2. Strip path traversal sequences (../ and ..\).
|
|
180
|
+
// Some integration APIs return full paths or relative paths.
|
|
181
|
+
// Even though HTTP clients shouldn't interpret these, defense-in-depth.
|
|
182
|
+
const noTraversal = noControls.replace(/\.\.[/\\]/g, "");
|
|
183
|
+
|
|
184
|
+
// 3. Extract basename (strip directory components).
|
|
185
|
+
// Integration APIs sometimes return full paths like "/uploads/2024/report.pdf".
|
|
186
|
+
// Uses lastIndexOf instead of split().pop() to avoid array allocation.
|
|
187
|
+
const lastSep = Math.max(noTraversal.lastIndexOf("/"), noTraversal.lastIndexOf("\\"));
|
|
188
|
+
const basename = lastSep >= 0 ? noTraversal.slice(lastSep + 1) : noTraversal;
|
|
189
|
+
|
|
190
|
+
// May be empty: the caller composes the fallback chain so both the
|
|
191
|
+
// filename and the fallback pass through this same sanitizer.
|
|
192
|
+
return basename;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Build the filename parameter string for Content-Disposition.
|
|
197
|
+
*
|
|
198
|
+
* Encoding strategy:
|
|
199
|
+
* 1. Pure ASCII token (no separators): emit unquoted `filename=report.pdf`
|
|
200
|
+
* 2. ASCII with special chars: emit quoted `filename="my report.pdf"`
|
|
201
|
+
* 3. Non-ASCII: emit both `filename="?rlig.pdf"` and `filename*=UTF-8''...`
|
|
202
|
+
*
|
|
203
|
+
* RFC 2616 Section 2.2 quoted-string escaping:
|
|
204
|
+
* - `\` -> `\\` (backslash must be escaped)
|
|
205
|
+
* - `"` -> `\"` (double-quote must be escaped)
|
|
206
|
+
*/
|
|
207
|
+
function buildFilenameParams(filename: string): string {
|
|
208
|
+
// Case 1: Pure ASCII that fits in a token -- emit unquoted.
|
|
209
|
+
// Also reject filenames with hex-encoded sequences (%20) which could
|
|
210
|
+
// be decoded by legacy clients.
|
|
211
|
+
if (ASCII_TEXT_REGEXP.test(filename) && !HEX_ESCAPE_REGEXP.test(filename)) {
|
|
212
|
+
if (TOKEN_REGEXP.test(filename)) {
|
|
213
|
+
return `; filename=${filename}`;
|
|
214
|
+
}
|
|
215
|
+
// ASCII but contains separators/spaces -- use quoted-string
|
|
216
|
+
return `; filename=${quoteString(filename)}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Case 2: Contains non-ASCII characters or hex escapes.
|
|
220
|
+
// Emit both parameters for cross-browser compatibility:
|
|
221
|
+
// - filename="ascii-fallback" for legacy clients
|
|
222
|
+
// - filename*=UTF-8''percent-encoded for modern clients (RFC 8187)
|
|
223
|
+
const asciiFallback = toAsciiFallback(filename);
|
|
224
|
+
const encoded = encodeRfc8187(filename);
|
|
225
|
+
|
|
226
|
+
// When filename has hex escapes but is pure ASCII, we still need
|
|
227
|
+
// filename* because the hex escape could be decoded by legacy clients.
|
|
228
|
+
// Percent-encode % in the fallback to prevent unintended decoding.
|
|
229
|
+
const safeFallback = asciiFallback.replace(/%/g, "%25");
|
|
230
|
+
if (TOKEN_REGEXP.test(safeFallback)) {
|
|
231
|
+
return `; filename=${safeFallback}; filename*=${encoded}`;
|
|
232
|
+
}
|
|
233
|
+
return `; filename=${quoteString(safeFallback)}; filename*=${encoded}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Quote a string per RFC 2616 Section 2.2.
|
|
238
|
+
*
|
|
239
|
+
* quoted-string = ( <"> *(qdtext | quoted-pair) <"> )
|
|
240
|
+
* quoted-pair = "\" CHAR
|
|
241
|
+
*
|
|
242
|
+
* Both `\` and `"` MUST be escaped with a preceding backslash.
|
|
243
|
+
*
|
|
244
|
+
* In practice, `\` is stripped by sanitizeFilename (treated as path separator),
|
|
245
|
+
* so only `"` escaping is exercised through the normal code path. The `\`
|
|
246
|
+
* escaping is defense-in-depth for correctness if sanitization ever changes.
|
|
247
|
+
*/
|
|
248
|
+
function quoteString(str: string): string {
|
|
249
|
+
return `"${str.replace(QUOTED_PAIR_REGEXP, "\\$&")}"`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Generate a US-ASCII fallback for non-ASCII filenames.
|
|
254
|
+
*
|
|
255
|
+
* Replaces non-ASCII characters with `?`. This is more honest than `_` --
|
|
256
|
+
* it signals to the user that characters were lost in translation, rather
|
|
257
|
+
* than suggesting underscores were in the original.
|
|
258
|
+
*/
|
|
259
|
+
function toAsciiFallback(filename: string): string {
|
|
260
|
+
return filename.replace(NON_ASCII_REGEXP, "?");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Encode a string per RFC 8187 (formerly RFC 5987).
|
|
265
|
+
*
|
|
266
|
+
* Format: charset'language'value-chars
|
|
267
|
+
* We always use UTF-8 and leave language empty.
|
|
268
|
+
*
|
|
269
|
+
* The encoding differs from encodeURIComponent in that RFC 8187
|
|
270
|
+
* defines its own set of "attr-char" that don't need encoding.
|
|
271
|
+
* We first apply encodeURIComponent (which handles all non-ASCII),
|
|
272
|
+
* then encode the characters that encodeURIComponent leaves unescaped
|
|
273
|
+
* but RFC 8187 requires encoded (including single quotes, which are
|
|
274
|
+
* the charset/language delimiter in the RFC 8187 format).
|
|
275
|
+
*/
|
|
276
|
+
function encodeRfc8187(str: string): string {
|
|
277
|
+
// encodeURIComponent itself throws URIError on lone surrogates
|
|
278
|
+
// (ECMA-262), so the exception IS the surrogate detector: clean strings
|
|
279
|
+
// (the overwhelming majority, including valid emoji pairs) pay no
|
|
280
|
+
// pre-scan at all. Lone surrogates only occur when upstream code
|
|
281
|
+
// truncates filenames mid-emoji with .slice() on UTF-16 code units;
|
|
282
|
+
// that pathological path eats the exception cost and retries stripped.
|
|
283
|
+
// Benchmarked against a surrogate-range pre-scan regex: this is ~6x
|
|
284
|
+
// faster for emoji filenames (a pre-scan cannot tell valid pairs from
|
|
285
|
+
// lone surrogates, so it triggered the strip for every emoji).
|
|
286
|
+
let encoded: string;
|
|
287
|
+
try {
|
|
288
|
+
encoded = encodeURIComponent(str);
|
|
289
|
+
} catch {
|
|
290
|
+
encoded = encodeURIComponent(str.replace(LONE_SURROGATE_REGEXP, ""));
|
|
291
|
+
}
|
|
292
|
+
encoded = encoded.replace(ENCODE_URL_ATTR_CHAR_REGEXP, percentEncode);
|
|
293
|
+
|
|
294
|
+
return `UTF-8''${encoded}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Percent-encode a single character. */
|
|
298
|
+
function percentEncode(char: string): string {
|
|
299
|
+
return "%" + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0");
|
|
300
|
+
}
|