@ultimat3/storage 23.0.0 → 24.0.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.
@@ -0,0 +1,50 @@
1
+ // Single responsibility: which media type an ISO base media file is. Every MP4, MOV, M4A, HEIC
2
+ // and AVIF opens with the same `ftyp` box, so the box alone says "container" — the four-byte MAJOR
3
+ // BRAND after it is what says which one, and reading only the box labelled all of them `video/mp4`.
4
+
5
+ const ascii = (bytes: Uint8Array, offset: number): string =>
6
+ String.fromCharCode(...bytes.subarray(offset, offset + 4));
7
+
8
+ /** The type a major brand cannot narrow: a container, as `application/zip` is one for OOXML. */
9
+ export const ISO_BMFF_CONTAINER = 'video/mp4';
10
+
11
+ /** HEIF's structural brands — a still HEIC and a still AVIF both ship under `mif1`. */
12
+ export const HEIF_CONTAINER = 'image/heif';
13
+
14
+ // A `Map`, never an object literal: the brand is four bytes off an upload, and `BRANDS['cons']`
15
+ // is harmless only until a brand spells a prototype key.
16
+ const BRANDS: ReadonlyMap<string, string> = new Map([
17
+ ['avif', 'image/avif'],
18
+ ['avis', 'image/avif'],
19
+ ['heic', 'image/heic'],
20
+ ['heix', 'image/heic'],
21
+ ['heim', 'image/heic'],
22
+ ['heis', 'image/heic'],
23
+ ['hevc', 'image/heic'],
24
+ ['hevx', 'image/heic'],
25
+ ['mif1', HEIF_CONTAINER],
26
+ ['msf1', HEIF_CONTAINER],
27
+ ['qt ', 'video/quicktime'],
28
+ ['M4A ', 'audio/mp4'],
29
+ ['M4B ', 'audio/mp4'],
30
+ ['M4P ', 'audio/mp4'],
31
+ ['3gp4', 'video/3gpp'],
32
+ ['3gp5', 'video/3gpp'],
33
+ ['3gp6', 'video/3gpp'],
34
+ ['3g2a', 'video/3gpp2'],
35
+ ]);
36
+
37
+ /** Every type `sniffIsoBmff` can answer — what `validateUpload` reads "could confirm" from. */
38
+ export const ISO_BMFF_TYPES: readonly string[] = [
39
+ ...new Set([ISO_BMFF_CONTAINER, ...BRANDS.values()]),
40
+ ];
41
+
42
+ /**
43
+ * `undefined` when the bytes do not open with a `ftyp` box carrying a brand. A brand this table
44
+ * does not list answers the container: it is an ISO base media file, which is all the box proves,
45
+ * and that is the answer every brand got before this file existed.
46
+ */
47
+ export function sniffIsoBmff(bytes: Uint8Array): string | undefined {
48
+ if (bytes.byteLength < 12 || ascii(bytes, 4) !== 'ftyp') return undefined;
49
+ return BRANDS.get(ascii(bytes, 8)) ?? ISO_BMFF_CONTAINER;
50
+ }
package/src/path.ts CHANGED
@@ -42,6 +42,9 @@ function hasControlByte(key: string): boolean {
42
42
  }
43
43
 
