@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/storage",
3
- "version": "19.2.0",
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.2.0"
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
@@ -73,6 +73,7 @@ export {
73
73
  checksumMismatch,
74
74
  contentTypeMismatch,
75
75
  contentTypeNotAllowed,
76
+ contentTypeUnrecognised,
76
77
  deleteFailed,
77
78
  diskUnknown,
78
79
  isStorageError,
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 { checksumMismatch, contentTypeMismatch, contentTypeNotAllowed, tooLarge } from './errors';
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 !== undefined && !contentTypeMatches(declared, 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