@usehenri/uploads 0.0.0 → 1.2.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/src/names.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Names.
3
+ *
4
+ * There are two of them and they never meet. The **stored name** is the one
5
+ * the filesystem sees; henri generates it and nothing a client sends takes
6
+ * part in it. The **original name** is metadata: it is cleaned, kept in the
7
+ * record and used for the `Content-Disposition` of a download, and it never
8
+ * reaches a path.
9
+ *
10
+ * That split is the whole answer to a long list of problems that are really
11
+ * one problem -- `../../etc/passwd`, `/etc/passwd`, `C:\boot.ini`,
12
+ * `a\0.png`, `.htaccess`, `CON`, `avatar.php`, a name 4000 characters long,
13
+ * a name that is only dots. None of them can matter, because none of them
14
+ * are consulted when a path is built. They are cleaned anyway, because the
15
+ * original name is shown to people and handed to browsers, and because a
16
+ * value that is only safe when nobody misuses it is not safe.
17
+ */
18
+ const crypto = require('node:crypto');
19
+
20
+ /** How long a cleaned original name may be, when nothing else is configured */
21
+ const MAX_NAME = 255;
22
+
23
+ /** What a name that cleaned down to nothing is called */
24
+ const FALLBACK = 'file';
25
+
26
+ /** Characters that never survive: separators, controls, quotes, wildcards */
27
+ // eslint-disable-next-line no-control-regex
28
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f/\\:*?"<>|]/gu;
29
+
30
+ /**
31
+ * The device names Windows refuses to have a file called, whatever the
32
+ * extension. Reserved before the first dot, so `CON.txt` is one too.
33
+ */
34
+ const RESERVED =
35
+ /^(?:con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])$/iu;
36
+
37
+ /** What a generated key looks like, and the only shape a storage accepts */
38
+ const KEY = /^(?:[0-9a-z][0-9a-z-]{0,63}\/)*[0-9a-f]{32}\.[0-9a-z]{1,8}$/u;
39
+
40
+ /**
41
+ * A name without the leading and trailing whitespace and dots.
42
+ *
43
+ * Scanned rather than matched: `[\s.]+$` is quadratic on a name that is a
44
+ * long run of whitespace followed by anything else, and a filename is
45
+ * whatever the client sent -- a hundred thousand tabs took five seconds
46
+ * here before this was two index walks.
47
+ *
48
+ * @param {string} value the name so far
49
+ * @returns {string} the name without its edges
50
+ */
51
+ function trimmed(value) {
52
+ const strip = (char) => char === '.' || /\s/u.test(char);
53
+ let start = 0;
54
+ let end = value.length;
55
+
56
+ while (start < end && strip(value[start])) {
57
+ start += 1;
58
+ }
59
+
60
+ while (end > start && strip(value[end - 1])) {
61
+ end -= 1;
62
+ }
63
+
64
+ return value.slice(start, end);
65
+ }
66
+
67
+ /**
68
+ * The original name, cleaned: safe to store, to print and to hand a browser
69
+ *
70
+ * @param {*} original what the client called the file
71
+ * @param {number} [max=MAX_NAME] how many characters to keep
72
+ * @returns {string} a name, never empty and never a path
73
+ */
74
+ function safeName(original, max = MAX_NAME) {
75
+ if (typeof original !== 'string') {
76
+ return FALLBACK;
77
+ }
78
+
79
+ // Both separators, because a Windows client sends a Windows path
80
+ const base = original.split(/[/\\]/u).pop() || '';
81
+ const cleaned = trimmed(base.replace(UNSAFE, ''));
82
+
83
+ if (cleaned.length === 0) {
84
+ return FALLBACK;
85
+ }
86
+
87
+ const stem = cleaned.split('.')[0];
88
+ const named = RESERVED.test(stem) ? `_${cleaned}` : cleaned;
89
+
90
+ return named.length > max ? truncate(named, max) : named;
91
+ }
92
+
93
+ /**
94
+ * A name shortened to `max` characters, keeping its extension
95
+ *
96
+ * @param {string} name the name
97
+ * @param {number} max how many characters to keep
98
+ * @returns {string} the shortened name
99
+ */
100
+ function truncate(name, max) {
101
+ const dot = name.lastIndexOf('.');
102
+ const extension = dot > 0 && name.length - dot <= 12 ? name.slice(dot) : '';
103
+
104
+ return `${name.slice(0, Math.max(1, max - extension.length))}${extension}`;
105
+ }
106
+
107
+ /**
108
+ * The name a stored object is given.
109
+ *
110
+ * `<yyyy>/<mm>/<32 hex characters>.<extension>`: the date so a directory
111
+ * never grows without end and a retention rule has something to read, 128
112
+ * bits of randomness so a key is never guessed from another one, and an
113
+ * extension that comes from the type the *bytes* were recognized as -- never
114
+ * from the name the client sent.
115
+ *
116
+ * @param {object} options the options
117
+ * @param {string} options.extension the extension, without its dot
118
+ * @param {?string} [options.prefix] a directory to put it under
119
+ * @param {Date} [options.now] the moment, for the dated directories
120
+ * @returns {string} the key
121
+ */
122
+ function keyFor({ extension, now = new Date(), prefix = null }) {
123
+ const year = String(now.getUTCFullYear());
124
+ const month = String(now.getUTCMonth() + 1).padStart(2, '0');
125
+ const random = crypto.randomBytes(16).toString('hex');
126
+ const safe = safePrefix(prefix);
127
+ const directory = safe ? `${safe}/${year}/${month}` : `${year}/${month}`;
128
+
129
+ return `${directory}/${random}.${extension}`;
130
+ }
131
+
132
+ /**
133
+ * A prefix an application asked for, reduced to what a key may hold
134
+ *
135
+ * @param {*} prefix what the application passed to `store()`
136
+ * @returns {?string} the prefix, or null when there is nothing usable left
137
+ */
138
+ function safePrefix(prefix) {
139
+ if (typeof prefix !== 'string') {
140
+ return null;
141
+ }
142
+
143
+ const segments = prefix
144
+ .toLowerCase()
145
+ .split('/')
146
+ .map((segment) => segment.replace(/[^0-9a-z-]/gu, ''))
147
+ .filter((segment) => segment.length > 0 && segment.length <= 64);
148
+
149
+ return segments.length > 0 ? segments.slice(0, 4).join('/') : null;
150
+ }
151
+
152
+ /**
153
+ * Is this a key henri generated? (a storage refuses anything else)
154
+ *
155
+ * @param {*} key the key
156
+ * @returns {boolean} true when it is safe to build a path from
157
+ */
158
+ const isKey = (key) =>
159
+ typeof key === 'string' &&
160
+ key.length <= 512 &&
161
+ !key.includes('..') &&
162
+ KEY.test(key);
163
+
164
+ /**
165
+ * The two forms of a filename a `Content-Disposition` header needs: the
166
+ * ASCII one every client understands, and the percent-encoded UTF-8 one
167
+ * (RFC 5987) for the rest of the alphabet
168
+ *
169
+ * @param {string} name a name from `safeName()`
170
+ * @param {string} [disposition='attachment'] `attachment` or `inline`
171
+ * @returns {string} the header value
172
+ */
173
+ function contentDisposition(name, disposition = 'attachment') {
174
+ const safe = safeName(name);
175
+ const ascii = safe.replace(/[^\u0020-\u007e]/gu, '_').replace(/["\\]/gu, '_');
176
+ const encoded = encodeURIComponent(safe);
177
+
178
+ return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
179
+ }
180
+
181
+ module.exports = {
182
+ FALLBACK,
183
+ KEY,
184
+ MAX_NAME,
185
+ RESERVED,
186
+ contentDisposition,
187
+ isKey,
188
+ keyFor,
189
+ safeName,
190
+ safePrefix,
191
+ };
package/src/signing.js ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * The signed urls henri makes itself, for a storage that has none of its own.
3
+ *
4
+ * ---------------------------------------------------------------------------
5
+ * What this is for
6
+ * ---------------------------------------------------------------------------
7
+ *
8
+ * An object store signs its own urls (`@usehenri/s3` presigns with SigV4),
9
+ * and that is always the better answer: the bytes never touch the
10
+ * application. The local disk cannot do that -- there is nobody else to hand
11
+ * the file to -- so `LocalStorage#url()` used to answer `null`, and an
12
+ * application that wanted a link had to write a controller, an
13
+ * authorization check and a route for every file it showed.
14
+ *
15
+ * `null` is not an answer, it is a hole. So henri signs the url itself: the
16
+ * same call, the same expiry, the same `{ expiresIn, disposition, filename,
17
+ * type }`, and a route that verifies the signature and streams the file.
18
+ * What changes between the two backends is who checks the signature, not
19
+ * what an application writes.
20
+ *
21
+ * ---------------------------------------------------------------------------
22
+ * What the signature covers, and what that buys
23
+ * ---------------------------------------------------------------------------
24
+ *
25
+ * One HMAC-SHA256 over a canonical string of six fields, under a key derived
26
+ * from `config.secret` (HKDF-SHA256, one label of its own, the way
27
+ * `@usehenri/webhooks` derives its sealing key):
28
+ *
29
+ * ```
30
+ * henri.uploads.url.v1
31
+ * <key>
32
+ * <expires, epoch seconds>
33
+ * <attachment|inline>
34
+ * <download name>
35
+ * <media type>
36
+ * ```
37
+ *
38
+ * - **It cannot be edited to name another object.** The key is the third
39
+ * line of the string and the path of the url; changing one without the
40
+ * other is a signature of a different string, and producing the right one
41
+ * needs the key, which never leaves the server. The storage refuses
42
+ * anything that is not shaped like a generated key on top of that, so even
43
+ * a forgery would have to name a real one.
44
+ * - **It cannot be replayed after it expires.** `expires` is inside the
45
+ * signed string, so widening the window invalidates the signature, and the
46
+ * verifier compares it with its own clock -- not with anything the client
47
+ * sent. What a signed url *is*, before it expires, is a bearer capability:
48
+ * whoever holds the link holds the file. That is the whole idea, it is
49
+ * what a presigned S3 url is too, and the expiry is the bound. The guide
50
+ * says so in those words.
51
+ * - **It cannot be turned into a page.** The disposition, the download name
52
+ * and the media type are signed as well, so a link to a download cannot be
53
+ * edited into `inline` `text/html` on the application's own origin. The
54
+ * two scriptable types henri recognizes are refused `inline` at signing
55
+ * time, which is the same rule that stores them under `.bin`.
56
+ *
57
+ * The host is deliberately **not** in the signature, which is what lets
58
+ * `uploads.urls.cdn` put a cache in front of the route: a CDN that forwards
59
+ * the path and the query forwards everything the signature is of. A
60
+ * provider-signed url is the opposite -- SigV4 covers the host -- which is
61
+ * why the two have different keys, and why the guide says which is which.
62
+ *
63
+ * Nothing is stored: there is no table of urls, no revocation list and no
64
+ * way to invalidate one link. Rotating `config.secret` invalidates all of
65
+ * them at once, and shortening `expiresIn` bounds the next ones.
66
+ */
67
+ const crypto = require('node:crypto');
68
+
69
+ const { isKey, safeName } = require('./names');
70
+
71
+ /** The label the url key is derived under */
72
+ const LABEL = 'henri.uploads.url.v1';
73
+
74
+ /** The first line of every canonical string, so a v2 is a different string */
75
+ const VERSION = 'henri.uploads.url.v1';
76
+
77
+ /** The bytes of the derived key */
78
+ const KEY_BYTES = 32;
79
+
80
+ /** How long a signed url lasts when nobody said (seconds) */
81
+ const EXPIRES_IN = 300;
82
+
83
+ /** The longest window one may be given, matching what S3 honours (seconds) */
84
+ const MAX_EXPIRES = 7 * 24 * 60 * 60;
85
+
86
+ /** Where the route that verifies these is mounted, unless it is moved */
87
+ const PATH = '/_uploads';
88
+
89
+ /** What a disposition may be */
90
+ const DISPOSITIONS = new Set(['attachment', 'inline']);
91
+
92
+ /**
93
+ * The types that are never served `inline`.
94
+ *
95
+ * The same two `sniff.js` stores under a `.bin` extension, for the same
96
+ * reason: they are text formats that carry script, and rendering one on the
97
+ * application's own origin is the whole attack. A signed url may still hand
98
+ * one back -- as a download, which is what an `attachment` is.
99
+ */
100
+ const SCRIPTABLE = new Set(['image/svg+xml', 'text/html']);
101
+
102
+ /**
103
+ * The key henri signs urls with
104
+ *
105
+ * @param {?string} secret `config.secret`
106
+ * @returns {?Buffer} the key, or null without a secret
107
+ */
108
+ const keyOf = (secret) =>
109
+ secret
110
+ ? Buffer.from(
111
+ crypto.hkdfSync(
112
+ 'sha256',
113
+ String(secret),
114
+ 'henri.uploads',
115
+ LABEL,
116
+ KEY_BYTES
117
+ )
118
+ )
119
+ : null;
120
+
121
+ /**
122
+ * The string a signature is of.
123
+ *
124
+ * Every field is on a line of its own and none of them may hold a newline
125
+ * (the key cannot, a disposition is one of two words, and the name and the
126
+ * type are cleaned), so no two different sets of fields build the same
127
+ * string.
128
+ *
129
+ * @param {object} claims `{ key, expires, disposition, filename, type }`
130
+ * @returns {string} the canonical string
131
+ */
132
+ const canonical = ({ disposition, expires, filename, key, type }) =>
133
+ [VERSION, key, String(expires), disposition, filename || '', type || ''].join(
134
+ '\n'
135
+ );
136
+
137
+ /**
138
+ * The signature of a set of claims
139
+ *
140
+ * @param {Buffer} key the derived key
141
+ * @param {object} claims what is being signed
142
+ * @returns {string} the signature, base64url
143
+ */
144
+ const signature = (key, claims) =>
145
+ crypto
146
+ .createHmac('sha256', key)
147
+ .update(canonical(claims), 'utf8')
148
+ .digest('base64url');
149
+
150
+ /**
151
+ * A media type, or null: anything that is not one is not signed as one
152
+ *
153
+ * @param {*} value what was asked for
154
+ * @returns {?string} the type
155
+ */
156
+ const typeOf = (value) =>
157
+ typeof value === 'string' && /^[\w.+-]+\/[\w.+-]+$/u.test(value)
158
+ ? value.toLowerCase()
159
+ : null;
160
+
161
+ /**
162
+ * A base url without its trailing slashes.
163
+ *
164
+ * Walked rather than matched, for the reason `names.js` gives about the
165
+ * filename cleaner: `/\/+$/` is quadratic on a run of slashes, and while
166
+ * this value comes from the configuration rather than from a request, a
167
+ * reader should not have to work out which one it is to know it is safe.
168
+ *
169
+ * @param {*} value the configured base url
170
+ * @returns {string} the same, with no trailing slash
171
+ */
172
+ function withoutTrailingSlashes(value) {
173
+ const text = String(value);
174
+ let end = text.length;
175
+
176
+ while (end > 0 && text[end - 1] === '/') {
177
+ end -= 1;
178
+ }
179
+
180
+ return text.slice(0, end);
181
+ }
182
+
183
+ /**
184
+ * Signs urls, and verifies the ones it signed
185
+ *
186
+ * @class UrlSigner
187
+ */
188
+ class UrlSigner {
189
+ /**
190
+ * Creates an instance of UrlSigner.
191
+ *
192
+ * @param {object} [options={}] `{ secret, expiresIn, path, cdn }`
193
+ * @memberof UrlSigner
194
+ */
195
+ constructor(options = {}) {
196
+ this.key = keyOf(options.secret);
197
+ this.expiresIn = options.expiresIn || EXPIRES_IN;
198
+ this.path = options.path || PATH;
199
+ this.cdn = options.cdn ? withoutTrailingSlashes(options.cdn) : '';
200
+ }
201
+
202
+ /**
203
+ * Whether this signer can sign anything at all
204
+ *
205
+ * @returns {boolean} true when there is a key
206
+ * @memberof UrlSigner
207
+ */
208
+ get usable() {
209
+ return Boolean(this.key);
210
+ }
211
+
212
+ /**
213
+ * A signed url for one object
214
+ *
215
+ * @param {string} key the storage key
216
+ * @param {object} [options={}] `{ expiresIn, disposition, filename, type, now }`
217
+ * @returns {string} the url
218
+ * @throws {RangeError} on a window nothing would honour
219
+ * @throws {Error} on a key henri did not generate, or an inline script
220
+ * @memberof UrlSigner
221
+ */
222
+ sign(key, options = {}) {
223
+ if (!isKey(key)) {
224
+ throw new Error(`unsafe storage key: ${JSON.stringify(String(key))}`);
225
+ }
226
+
227
+ // `undefined` means "whatever the configuration says"; every other value
228
+ // is a window somebody asked for, `0` included, and `0` is a mistake
229
+ const asked =
230
+ options.expiresIn === undefined || options.expiresIn === null
231
+ ? this.expiresIn
232
+ : options.expiresIn;
233
+ const seconds = Math.floor(Number(asked));
234
+
235
+ if (!Number.isFinite(seconds) || seconds < 1 || seconds > MAX_EXPIRES) {
236
+ throw new RangeError(
237
+ `a signed url lasts between 1 and ${MAX_EXPIRES} seconds, not ${asked}`
238
+ );
239
+ }
240
+
241
+ const disposition = DISPOSITIONS.has(options.disposition)
242
+ ? options.disposition
243
+ : 'attachment';
244
+ const type = typeOf(options.type);
245
+
246
+ if (disposition === 'inline' && SCRIPTABLE.has(type)) {
247
+ throw new Error(
248
+ `${type} is never served inline: it would run on this application's own origin`
249
+ );
250
+ }
251
+
252
+ const now = options.now ? options.now.getTime() : Date.now();
253
+ const claims = {
254
+ disposition,
255
+ expires: Math.floor(now / 1000) + seconds,
256
+ filename: options.filename ? safeName(options.filename) : '',
257
+ key,
258
+ type,
259
+ };
260
+ const query = new URLSearchParams({
261
+ disposition,
262
+ expires: String(claims.expires),
263
+ });
264
+
265
+ claims.filename && query.set('name', claims.filename);
266
+ type && query.set('type', type);
267
+ query.set('signature', signature(this.key, claims));
268
+
269
+ return `${this.cdn}${this.path}/${key}?${query.toString()}`;
270
+ }
271
+
272
+ /**
273
+ * What a url that verifies says, or why it does not.
274
+ *
275
+ * The signature is checked **before** the expiry on purpose: an expired
276
+ * link is then only ever reported to somebody holding a link henri really
277
+ * signed, and everything else is one answer. The expiry is in the url in
278
+ * plain sight, so saying "this has expired" tells a legitimate visitor
279
+ * something useful and an attacker nothing.
280
+ *
281
+ * @param {string} key the storage key, from the path
282
+ * @param {URLSearchParams} query the query of the url
283
+ * @param {Date} [now=new Date()] the moment
284
+ * @returns {{ok: boolean, reason: ?string, claims: ?object}} the verdict
285
+ * @memberof UrlSigner
286
+ */
287
+ verify(key, query, now = new Date()) {
288
+ const given = query.get('signature') || '';
289
+ const expires = Number(query.get('expires'));
290
+
291
+ if (!this.key || !isKey(key) || !Number.isInteger(expires)) {
292
+ return { claims: null, ok: false, reason: 'invalid' };
293
+ }
294
+
295
+ const claims = {
296
+ disposition: query.get('disposition') || '',
297
+ expires,
298
+ filename: query.get('name') || '',
299
+ key,
300
+ type: query.get('type') || null,
301
+ };
302
+ const wanted = Buffer.from(signature(this.key, claims), 'utf8');
303
+ const received = Buffer.from(given, 'utf8');
304
+
305
+ if (
306
+ wanted.length !== received.length ||
307
+ !crypto.timingSafeEqual(wanted, received)
308
+ ) {
309
+ return { claims: null, ok: false, reason: 'invalid' };
310
+ }
311
+
312
+ if (!DISPOSITIONS.has(claims.disposition)) {
313
+ return { claims: null, ok: false, reason: 'invalid' };
314
+ }
315
+
316
+ return now.getTime() / 1000 > expires
317
+ ? { claims, ok: false, reason: 'expired' }
318
+ : { claims, ok: true, reason: null };
319
+ }
320
+ }
321
+
322
+ module.exports = {
323
+ DISPOSITIONS,
324
+ EXPIRES_IN,
325
+ LABEL,
326
+ MAX_EXPIRES,
327
+ PATH,
328
+ SCRIPTABLE,
329
+ UrlSigner,
330
+ canonical,
331
+ keyOf,
332
+ typeOf,
333
+ };