44
44
  function unsafeReason(key: string): string | undefined {
45
+ // The type says string; a key off an untyped caller is whatever it is, and `.length` on
46
+ // `undefined` was a bare `TypeError` out of every driver method at once.
47
+ if (typeof key !== 'string') return 'is not a string';
45
48
  if (key.length === 0) return 'is empty';
46
49
  const bytes = utf8Bytes(key);
47
50
  if (bytes > MAX_KEY_LENGTH) {
@@ -110,9 +113,21 @@ export function scopedKey(orgId: string, ...parts: readonly string[]): string {
110
113
  return joinKey(ORG_PREFIX, assertOrgId(orgId), ...parts);
111
114
  }
112
115
 
113
- /** Guard for read paths: a key handed in by a client must still belong to the actor's org. */
116
+ /**
117
+ * Guard for read paths: a key handed in by a client must still belong to the actor's org.
118
+ *
119
+ * A PREDICATE, so it answers and never throws. An org id that cannot be one — empty, or carrying
120
+ * a separator — has no keys inside it, and that is `false`: it used to raise
121
+ * `X_STORAGE_PATH_UNSAFE` through `orgPrefix`, blaming the KEY for an actor with no org, and every
122
+ * caller that knew wrote `orgId === '' ||` in front of it while the ones that did not threw.
123
+ */
114
124
  export function isWithinOrg(key: string, orgId: string): boolean {
115
- return isSafeKey(key) && key.startsWith(orgPrefix(orgId));
125
+ // `typeof`, because the caller is a guard on a request: an actor whose org claim is `undefined`
126
+ // at runtime must read as "inside no org", not as a `TypeError` out of `.includes`.
127
+ if (typeof key !== 'string' || typeof orgId !== 'string') return false;
128
+ const prefix = `${ORG_PREFIX}/${orgId}`;
129
+ if (orgId.includes('/') || !isSafeKey(prefix)) return false;
130
+ return isSafeKey(key) && key.startsWith(`${prefix}/`);
116
131
  }
117
132
 
118
133
  /**
package/src/signed-url.ts CHANGED
@@ -12,7 +12,8 @@ import { assertSafeKey, isSafeKey } from './path';
12
12
  * implementation now lives in `@ultimat3/core`, shared with `@ultimat3/auth`. */
13
13
  export { timingSafeEqual };
14
14
 
15
- export const SIGNED_URL_VERSION = 'v1';
15
+ /** `v2` since the canonical string carries the base path: a `v1` signature names no disk. */
16
+ export const SIGNED_URL_VERSION = 'v2';
16
17
  export const DEFAULT_SIGNED_URL_TTL_MS = 900_000;
17
18
  /** The dev server mounts the download/upload route here; S3 disks never use it. */
18
19
  export const DEFAULT_SIGNED_URL_BASE = '/_storage';
@@ -70,10 +71,34 @@ export type SignedUrlVerification =
70
71
  | { readonly ok: true; readonly constraints: SignedUrlConstraints }
71
72
  | { readonly ok: false; readonly reason: SignedUrlFailure; readonly detail: string };
72
73
 
73
- /** Newline-separated and order-fixed: an ambiguous canonical form is a forgeable one. */
74
- export function canonicalRequest(constraints: SignedUrlConstraints): string {
74
+ const trimBase = (base: string): string => base.replace(/\/+$/, '');
75
+
76
+ /**
77
+ * The PATH a base mounts at — what a route sees, and so the only part of a base both halves can
78
+ * agree on. An absolute `baseUrl` (`https://cdn.example.com/_storage/local`) names an origin too,
79
+ * and the verifier compared a request's pathname against the whole string: every URL minted under
80
+ * one was `malformed`. Never throws — a base `URL` cannot parse is compared as written.
81
+ */
82
+ export function signedUrlBasePath(baseUrl: string): string {
83
+ try {
84
+ return trimBase(new URL(baseUrl, 'http://storage.invalid').pathname);
85
+ } catch {
86
+ return trimBase(baseUrl);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Newline-separated and order-fixed: an ambiguous canonical form is a forgeable one.
92
+ *
93
+ * `basePath` is the disk: `/_storage/<registered name>`. Every local disk signs with the one
94
+ * `STORAGE_SIGNING_SECRET`, so without it a URL for `uploads/<key>` verified on `private/<key>`
95
+ * with only the path segment edited — a grant for one disk was a grant for all of them. The
96
+ * origin is deliberately outside it: the verifying route is handed a path.
97
+ */
98
+ export function canonicalRequest(constraints: SignedUrlConstraints, basePath: string): string {
75
99
  return [
76
100
  SIGNED_URL_VERSION,
101
+ basePath,
77
102
  constraints.method,
78
103
  constraints.key,
79
104
  String(constraints.expiresAt),
@@ -87,6 +112,7 @@ const encoder = new TextEncoder();
87
112
  export async function signConstraints(
88
113
  secret: string,
89
114
  constraints: SignedUrlConstraints,
115
+ basePath: string,
90
116
  ): Promise<string> {
91
117
  const key = await crypto.subtle.importKey(
92
118
  'raw',
@@ -95,11 +121,14 @@ export async function signConstraints(
95
121
  false,
96
122
  ['sign'],
97
123
  );
98
- const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(canonicalRequest(constraints)));
124
+ const mac = await crypto.subtle.sign(
125
+ 'HMAC',
126
+ key,
127
+ encoder.encode(canonicalRequest(constraints, basePath)),
128
+ );
99
129
  return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
100
130
  }
101
131
 
102
- const trimBase = (base: string): string => base.replace(/\/+$/, '');
103
132
  const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
104
133
 
105
134
  /**
@@ -148,8 +177,11 @@ export async function buildSignedUrl(input: SignedUrlInput): Promise<string> {
148
177
  if (constraints.contentType !== undefined) {
149
178
  params.set(SIGNED_URL_PARAMS.contentType, constraints.contentType);
150
179
  }
151
- params.set(SIGNED_URL_PARAMS.signature, await signConstraints(input.secret, constraints));
152
180
  const base = trimBase(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE);
181
+ params.set(
182
+ SIGNED_URL_PARAMS.signature,
183
+ await signConstraints(input.secret, constraints, signedUrlBasePath(base)),
184
+ );
153
185
  return `${base}/${encodeKey(key)}?${params.toString()}`;
154
186
  }
155
187
 
@@ -222,10 +254,11 @@ export async function verifySignedUrl(input: VerifySignedUrlInput): Promise<Sign
222
254
  }
223
255
  const signature = url.searchParams.get(SIGNED_URL_PARAMS.signature);
224
256
  if (signature === null) return fail('malformed', `no ${SIGNED_URL_PARAMS.signature} parameter`);
225
- const parsed = parseConstraints(url, trimBase(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE));
257
+ const basePath = signedUrlBasePath(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE);
258
+ const parsed = parseConstraints(url, basePath);
226
259
  if (typeof parsed === 'string') return fail(parsed, `${url.pathname} is not a signable request`);
227
260
 
228
- const expected = await signConstraints(input.secret, parsed);
261
+ const expected = await signConstraints(input.secret, parsed, basePath);
229
262
  if (!timingSafeEqual(expected, signature)) {
230
263
  return fail('signature-mismatch', 'the constraints do not match the signature');
231
264
  }
@@ -0,0 +1,71 @@
1
+ // Single responsibility: the secret a disk that mints its OWN URLs signs with — the published
2
+ // development fallback, the env key production must set, and the one rule (`localDriver` and
3
+ // `memoryDriver` both) for refusing the fallback outside a development or test environment.
4
+
5
+ import { isLocal, type ResolveEnvironmentOptions, resolveEnvironment } from '@ultimat3/core';
6
+ import { signingSecretMissing } from './errors';
7
+
8
+ /**
9
+ * The dev-only fallback signing key. A literal, not a per-process random one, so a restart does
10
+ * not invalidate every URL `x dev` handed out — and published in this repo, which is exactly why
11
+ * `localDriver` refuses to use it outside a development or test environment.
12
+ */
13
+ export const DEV_SIGNING_SECRET = 'ultimate-dev-signing-secret';
14
+
15
+ /** The env key production must set. Named once, read by the driver and by the predicate below. */
16
+ export const STORAGE_SIGNING_SECRET_KEY = 'STORAGE_SIGNING_SECRET';
17
+
18
+ /**
19
+ * True while a local disk built without an explicit `signingSecret` would sign with the shipped
20
+ * development key — `x doctor` reports it, exactly as it reports `usesDevCursorSecret()`.
21
+ *
22
+ * Reads the environment, not a driver instance: this is the same question `x doctor` asks about
23
+ * the cursor secret, and a disk handed an explicit `signingSecret` in `app.config.ts` never
24
+ * consults the variable at all.
25
+ *
26
+ * `env` is core's own slot, so this half of the guard reads the SAME table its other half does:
27
+ * `dev-runtime.ts` asks `!isLocal({ env }) && usesDevStorageSecret({ env })`, and an embedding
28
+ * caller (`serveApp({ env })`, a test fixture) whose `env` is not `process.env` used to get one
29
+ * answer about the boot and one about the process — for the decision of whether a disk may be
30
+ * signed with the published development key. Defaulted to `process.env`, so a bare call is
31
+ * unchanged.
32
+ */
33
+ export function usesDevStorageSecret(options?: Pick<ResolveEnvironmentOptions, 'env'>): boolean {
34
+ const source = options?.env ?? (process.env as Record<string, string | undefined>);
35
+ const configured = source[STORAGE_SIGNING_SECRET_KEY];
36
+ return configured === undefined || configured === '' || configured === DEV_SIGNING_SECRET;
37
+ }
38
+
39
+ /** The two options the rule reads; `LocalDriverOptions` carries both. */
40
+ export interface SigningSecretOptions {
41
+ readonly signingSecret?: string | undefined;
42
+ readonly env?: ResolveEnvironmentOptions['env'];
43
+ }
44
+
45
+ /**
46
+ * The secret a disk that mints its OWN URLs signs with — `localDriver` and `memoryDriver` both.
47
+ *
48
+ * A dev disk must work with zero config. Outside development the fallback is refused rather than
49
+ * used: the literal is published, so signing with it hands every reader the power to mint a PUT
50
+ * for any key with any size and type limit — which `acceptSignedUpload` then trusts over the app's
51
+ * own `uploadPolicy`. Refused at construction, so the boot fails rather than the first upload.
52
+ *
53
+ * The published literal counts as no secret at all, whichever way it arrives: an env var or an
54
+ * `app.config.ts` that pasted it in signs exactly as weakly as the fallback does. One table for
55
+ * all three reads — the secret, the environment test and the environment the refusal names.
56
+ * Splitting them is how the guard and the disk came to answer about two different processes.
57
+ */
58
+ export function resolveSigningSecret(disk: string, options: SigningSecretOptions): string {
59
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
60
+ const supplied = options.signingSecret ?? env[STORAGE_SIGNING_SECRET_KEY];
61
+ const configured =
62
+ supplied === undefined || supplied === '' || supplied === DEV_SIGNING_SECRET
63
+ ? undefined
64
+ : supplied;
65
+ // FAILS CLOSED: a process that names no environment resolves as `production` here, the answer
66
+ // core's `assertNoDevSecretsOutsideLocal` gives. `isLocal`'s own fallback is `development`, so the
67
+ // process that forgot to say signed with the published key — exactly the one that must not.
68
+ if (configured === undefined && !isLocal({ env, fallback: 'production' }))
69
+ throw signingSecretMissing(resolveEnvironment({ env, fallback: 'production' }), disk);
70
+ return configured ?? DEV_SIGNING_SECRET;
71
+ }
package/src/upload.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  contentTypeUnrecognised,
17
17
  tooLarge,
18
18
  } from './errors';
19
+ import { HEIF_CONTAINER, ISO_BMFF_CONTAINER, ISO_BMFF_TYPES, sniffIsoBmff } from './iso-bmff';
19
20
  import { assertSafeKey } from './path';
20
21
 
21
22
  export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
@@ -65,7 +66,12 @@ export function uploadPolicy(init: UploadPolicyInit = {}): UploadPolicy {
65
66
  init.maxBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : init.maxBytes,
66
67
  1,
67
68
  ),
68
- allowedContentTypes: init.allowedContentTypes ?? IMAGE_CONTENT_TYPES,
69
+ // Normalised, because the declared type is: `validateUpload` and `grantUpload` compare the
70
+ // NORMALISED declaration against this list, so an allowlist spelling an alias (`image/jpg`,
71
+ // `audio/x-m4a`) named a type no upload could ever match.
72
+ allowedContentTypes: (init.allowedContentTypes ?? IMAGE_CONTENT_TYPES).map(
73
+ normalizeContentType,
74
+ ),
69
75
  requireChecksum: init.requireChecksum ?? false,
70
76
  };
71
77
  }
@@ -111,7 +117,6 @@ const MAGIC_RULES: readonly MagicRule[] = [
111
117
  ],
112
118
  },
113
119
  { type: 'application/pdf', parts: [{ offset: 0, pattern: ascii('%PDF-') }] },
114
- { type: 'video/mp4', parts: [{ offset: 4, pattern: ascii('ftyp') }] },
115
120
  // Every OOXML document and epub is a zip; the container is as far as magic bytes go.
116
121
  { type: 'application/zip', parts: [{ offset: 0, pattern: [0x50, 0x4b, 0x03, 0x04] }] },
117
122
  ];
@@ -154,7 +159,9 @@ export function sniffContentType(bytes: Uint8Array): string | undefined {
154
159
  for (const rule of MAGIC_RULES) {
155
160
  if (matches(bytes, rule)) return rule.type;
156
161
  }
157
- return sniffText(bytes);
162
+ // Not a `MagicRule`: every ISO base media file opens with the same `ftyp` box, and the one rule
163
+ // that matched it labelled AVIF, HEIC, MOV and M4A all `video/mp4`. The major brand decides.
164
+ return sniffIsoBmff(bytes) ?? sniffText(bytes);
158
165
  }
159
166
 
160
167
  // A `Map`, not an object literal: `base` is the transport's own `Content-Type` header by the time
@@ -164,6 +171,10 @@ const ALIASES: ReadonlyMap<string, string> = new Map([
164
171
  ['image/jpg', 'image/jpeg'],
165
172
  ['image/x-png', 'image/png'],
166
173
  ['application/x-pdf', 'application/pdf'],
174
+ // What Chrome and Safari report for an `.m4a` / `.m4v` picked from disk.
175
+ ['audio/x-m4a', 'audio/mp4'],
176
+ ['audio/m4a', 'audio/mp4'],
177
+ ['video/x-m4v', 'video/mp4'],
167
178
  ]);
168
179
 
169
180
  /** Strip parameters and case: `IMAGE/PNG; charset=binary` and `image/png` are one type. */
@@ -182,11 +193,23 @@ const TEXT_FAMILY = new Set([
182
193
  'text/xml',
183
194
  ]);
184
195
 
185
- /** A generic sniff (zip container, plain text) may stand in for a specific declared type. */
196
+ // A brand that names only the container: `isom` is on an audio-only MP4 as often as on a video,
197
+ // and `mif1` on a still HEIC and a still AVIF alike. `image/heif` is also the general name of a
198
+ // file whose brand says `heic`, so that pair matches in the other direction too.
199
+ const MP4_CONTAINED = new Set(['audio/mp4']);
200
+ const HEIF_CONTAINED = new Set(['image/heic', 'image/avif']);
201
+
202
+ /**
203
+ * A generic sniff (zip container, ISO-BMFF container, plain text) may stand in for a specific
204
+ * declared type.
205
+ */
186
206
  export function contentTypeMatches(declared: string, sniffed: string): boolean {
187
207
  const type = normalizeContentType(declared);
188
208
  if (type === sniffed) return true;
189
209
  if (sniffed === 'application/zip') return ZIP_CONTAINERS.has(type);
210
+ if (sniffed === ISO_BMFF_CONTAINER) return MP4_CONTAINED.has(type);
211
+ if (sniffed === HEIF_CONTAINER) return HEIF_CONTAINED.has(type);
212
+ if (sniffed === 'image/heic') return type === HEIF_CONTAINER;
190
213
  if (sniffed === 'text/plain') return TEXT_FAMILY.has(type);
191
214
  return false;
192
215
  }
@@ -199,7 +222,10 @@ export function contentTypeMatches(declared: string, sniffed: string): boolean {
199
222
  * as provably wrong as one under `image/png`.
200
223
  */
201
224
  function hasSignature(declared: string): boolean {
202
- return MAGIC_RULES.some((rule) => contentTypeMatches(declared, rule.type));
225
+ return (
226
+ MAGIC_RULES.some((rule) => contentTypeMatches(declared, rule.type)) ||
227
+ ISO_BMFF_TYPES.some((type) => contentTypeMatches(declared, type))
228
+ );
203
229
  }
204
230
 
205
231
  /**