@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/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +42 -0
- package/module.js +8 -0
- package/package.json +56 -10
- package/src/bytes.js +86 -0
- package/src/config.js +243 -0
- package/src/download.js +126 -0
- package/src/errors.js +92 -0
- package/src/file.js +204 -0
- package/src/module.js +694 -0
- package/src/multipart.js +696 -0
- package/src/names.js +191 -0
- package/src/signing.js +333 -0
- package/src/sniff.js +292 -0
- package/src/storage/index.js +125 -0
- package/src/storage/local.js +345 -0
- package/src/variants.js +392 -0
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
|
+
};
|