@ultimat3/storage 2.0.0 → 3.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.
- package/CLAUDE.md +17 -0
- package/README.md +13 -4
- package/package.json +2 -2
- package/src/accept.ts +30 -6
- package/src/driver-local.ts +2 -2
- package/src/index.ts +1 -0
- package/src/path.ts +7 -1
- package/src/signed-url.ts +11 -0
package/CLAUDE.md
CHANGED
|
@@ -157,6 +157,23 @@ Gotchas:
|
|
|
157
157
|
- `acceptSignedUpload` refuses a URL signed with **no** content type (`unconstrained`). `grantUpload`
|
|
158
158
|
always sets one, so such a URL is hand-rolled, and trusting the uploader's header instead is the
|
|
159
159
|
only other option.
|
|
160
|
+
- **The signed base is declared ONCE, in `signedUrlBaseFor(driverName)`.** `localDriver` mints
|
|
161
|
+
under it and `accept.ts` defaults to `signedUrlBaseFor(disk.name)` — the same base, arrived at
|
|
162
|
+
from the disk the caller already passed. It was stated twice (`/_storage/local` in the driver,
|
|
163
|
+
`/_storage` in `verifySignedUrl`'s default), so with both defaults NO genuine URL verified: the
|
|
164
|
+
key parsed as `local/<key>` and every grant died as `signature-mismatch`. `@ultimat3/cli`'s
|
|
165
|
+
`STORAGE_BASE_PATH` is a third statement of the mount prefix and should import
|
|
166
|
+
`DEFAULT_SIGNED_URL_BASE` instead.
|
|
167
|
+
- **`accept.ts` asks the `isTenantScoped`/`isWithinOrg` PAIR, exactly as `dev-storage.ts` does.**
|
|
168
|
+
`isWithinOrg` alone refused every un-scoped key, so an app's own `brand/logo.png` was unreachable
|
|
169
|
+
through a URL it had just signed. `isTenantScoped` is case-INSENSITIVE and `isWithinOrg` is not:
|
|
170
|
+
`Org/o2/x` and `org/o2/x` are one file on APFS/NTFS, so the fold has to count as tenant-scoped
|
|
171
|
+
and then fail the exact-case membership test. Do not "simplify" either half.
|
|
172
|
+
- **`AcceptSignedUploadInput.checksum` is what makes `uploadPolicy({ requireChecksum: true })`
|
|
173
|
+
reachable.** Without it that option could only ever fail, because nothing on the accept path
|
|
174
|
+
could declare a hash. It travels like `declaredContentType`: the route reads a header and hands
|
|
175
|
+
it over, and `validateUpload` hashes the bytes itself. The browser half does NOT send one — a
|
|
176
|
+
custom header on an S3 presigned PUT is a signature question this package cannot answer.
|
|
160
177
|
- `orgId` is required on both halves of `accept.ts` and is the ACTOR's, never a request field. A
|
|
161
178
|
signed URL is a capability; a leaked capability must still not cross a tenant.
|
|
162
179
|
- `X_STORAGE_ORG_MISMATCH` maps to **404**, not 403 (`@ultimat3/http`'s `error-map.ts`). 403 would
|
package/README.md
CHANGED
|
@@ -89,7 +89,9 @@ fixing was built wrong.
|
|
|
89
89
|
`scopedKey('org-1', 'avatars', 'a.png')` is `org/org-1/avatars/a.png`; guard every
|
|
90
90
|
client-supplied key with `isWithinOrg(key, ctx.actor.orgId)`. A surface that serves objects pairs
|
|
91
91
|
it with `isTenantScoped(key)`: only a key already inside `org/` is another tenant's to refuse, so
|
|
92
|
-
`disk().put('brand/logo.png', …)` stays reachable while `org/org-2/…` never is.
|
|
92
|
+
`disk().put('brand/logo.png', …)` stays reachable while `org/org-2/…` never is. `accept.ts` asks
|
|
93
|
+
the pair too. `isTenantScoped` folds case (`Org/`, `ORG/`) and `isWithinOrg` does not, so a
|
|
94
|
+
case-variant prefix — one directory, not two, on APFS or NTFS — is refused rather than matched.
|
|
93
95
|
|
|
94
96
|
## Signed URLs
|
|
95
97
|
|
|
@@ -144,8 +146,10 @@ const { key } = await uploadFile({ file, grant: (request) => api.requestUpload(r
|
|
|
144
146
|
|
|
145
147
|
// 3. server, in the route mounted at `/_storage`: take it back, or refuse
|
|
146
148
|
const object = await acceptSignedUpload({
|
|
147
|
-
url: request.url,
|
|
148
|
-
|
|
149
|
+
url: request.url, // baseUrl defaults to signedUrlBaseFor(disk.name) — the
|
|
150
|
+
secret, // same base the driver signed under. Pass one only for a
|
|
151
|
+
disk: disk('uploads'), // route mounted somewhere other than /_storage/<driver>.
|
|
152
|
+
orgId: ctx.actor.orgId,
|
|
149
153
|
bytes, declaredContentType: request.headers.get('content-type') ?? undefined,
|
|
150
154
|
policy: uploadPolicy({ maxBytes: 5e6 }),
|
|
151
155
|
});
|
|
@@ -154,7 +158,12 @@ const object = await acceptSignedUpload({
|
|
|
154
158
|
`acceptSignedUpload` refuses on any of: a signature that does not verify, an expired grant, a
|
|
155
159
|
`PUT` grant replayed as a `GET`, a key outside the actor's org, more bytes than the signature
|
|
156
160
|
granted, a `Content-Type` the signature does not cover, or magic bytes that contradict it.
|
|
157
|
-
`readSignedObject` is the GET half and applies the same verification and the same org check
|
|
161
|
+
`readSignedObject` is the GET half and applies the same verification and the same org check —
|
|
162
|
+
which is the `isTenantScoped`/`isWithinOrg` pair, so an app's own un-scoped `brand/logo.png` is
|
|
163
|
+
readable through a URL it signed and `org/org-2/…` still is not.
|
|
164
|
+
`uploadPolicy({ requireChecksum: true })` needs the request's declared hash, which travels as
|
|
165
|
+
`checksum` exactly as the content type travels as `declaredContentType`: a header the route reads
|
|
166
|
+
and hands over, hashed again here and refused on any disagreement.
|
|
158
167
|
Neither owns a `Request`, a `Response` or a status number — mounting is the host's job, and
|
|
159
168
|
`@ultimat3/http` is the only layer that turns an `X_*` code into a status.
|
|
160
169
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/storage",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
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": "
|
|
34
|
+
"@ultimat3/core": "3.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/accept.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
import type { Clock } from '@ultimat3/core';
|
|
10
10
|
import type { SignedUrlMethod, StorageDriver, StorageObject, StorageRead } from './driver';
|
|
11
11
|
import { orgMismatch, signedUrlExpired, signedUrlRejected, tooLarge } from './errors';
|
|
12
|
-
import { isWithinOrg } from './path';
|
|
12
|
+
import { isTenantScoped, isWithinOrg } from './path';
|
|
13
13
|
import type { SignedUrlConstraints } from './signed-url';
|
|
14
|
-
import { verifySignedUrl } from './signed-url';
|
|
14
|
+
import { signedUrlBaseFor, verifySignedUrl } from './signed-url';
|
|
15
15
|
import type { UploadPolicy } from './upload';
|
|
16
16
|
import { normalizeContentType, uploadPolicy, validateUpload } from './upload';
|
|
17
17
|
|
|
@@ -19,6 +19,11 @@ export interface SignedRequestInput {
|
|
|
19
19
|
/** Absolute or route-relative — `verifySignedUrl` parses both. */
|
|
20
20
|
readonly url: string;
|
|
21
21
|
readonly secret: string;
|
|
22
|
+
/**
|
|
23
|
+
* Defaults to the base THIS disk signs under (`signedUrlBaseFor(disk.name)`), never to the bare
|
|
24
|
+
* mount prefix: a second default here made every URL `localDriver` mints a signature-mismatch,
|
|
25
|
+
* because the key parsed as `local/<key>`. Pass one only for a route mounted somewhere else.
|
|
26
|
+
*/
|
|
22
27
|
readonly baseUrl?: string | undefined;
|
|
23
28
|
readonly disk: StorageDriver;
|
|
24
29
|
/**
|
|
@@ -33,6 +38,13 @@ export interface AcceptSignedUploadInput extends SignedRequestInput {
|
|
|
33
38
|
readonly bytes: Uint8Array;
|
|
34
39
|
/** The transport's `Content-Type`. Refused unless it equals the type the grant signed. */
|
|
35
40
|
readonly declaredContentType?: string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* The transport's declared base64 SHA-256, travelling exactly as `declaredContentType` does: a
|
|
43
|
+
* header the route reads and hands over, trusted for nothing — the bytes are hashed here and a
|
|
44
|
+
* disagreement is refused. Without this field `uploadPolicy({ requireChecksum: true })` could
|
|
45
|
+
* only ever fail, since nothing on this path could ever declare one.
|
|
46
|
+
*/
|
|
47
|
+
readonly checksum?: string | undefined;
|
|
36
48
|
readonly policy?: UploadPolicy | undefined;
|
|
37
49
|
}
|
|
38
50
|
|
|
@@ -48,7 +60,7 @@ async function constraintsFor(
|
|
|
48
60
|
const result = await verifySignedUrl({
|
|
49
61
|
url: input.url,
|
|
50
62
|
secret: input.secret,
|
|
51
|
-
|
|
63
|
+
baseUrl: input.baseUrl ?? signedUrlBaseFor(input.disk.name),
|
|
52
64
|
...(input.clock === undefined ? {} : { clock: input.clock }),
|
|
53
65
|
});
|
|
54
66
|
if (!result.ok) {
|
|
@@ -63,8 +75,15 @@ async function constraintsFor(
|
|
|
63
75
|
`the URL is signed for ${constraints.method}, and this is a ${method}`,
|
|
64
76
|
);
|
|
65
77
|
}
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
// The PAIR is the question "does this key belong to somebody else?". `isWithinOrg` alone
|
|
79
|
+
// answered `false` for every un-scoped key, so an app's own `brand/logo.png` was unreachable
|
|
80
|
+
// through a URL it had just signed — `path.ts` says so and `dev-storage.ts` already asks it this
|
|
81
|
+
// way. An actor with no org is inside no org, so every tenant-scoped key is somebody else's;
|
|
82
|
+
// checked here because `isWithinOrg` reads an empty org as a malformed key and would blame the
|
|
83
|
+
// URL for the actor's missing claim.
|
|
84
|
+
const orgId = input.orgId;
|
|
85
|
+
if (isTenantScoped(constraints.key) && (orgId === '' || !isWithinOrg(constraints.key, orgId))) {
|
|
86
|
+
throw orgMismatch(constraints.key, orgId);
|
|
68
87
|
}
|
|
69
88
|
return constraints;
|
|
70
89
|
}
|
|
@@ -106,7 +125,12 @@ export async function acceptSignedUpload(input: AcceptSignedUploadInput): Promis
|
|
|
106
125
|
|
|
107
126
|
const policy = input.policy ?? uploadPolicy();
|
|
108
127
|
const validated = validateUpload(
|
|
109
|
-
{
|
|
128
|
+
{
|
|
129
|
+
key,
|
|
130
|
+
declaredContentType: signed,
|
|
131
|
+
bytes: input.bytes,
|
|
132
|
+
...(input.checksum === undefined ? {} : { checksum: input.checksum }),
|
|
133
|
+
},
|
|
110
134
|
policy,
|
|
111
135
|
);
|
|
112
136
|
return input.disk.put(validated.key, validated.bytes, {
|
package/src/driver-local.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
storageNotImplemented,
|
|
30
30
|
} from './errors';
|
|
31
31
|
import { assertSafeKey, META_DIR } from './path';
|
|
32
|
-
import { buildSignedUrl } from './signed-url';
|
|
32
|
+
import { buildSignedUrl, signedUrlBaseFor } from './signed-url';
|
|
33
33
|
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
|
|
34
34
|
|
|
35
35
|
const DRIVER_NAME = 'local';
|
|
@@ -130,7 +130,7 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
|
|
|
130
130
|
const root = options.root.replace(/\/+$/, '');
|
|
131
131
|
const maxPutBytes = options.maxPutBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
132
132
|
const clock = options.clock ?? systemClock;
|
|
133
|
-
const baseUrl = options.baseUrl ??
|
|
133
|
+
const baseUrl = options.baseUrl ?? signedUrlBaseFor(DRIVER_NAME);
|
|
134
134
|
// A dev disk must work with zero config. Outside development the fallback is refused rather
|
|
135
135
|
// than used: the literal is published, so signing with it hands every reader the power to mint
|
|
136
136
|
// a PUT for any key with any size and type limit — which `acceptSignedUpload` then trusts over
|
package/src/index.ts
CHANGED
package/src/path.ts
CHANGED
|
@@ -102,9 +102,15 @@ export function isWithinOrg(key: string, orgId: string): boolean {
|
|
|
102
102
|
* two apart: `isWithinOrg` alone would answer `false` for every un-scoped key and make an app's
|
|
103
103
|
* own shared assets unreachable, and dropping the check would make one tenant's prefix readable
|
|
104
104
|
* by another. The pair is the question "does this key belong to somebody else?".
|
|
105
|
+
*
|
|
106
|
+
* Case-INSENSITIVE, and that is the load-bearing half: `Org/o2/a.png` and `org/o2/a.png` are one
|
|
107
|
+
* file on a case-insensitive filesystem (APFS, NTFS), so an exact-case test would answer "not a
|
|
108
|
+
* tenant's" for a key that reads another tenant's bytes on every macOS dev disk. `isWithinOrg` is
|
|
109
|
+
* exact-case and stays so, so a folded prefix is refused outright rather than matched — `org/` is
|
|
110
|
+
* the only spelling `scopedKey` mints, so nothing legitimate arrives in any other.
|
|
105
111
|
*/
|
|
106
112
|
export function isTenantScoped(key: string): boolean {
|
|
107
|
-
return key.
|
|
113
|
+
return key.slice(0, ORG_PREFIX.length + 1).toLowerCase() === `${ORG_PREFIX}/`;
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
/** `org/o1/a/b.png` -> `org/o1/a`. Empty for a top-level key. */
|
package/src/signed-url.ts
CHANGED
|
@@ -17,6 +17,17 @@ export const DEFAULT_SIGNED_URL_TTL_MS = 900_000;
|
|
|
17
17
|
/** The dev server mounts the download/upload route here; S3 disks never use it. */
|
|
18
18
|
export const DEFAULT_SIGNED_URL_BASE = '/_storage';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The base ONE disk's own URLs hang off: the mount prefix plus the driver's name, because the
|
|
22
|
+
* mounted route is `${DEFAULT_SIGNED_URL_BASE}/:disk/*key` and the disk segment is inside the
|
|
23
|
+
* path the HMAC is recovered from. Declared once and called by both halves — the driver that
|
|
24
|
+
* mints and `accept.ts` that verifies — because a base stated twice is a base that drifts, and a
|
|
25
|
+
* drifted base makes every genuine URL a `signature-mismatch`: the key parses as `local/<key>`.
|
|
26
|
+
*/
|
|
27
|
+
export function signedUrlBaseFor(driverName: string): string {
|
|
28
|
+
return `${DEFAULT_SIGNED_URL_BASE}/${driverName}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
export const SIGNED_URL_PARAMS = {
|
|
21
32
|
method: 'x-method',
|
|
22
33
|
expires: 'x-exp',
|