@aria-framework/kit 0.3.1 → 0.5.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/fileSniff.js +210 -117
- package/package.json +1 -1
package/fileSniff.js
CHANGED
|
@@ -1,117 +1,210 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Magic-byte file-type validation for uploads.
|
|
3
|
-
*
|
|
4
|
-
* multer's `file.mimetype` is the client-declared Content-Type — attacker
|
|
5
|
-
* controlled. This module reads the actual leading bytes and checks they are
|
|
6
|
-
* consistent with the declared MIME's container family, so an HTML file
|
|
7
|
-
* labelled `image/png` is rejected. Office formats can't be distinguished from
|
|
8
|
-
* each other by magic alone (docx/xlsx are both ZIP; doc/xls are both OLE), so
|
|
9
|
-
* we validate the container family, not the exact subtype.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (hex.startsWith('
|
|
44
|
-
|
|
45
|
-
if (hex.startsWith('
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
'
|
|
89
|
-
'
|
|
90
|
-
'
|
|
91
|
-
'
|
|
92
|
-
'
|
|
93
|
-
'
|
|
94
|
-
'
|
|
95
|
-
|
|
96
|
-
'application/
|
|
97
|
-
'application/
|
|
98
|
-
|
|
99
|
-
'
|
|
100
|
-
'
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Magic-byte file-type validation for uploads.
|
|
3
|
+
*
|
|
4
|
+
* multer's `file.mimetype` is the client-declared Content-Type — attacker
|
|
5
|
+
* controlled. This module reads the actual leading bytes and checks they are
|
|
6
|
+
* consistent with the declared MIME's container family, so an HTML file
|
|
7
|
+
* labelled `image/png` is rejected. Office formats can't be distinguished from
|
|
8
|
+
* each other by magic alone (docx/xlsx are both ZIP; doc/xls are both OLE), so
|
|
9
|
+
* we validate the container family, not the exact subtype.
|
|
10
|
+
*
|
|
11
|
+
* ACCEPTS A PATH OR A BUFFER. Both exist in practice: multer's disk storage gives a path, and
|
|
12
|
+
* its memory storage gives a buffer with no path at all — which is what a consumer writing
|
|
13
|
+
* documents into an encrypted database column uses. Sniffing only ever needed the first 16
|
|
14
|
+
* bytes, so a path-only signature meant the memory case could not be checked and simply was
|
|
15
|
+
* not, in the one app that stores uploads inside the database.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
|
|
20
|
+
/** The first 16 bytes, from a path or straight from a buffer. */
|
|
21
|
+
function head(input) {
|
|
22
|
+
if (Buffer.isBuffer(input)) return input.subarray(0, 16);
|
|
23
|
+
let fd;
|
|
24
|
+
try {
|
|
25
|
+
fd = fs.openSync(input, 'r');
|
|
26
|
+
const buf = Buffer.alloc(16);
|
|
27
|
+
const n = fs.readSync(fd, buf, 0, 16, 0);
|
|
28
|
+
return buf.subarray(0, n);
|
|
29
|
+
} finally {
|
|
30
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch (_) { /* noop */ }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sniff the container/type from the first bytes. Returns a coarse tag or null.
|
|
36
|
+
* @param {string|Buffer} input a file path, or the bytes themselves
|
|
37
|
+
*/
|
|
38
|
+
function sniff(input) {
|
|
39
|
+
try {
|
|
40
|
+
const b = head(input);
|
|
41
|
+
const hex = b.toString('hex').toUpperCase();
|
|
42
|
+
|
|
43
|
+
if (hex.startsWith('89504E47')) return 'image/png';
|
|
44
|
+
if (hex.startsWith('FFD8FF')) return 'image/jpeg';
|
|
45
|
+
if (hex.startsWith('47494638')) return 'image/gif';
|
|
46
|
+
if (hex.startsWith('25504446')) return 'application/pdf';
|
|
47
|
+
|
|
48
|
+
// RIFF container — disambiguate by the form-type at bytes 8–12.
|
|
49
|
+
if (hex.startsWith('52494646') && b.length >= 12) {
|
|
50
|
+
const form = b.subarray(8, 12).toString('ascii');
|
|
51
|
+
if (form === 'WEBP') return 'image/webp';
|
|
52
|
+
if (form === 'WAVE') return 'riff-wave';
|
|
53
|
+
if (form === 'AVI ') return 'riff-avi';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ISO-BMFF (MP4/MOV/M4A/HEIC): 'ftyp' box at bytes 4–8. We don't split the
|
|
57
|
+
// brand — validate the container family, like we do for zip-based formats.
|
|
58
|
+
if (b.length >= 8 && b.subarray(4, 8).toString('ascii') === 'ftyp') return 'isobmff';
|
|
59
|
+
// Matroska/WebM (EBML)
|
|
60
|
+
if (hex.startsWith('1A45DFA3')) return 'ebml';
|
|
61
|
+
// Ogg (audio/video)
|
|
62
|
+
if (hex.startsWith('4F676753')) return 'ogg';
|
|
63
|
+
// MP3: ID3 tag, or an MPEG audio frame sync (0xFFEx).
|
|
64
|
+
if (hex.startsWith('494433') || (b.length >= 2 && b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) return 'mp3';
|
|
65
|
+
|
|
66
|
+
// ZIP container (docx/xlsx and plain zip): PK\x03\x04 / PK\x05\x06 / PK\x07\x08
|
|
67
|
+
if (hex.startsWith('504B0304') || hex.startsWith('504B0506') || hex.startsWith('504B0708')) return 'zip';
|
|
68
|
+
// OLE compound file (legacy doc/xls)
|
|
69
|
+
if (hex.startsWith('D0CF11E0A1B11AE1')) return 'ole';
|
|
70
|
+
// Other archives
|
|
71
|
+
if (hex.startsWith('377ABCAF271C')) return '7z';
|
|
72
|
+
if (hex.startsWith('526172211A07')) return 'rar'; // 'Rar!\x1A\x07'
|
|
73
|
+
if (hex.startsWith('1F8B')) return 'gzip';
|
|
74
|
+
// No binary signature: treat as text only if there are no NUL bytes.
|
|
75
|
+
if (b.length > 0 && !b.includes(0)) return 'text';
|
|
76
|
+
return null;
|
|
77
|
+
} catch (_) {
|
|
78
|
+
// A path that cannot be opened, or bytes that cannot be read. "Unknown" rather than a
|
|
79
|
+
// throw: the caller's next move is to reject the file either way, and a sniffer that can
|
|
80
|
+
// fail loudly turns an unreadable upload into a 500.
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Declared MIME → the sniffed tags that are acceptable for it.
|
|
86
|
+
const FAMILY = {
|
|
87
|
+
'image/png': ['image/png'],
|
|
88
|
+
'image/jpeg': ['image/jpeg'],
|
|
89
|
+
'image/gif': ['image/gif'],
|
|
90
|
+
'image/webp': ['image/webp'],
|
|
91
|
+
'application/pdf': ['application/pdf'],
|
|
92
|
+
'text/plain': ['text'],
|
|
93
|
+
'text/csv': ['text'],
|
|
94
|
+
'application/msword': ['ole'],
|
|
95
|
+
'application/vnd.ms-excel': ['ole'],
|
|
96
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['zip'],
|
|
97
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['zip'],
|
|
98
|
+
// images (HEIC/HEIF are ISO-BMFF)
|
|
99
|
+
'image/heic': ['isobmff'],
|
|
100
|
+
'image/heif': ['isobmff'],
|
|
101
|
+
// video
|
|
102
|
+
'video/mp4': ['isobmff'],
|
|
103
|
+
'video/quicktime': ['isobmff'],
|
|
104
|
+
'video/webm': ['ebml'],
|
|
105
|
+
'video/x-msvideo': ['riff-avi'],
|
|
106
|
+
'video/ogg': ['ogg'],
|
|
107
|
+
// audio
|
|
108
|
+
'audio/mpeg': ['mp3'],
|
|
109
|
+
'audio/mp4': ['isobmff'],
|
|
110
|
+
'audio/x-m4a': ['isobmff'],
|
|
111
|
+
'audio/wav': ['riff-wave'],
|
|
112
|
+
'audio/x-wav': ['riff-wave'],
|
|
113
|
+
'audio/ogg': ['ogg'],
|
|
114
|
+
'audio/webm': ['ebml'],
|
|
115
|
+
// archives
|
|
116
|
+
'application/zip': ['zip'],
|
|
117
|
+
'application/x-zip-compressed': ['zip'],
|
|
118
|
+
'application/x-7z-compressed': ['7z'],
|
|
119
|
+
'application/x-rar-compressed': ['rar'],
|
|
120
|
+
'application/vnd.rar': ['rar'],
|
|
121
|
+
'application/gzip': ['gzip']
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* True if the file's actual content is consistent with the declared MIME.
|
|
126
|
+
* @param {string|Buffer} input a file path, or the bytes themselves
|
|
127
|
+
* @param {string} declaredMime
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
/**
|
|
131
|
+
* Extension → MIME, for the cases MAGIC BYTES CANNOT SETTLE.
|
|
132
|
+
*
|
|
133
|
+
* .docx, .xlsx and a plain .zip are all ZIP containers; .doc and .xls are both OLE; .mp4, .mov,
|
|
134
|
+
* .m4a and .heic are all ISO-BMFF. The bytes identify the CONTAINER and stop there, so
|
|
135
|
+
* something else has to choose within it — and the only candidates are client-supplied.
|
|
136
|
+
*
|
|
137
|
+
* That is not a flaw in this table, it is the shape of the formats. What matters is that the
|
|
138
|
+
* caller is TOLD which answer it got: `from: 'content'` was decided by the bytes and cannot be
|
|
139
|
+
* influenced; `from: 'extension'` was narrowed by a client-supplied name inside a container the
|
|
140
|
+
* bytes did confirm. An app storing documents may accept the second; one serving them back
|
|
141
|
+
* inline should think about it.
|
|
142
|
+
*/
|
|
143
|
+
const EXT = {
|
|
144
|
+
doc: 'application/msword',
|
|
145
|
+
xls: 'application/vnd.ms-excel',
|
|
146
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
147
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
148
|
+
zip: 'application/zip',
|
|
149
|
+
heic: 'image/heic',
|
|
150
|
+
heif: 'image/heif',
|
|
151
|
+
mp4: 'video/mp4',
|
|
152
|
+
m4v: 'video/mp4',
|
|
153
|
+
mov: 'video/quicktime',
|
|
154
|
+
m4a: 'audio/x-m4a',
|
|
155
|
+
webm: 'video/webm',
|
|
156
|
+
mkv: 'video/webm',
|
|
157
|
+
avi: 'video/x-msvideo',
|
|
158
|
+
wav: 'audio/wav',
|
|
159
|
+
mp3: 'audio/mpeg',
|
|
160
|
+
txt: 'text/plain',
|
|
161
|
+
csv: 'text/csv'
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Tags sniff() returns that ARE the answer, needing no extension to disambiguate. */
|
|
165
|
+
const CONCRETE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf']);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* DECIDE the type, rather than checking someone else's claim.
|
|
169
|
+
*
|
|
170
|
+
* `matches()` asks "are these bytes consistent with what the client said?" — and then the caller
|
|
171
|
+
* usually stores what the client said. `resolve()` asks "what ARE these bytes?" and returns the
|
|
172
|
+
* answer to store. Where magic is unambiguous the client has no say at all; where it is not, the
|
|
173
|
+
* extension narrows within a container the bytes confirmed, and the result says so.
|
|
174
|
+
*
|
|
175
|
+
* Promoted from a consuming app that had written this by hand for five types, because deriving
|
|
176
|
+
* is strictly stronger than validating: a file whose bytes say nothing recognisable is REJECTED,
|
|
177
|
+
* rather than accepted under whatever label happened to arrive with it.
|
|
178
|
+
*
|
|
179
|
+
* @param {string|Buffer} input a path or the bytes
|
|
180
|
+
* @param {string} filename the client's name — used ONLY to disambiguate a container
|
|
181
|
+
* @param {Set} allow permitted MIME types; anything outside is rejected
|
|
182
|
+
* @returns {{mime: string, from: 'content'|'extension'}|null}
|
|
183
|
+
*/
|
|
184
|
+
function resolve(input, { filename = '', allow = null } = {}) {
|
|
185
|
+
const tag = sniff(input);
|
|
186
|
+
if (!tag) return null; // unrecognised bytes are refused, never guessed
|
|
187
|
+
|
|
188
|
+
const permitted = (m) => !allow || allow.has(m);
|
|
189
|
+
|
|
190
|
+
// 1. The bytes named it outright. The client's opinion is not consulted.
|
|
191
|
+
if (CONCRETE.has(tag)) return permitted(tag) ? { mime: tag, from: 'content' } : null;
|
|
192
|
+
|
|
193
|
+
// 2. A container. Narrow by extension, but only to something the container can actually hold —
|
|
194
|
+
// so a ZIP named .mp4 is refused rather than relabelled.
|
|
195
|
+
const ext = String(filename).toLowerCase().split('.').pop();
|
|
196
|
+
const byExt = EXT[ext];
|
|
197
|
+
if (!byExt) return null;
|
|
198
|
+
const acceptableTags = FAMILY[byExt] || [];
|
|
199
|
+
if (!acceptableTags.includes(tag)) return null;
|
|
200
|
+
return permitted(byExt) ? { mime: byExt, from: 'extension' } : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function matches(input, declaredMime) {
|
|
204
|
+
const allowed = FAMILY[declaredMime];
|
|
205
|
+
if (!allowed) return false;
|
|
206
|
+
const sniffed = sniff(input);
|
|
207
|
+
return !!sniffed && allowed.includes(sniffed);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { sniff, matches, resolve };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Aria App Framework — kit module. Small dependency-free server utilities: open-redirect guard (safeReturnTo), SQL LIKE escaping, magic-byte upload validation (fileSniff), Crockford base32 tracking IDs, and person-name compose/split helpers.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|