@ultimat3/storage 19.2.0 → 19.3.1
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 +2 -2
- package/src/errors.ts +21 -0
- package/src/index.ts +1 -0
- package/src/upload.ts +29 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/storage",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.3.1",
|
|
4
4
|
"description": "Named disks over Bun.file and Bun.s3: safe keys, signed URLs, sniffed uploads",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "19.
|
|
34
|
+
"@ultimat3/core": "19.3.1"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/errors.ts
CHANGED
|
@@ -179,6 +179,27 @@ export const contentTypeMismatch = (key: string, declared: string, sniffed: stri
|
|
|
179
179
|
meta: { key, declared, sniffed },
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The bytes match NO rule at all, under a type a signature could have confirmed. Its own
|
|
184
|
+
* constructor rather than `contentTypeMismatch` with a placeholder, because that error's `fix:`
|
|
185
|
+
* offers the sniffed type as the one to re-declare and here there is no such type — the whole
|
|
186
|
+
* finding is that nothing recognised these bytes. Same shipped code: what a client retries on is
|
|
187
|
+
* unchanged.
|
|
188
|
+
*
|
|
189
|
+
* The `fix:` names the CALL and the key, never "upload a genuine file". There is exactly one
|
|
190
|
+
* remedy — re-run the same validation over different bytes — and the reader's problem is which
|
|
191
|
+
* bytes, so the line has to name the operation it is re-running and the object it is re-running it
|
|
192
|
+
* for. `upload a genuine <type> file` restated the type the caller had already declared and gave a
|
|
193
|
+
* CLI, an MCP tool or an agent nothing to do.
|
|
194
|
+
*/
|
|
195
|
+
export const contentTypeUnrecognised = (key: string, declared: string): StorageError =>
|
|
196
|
+
new StorageError({
|
|
197
|
+
code: 'X_STORAGE_TYPE_REJECTED',
|
|
198
|
+
cause: `"${key}" declares ${declared}, and its first bytes match no known signature — every ${declared} carries one`,
|
|
199
|
+
fix: `re-send "${key}" through validateUpload(candidate, policy) with the bytes of a real ${declared}: the ones sent open with no ${declared} signature, so they are another format or truncated`,
|
|
200
|
+
meta: { key, declared },
|
|
201
|
+
});
|
|
202
|
+
|
|
182
203
|
export const checksumMismatch = (key: string, declared: string, actual: string): StorageError =>
|
|
183
204
|
new StorageError({
|
|
184
205
|
code: 'X_STORAGE_CHECKSUM_MISMATCH',
|
package/src/index.ts
CHANGED
package/src/upload.ts
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
// allowlist, checksum — and the content-type sniff that enforces it.
|
|
3
3
|
// WHY sniff: `Content-Type` is attacker-controlled. A `.png` that is really an HTML document
|
|
4
4
|
// is a stored-XSS delivery vehicle the moment any surface serves it back with the declared
|
|
5
|
-
// type, so the magic bytes decide and a contradiction is rejected outright
|
|
5
|
+
// type, so the magic bytes decide and a contradiction is rejected outright — including the
|
|
6
|
+
// contradiction of NO answer at all, where the declared type is one a signature could have
|
|
7
|
+
// confirmed. A type no rule can ever confirm (`text/csv`, `application/json`) is still accepted
|
|
8
|
+
// on the client's word: there is nothing to compare it against.
|
|
6
9
|
|
|
7
10
|
import { finiteCount } from '@ultimat3/core';
|
|
8
11
|
import { sha256Base64 } from './driver';
|
|
9
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
checksumMismatch,
|
|
14
|
+
contentTypeMismatch,
|
|
15
|
+
contentTypeNotAllowed,
|
|
16
|
+
contentTypeUnrecognised,
|
|
17
|
+
tooLarge,
|
|
18
|
+
} from './errors';
|
|
10
19
|
import { assertSafeKey } from './path';
|
|
11
20
|
|
|
12
21
|
export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
@@ -182,6 +191,17 @@ export function contentTypeMatches(declared: string, sniffed: string): boolean {
|
|
|
182
191
|
return false;
|
|
183
192
|
}
|
|
184
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Whether the magic bytes could ever CONFIRM this declared type — the question a `undefined`
|
|
196
|
+
* sniff has to be read against. Through `contentTypeMatches` rather than an equality on
|
|
197
|
+
* `rule.type`, so the zip containers ride along: no rule names a `.docx`, but every OOXML
|
|
198
|
+
* document is a zip and `application/zip` is a rule, so an unrecognisable body under that type is
|
|
199
|
+
* as provably wrong as one under `image/png`.
|
|
200
|
+
*/
|
|
201
|
+
function hasSignature(declared: string): boolean {
|
|
202
|
+
return MAGIC_RULES.some((rule) => contentTypeMatches(declared, rule.type));
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
/**
|
|
186
206
|
* Throws the first violated constraint, in this order: key, size, type, checksum. The key comes
|
|
187
207
|
* before the size because a key nothing may store makes the other three moot, and which
|
|
@@ -200,7 +220,13 @@ export function validateUpload(
|
|
|
200
220
|
throw contentTypeNotAllowed(key, declared, policy.allowedContentTypes);
|
|
201
221
|
}
|
|
202
222
|
const sniffed = sniffContentType(candidate.bytes);
|
|
203
|
-
if (sniffed
|
|
223
|
+
if (sniffed === undefined) {
|
|
224
|
+
// `undefined` is "no rule recognised it", never "it is fine" — `sniffContentType`'s own doc
|
|
225
|
+
// says so and this branch read it the other way, so any body the sniffer bailed on (one
|
|
226
|
+
// control byte, one non-UTF-8 sequence) skipped the guard entirely. Measured: an HTML
|
|
227
|
+
// document with a trailing `0x01` was accepted as `image/png`.
|
|
228
|
+
if (hasSignature(declared)) throw contentTypeUnrecognised(key, declared);
|
|
229
|
+
} else if (!contentTypeMatches(declared, sniffed)) {
|
|
204
230
|
throw contentTypeMismatch(key, declared, sniffed);
|
|
205
231
|
}
|
|
206
232
|
|