@crewhaus/tool-objectstore 0.7.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,89 @@
1
+ /**
2
+ * Where the object lives: endpoint, addressing style, bucket, key — resolved
3
+ * into the two strings the signature is computed over, the Host header and
4
+ * the canonical URI.
5
+ *
6
+ * The addressing style is an INPUT, never an inference. `https://minio.acme.io`
7
+ * and `https://s3.us-east-1.amazonaws.com` look the same to a parser, and R2
8
+ * answers to both styles depending on how the bucket was configured. Guessing
9
+ * produces a signature that is internally consistent and unusable: the Host
10
+ * header and the path both move together, so the wrong guess is not a typo
11
+ * the server can correct, it is a different request entirely.
12
+ *
13
+ * Everything here throws `PresignError` on a fact that would produce a URL
14
+ * that cannot work. A refusal now is worth an opaque 403 later, and it is the
15
+ * only feedback available — this package never talks to the store, so it can
16
+ * never learn that the bucket does not exist.
17
+ */
18
+ import { CrewhausError } from "@crewhaus/errors";
19
+ /** A URL this tool would refuse to sign, with the reason in the message. */
20
+ export declare class PresignError extends CrewhausError {
21
+ readonly name = "PresignError";
22
+ constructor(message: string);
23
+ }
24
+ export declare const ADDRESSING_STYLES: readonly ["virtual-hosted", "path"];
25
+ export type AddressingStyle = (typeof ADDRESSING_STYLES)[number];
26
+ /** S3's own limit on a key: 1024 BYTES of UTF-8, not 1024 characters. */
27
+ export declare const MAX_KEY_BYTES = 1024;
28
+ export type Endpoint = {
29
+ scheme: "http" | "https";
30
+ /** Host header value: lowercased, port present only when non-default. */
31
+ host: string;
32
+ hostname: string;
33
+ isIpLiteral: boolean;
34
+ isLoopback: boolean;
35
+ };
36
+ export type Target = {
37
+ host: string;
38
+ /** `https://bucket.s3.amazonaws.com` — no path, no trailing slash. */
39
+ origin: string;
40
+ /** Percent-encoded, leading slash included. */
41
+ canonicalUri: string;
42
+ warnings: string[];
43
+ };
44
+ /**
45
+ * Parse the store's base URL into the pieces the signature needs.
46
+ *
47
+ * The WHATWG parser does three things here that a hand-rolled split would
48
+ * get wrong: it lowercases the host, it converts an internationalized host to
49
+ * punycode (which is what the Host header must carry), and it drops the port
50
+ * when it is the scheme's default. That last one is load-bearing — a Host
51
+ * header of `s3.amazonaws.com:443` signs fine and then fails, because every
52
+ * HTTP client omits the default port and the server canonicalizes what it
53
+ * received, not what was meant.
54
+ */
55
+ export declare function parseEndpoint(raw: string): Endpoint;
56
+ /**
57
+ * Check a bucket name, and say what is wrong in the terms of the style that
58
+ * will be used.
59
+ *
60
+ * Virtual-hosted style is strict because the name becomes a DNS label: an
61
+ * underscore or a capital cannot be a hostname, so the request would not even
62
+ * reach the store. Path style is permissive because the name is just a path
63
+ * segment — legacy us-east-1 buckets with capitals predate the modern rules
64
+ * and still work there — but it warns, because such a bucket can never be
65
+ * addressed the other way.
66
+ */
67
+ export declare function checkBucket(bucket: string, style: AddressingStyle): string[];
68
+ /**
69
+ * Check an object key.
70
+ *
71
+ * The dot-segment refusal is the one that repays itself. S3 keys may legally
72
+ * contain `.` and `..` segments, and S3 does not normalize the request path —
73
+ * but nearly every HTTP client does, before sending. So a URL signed over
74
+ * `/a/../b.txt` is sent as `/b.txt`, the signature is computed over a path
75
+ * that never arrives, and the store answers 403 with no mention of paths.
76
+ * Signing it would be minting a URL that is broken by design.
77
+ */
78
+ export declare function checkKey(key: string): void;
79
+ /**
80
+ * Resolve endpoint + style + bucket + key into the Host header and the
81
+ * canonical URI. These two strings are what the signature commits to; every
82
+ * other difference between the two styles follows from them.
83
+ */
84
+ export declare function resolveTarget(input: {
85
+ endpoint: Endpoint;
86
+ style: AddressingStyle;
87
+ bucket: string;
88
+ key: string;
89
+ }): Target;
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Where the object lives: endpoint, addressing style, bucket, key — resolved
3
+ * into the two strings the signature is computed over, the Host header and
4
+ * the canonical URI.
5
+ *
6
+ * The addressing style is an INPUT, never an inference. `https://minio.acme.io`
7
+ * and `https://s3.us-east-1.amazonaws.com` look the same to a parser, and R2
8
+ * answers to both styles depending on how the bucket was configured. Guessing
9
+ * produces a signature that is internally consistent and unusable: the Host
10
+ * header and the path both move together, so the wrong guess is not a typo
11
+ * the server can correct, it is a different request entirely.
12
+ *
13
+ * Everything here throws `PresignError` on a fact that would produce a URL
14
+ * that cannot work. A refusal now is worth an opaque 403 later, and it is the
15
+ * only feedback available — this package never talks to the store, so it can
16
+ * never learn that the bucket does not exist.
17
+ */
18
+ import { CrewhausError } from "@crewhaus/errors";
19
+ import { encodePath, encodeRfc3986, utf8Length } from "./encode";
20
+ /** A URL this tool would refuse to sign, with the reason in the message. */
21
+ export class PresignError extends CrewhausError {
22
+ name = "PresignError";
23
+ constructor(message) {
24
+ super("tool", message);
25
+ }
26
+ }
27
+ export const ADDRESSING_STYLES = ["virtual-hosted", "path"];
28
+ /** S3's own limit on a key: 1024 BYTES of UTF-8, not 1024 characters. */
29
+ export const MAX_KEY_BYTES = 1024;
30
+ function isIpv4(hostname) {
31
+ return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname);
32
+ }
33
+ /**
34
+ * Is this host the local machine?
35
+ *
36
+ * Decided on the parsed ADDRESS, never on the text of the hostname. The trap
37
+ * this replaced was `/^127\./.test(hostname)`, which is a prefix match on a
38
+ * string: `127.0.0.1.evil.example` is an ordinary registered domain that
39
+ * somebody else controls, and it passes that test. The consequence is not
40
+ * cosmetic — loopback is the one place `parseEndpoint` permits plain http, on
41
+ * the grounds that there is no wire to listen on, and a presigned URL is a
42
+ * bearer credential for the object until it expires. A hostname that merely
43
+ * begins with "127." put that credential, and the object, on the open network
44
+ * in clear.
45
+ *
46
+ * Requiring an IPv4 literal first is exact rather than approximate, because
47
+ * the WHATWG parser has already normalized every form of one to dotted quad:
48
+ * `0x7f000001`, `127.1` and `127.0.0.1` all arrive here as `127.0.0.1`, and a
49
+ * host whose last label is not numeric is a domain, never an address.
50
+ *
51
+ * IPv4-mapped IPv6 (`[::ffff:7f00:1]`) is loopback in fact and is NOT
52
+ * accepted here. That direction is safe: it costs a clear refusal, where the
53
+ * opposite mistake costs a credential.
54
+ */
55
+ function isLoopbackHost(hostname) {
56
+ if (hostname === "localhost" || hostname.endsWith(".localhost"))
57
+ return true;
58
+ if (hostname === "[::1]")
59
+ return true;
60
+ return isIpv4(hostname) && hostname.startsWith("127.");
61
+ }
62
+ /**
63
+ * Quote an endpoint back in a refusal, WITHOUT anything credential-shaped.
64
+ *
65
+ * Two things a refused endpoint can carry, and both of them get quoted into
66
+ * an error message, which is the one string guaranteed to be copied onward
67
+ * into a transcript, a log line and a bug report:
68
+ *
69
+ * - USERINFO. Two of the checks in `parseEndpoint` run before the one that
70
+ * refuses `https://KEY:SECRET@host`: a bad port never reaches the URL
71
+ * parser at all, and a non-http scheme is rejected first. So those two
72
+ * quoted the secret straight back.
73
+ * - A QUERY STRING. The likeliest way an endpoint acquires one is somebody
74
+ * pasting a whole presigned URL into the field — which carries
75
+ * `X-Amz-Signature` and, on temporary credentials, a live
76
+ * `X-Amz-Security-Token`.
77
+ *
78
+ * Redacting at the point of echo rather than relying on check order, because
79
+ * check order is exactly what the next edit changes. The host and the path
80
+ * survive, which is all the message needs to identify what was refused.
81
+ */
82
+ function quoteEndpoint(raw) {
83
+ const trimmed = raw.trim();
84
+ const scheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.exec(trimmed);
85
+ const prefix = scheme === null ? "" : scheme[0];
86
+ const rest = trimmed.slice(prefix.length);
87
+ const cut = rest.search(/[/?#]/);
88
+ const authority = cut === -1 ? rest : rest.slice(0, cut);
89
+ const at = authority.lastIndexOf("@");
90
+ const shown = at === -1 ? authority : `<redacted>@${authority.slice(at + 1)}`;
91
+ if (cut === -1)
92
+ return `${prefix}${shown}`;
93
+ const tail = rest.slice(cut);
94
+ const mark = tail.search(/[?#]/);
95
+ const path = mark === -1 ? tail : tail.slice(0, mark);
96
+ const suffix = mark === -1 ? "" : `${tail[mark]}<redacted>`;
97
+ return `${prefix}${shown}${path}${suffix}`;
98
+ }
99
+ /**
100
+ * Parse the store's base URL into the pieces the signature needs.
101
+ *
102
+ * The WHATWG parser does three things here that a hand-rolled split would
103
+ * get wrong: it lowercases the host, it converts an internationalized host to
104
+ * punycode (which is what the Host header must carry), and it drops the port
105
+ * when it is the scheme's default. That last one is load-bearing — a Host
106
+ * header of `s3.amazonaws.com:443` signs fine and then fails, because every
107
+ * HTTP client omits the default port and the server canonicalizes what it
108
+ * received, not what was meant.
109
+ */
110
+ export function parseEndpoint(raw) {
111
+ let url;
112
+ try {
113
+ url = new URL(raw.trim());
114
+ }
115
+ catch {
116
+ throw new PresignError(`endpoint "${quoteEndpoint(raw)}" is not a URL — write the store's origin, e.g. https://s3.us-east-1.amazonaws.com or http://127.0.0.1:9000`);
117
+ }
118
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
119
+ throw new PresignError(`endpoint "${quoteEndpoint(raw)}" uses ${url.protocol} — an object-store endpoint is http or https`);
120
+ }
121
+ if (url.username !== "" || url.password !== "") {
122
+ throw new PresignError("endpoint carries a username or password — SigV4 credentials go in accessKeyId/secretAccessKey, and a credential in the URL would be signed into the result");
123
+ }
124
+ if (url.search !== "" || url.hash !== "") {
125
+ throw new PresignError(`endpoint "${quoteEndpoint(raw)}" carries a query string or fragment; pass the origin only`);
126
+ }
127
+ if (url.pathname !== "/") {
128
+ // A path-prefixed gateway (`https://gw.acme.io/s3`) is a real deployment,
129
+ // and it is refused rather than guessed at: the prefix belongs in the
130
+ // canonical URI ahead of the bucket, and whether THIS gateway wants it
131
+ // signed is a fact about the gateway that nothing here can check.
132
+ throw new PresignError(`endpoint "${quoteEndpoint(raw)}" has a path ("${url.pathname}") — this tool signs a path built from the bucket and key, so a prefix on the endpoint would be signed twice or not at all. Pass the origin only`);
133
+ }
134
+ if (url.hostname === "")
135
+ throw new PresignError(`endpoint "${quoteEndpoint(raw)}" has no host`);
136
+ const hostname = url.hostname;
137
+ const isLoopback = isLoopbackHost(hostname);
138
+ const scheme = url.protocol === "https:" ? "https" : "http";
139
+ if (scheme === "http" && !isLoopback) {
140
+ // A presigned URL IS the credential for one object until it expires.
141
+ // Over http it, and the object, cross the network in clear to anyone on
142
+ // the path. Loopback is exempted because that is where MinIO runs in
143
+ // development and there is no network to listen on.
144
+ throw new PresignError(`endpoint "${quoteEndpoint(raw)}" is plain http and "${hostname}" is not loopback — a presigned URL is a bearer credential for the object, and http puts it and the object on the wire in clear. Use https, or point at localhost for a local MinIO`);
145
+ }
146
+ return {
147
+ scheme,
148
+ host: url.host,
149
+ hostname,
150
+ isIpLiteral: isIpv4(hostname) || hostname.startsWith("["),
151
+ isLoopback,
152
+ };
153
+ }
154
+ /**
155
+ * Check a bucket name, and say what is wrong in the terms of the style that
156
+ * will be used.
157
+ *
158
+ * Virtual-hosted style is strict because the name becomes a DNS label: an
159
+ * underscore or a capital cannot be a hostname, so the request would not even
160
+ * reach the store. Path style is permissive because the name is just a path
161
+ * segment — legacy us-east-1 buckets with capitals predate the modern rules
162
+ * and still work there — but it warns, because such a bucket can never be
163
+ * addressed the other way.
164
+ */
165
+ export function checkBucket(bucket, style) {
166
+ const warnings = [];
167
+ if (bucket.length === 0)
168
+ throw new PresignError("bucket is empty");
169
+ if (bucket.length > 255) {
170
+ throw new PresignError(`bucket name is ${bucket.length} characters; the ceiling is 255`);
171
+ }
172
+ if (bucket.includes("/")) {
173
+ throw new PresignError(`bucket "${bucket}" contains "/" — pass the bucket alone and put the rest in key`);
174
+ }
175
+ const modern = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) && !bucket.includes("..");
176
+ if (style === "virtual-hosted") {
177
+ if (!modern) {
178
+ throw new PresignError(`bucket "${bucket}" cannot be a DNS label, so virtual-hosted addressing cannot reach it: names must be 3-63 characters of lowercase letters, digits, dots and hyphens, start and end alphanumeric, and contain no "..". Use addressingStyle "path" for a legacy or non-conforming name`);
179
+ }
180
+ if (isIpv4(bucket)) {
181
+ throw new PresignError(`bucket "${bucket}" is shaped like an IPv4 address, which S3 forbids`);
182
+ }
183
+ if (bucket.includes(".")) {
184
+ warnings.push(`bucket "${bucket}" contains a dot: over https the wildcard certificate for the endpoint covers one label, so TLS verification typically fails for a dotted bucket in virtual-hosted style. Path style avoids it.`);
185
+ }
186
+ }
187
+ else if (!modern) {
188
+ // No length exemption here. There used to be a `bucket.length > 1` guard
189
+ // in front of this check, and it let a bucket of "." through entirely
190
+ // unvalidated: path style then signed "/./key" — the very dot segment
191
+ // `checkKey` refuses in a key, for the reason given there. Every HTTP
192
+ // client resolves it away before sending, so the signature would cover a
193
+ // path that never arrives and the store would answer 403 without
194
+ // mentioning paths. A one-character bucket is checked like any other.
195
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(bucket)) {
196
+ throw new PresignError(`bucket "${bucket}" is not a usable path segment: it must start and end alphanumeric and contain only letters, digits, dots, hyphens and underscores`);
197
+ }
198
+ warnings.push(`bucket "${bucket}" does not meet current S3 naming rules (3-63 lowercase alphanumerics, dots and hyphens), so it can only ever be addressed path-style.`);
199
+ }
200
+ return warnings;
201
+ }
202
+ /**
203
+ * Check an object key.
204
+ *
205
+ * The dot-segment refusal is the one that repays itself. S3 keys may legally
206
+ * contain `.` and `..` segments, and S3 does not normalize the request path —
207
+ * but nearly every HTTP client does, before sending. So a URL signed over
208
+ * `/a/../b.txt` is sent as `/b.txt`, the signature is computed over a path
209
+ * that never arrives, and the store answers 403 with no mention of paths.
210
+ * Signing it would be minting a URL that is broken by design.
211
+ */
212
+ export function checkKey(key) {
213
+ if (key.length === 0)
214
+ throw new PresignError("key is empty");
215
+ const bytes = utf8Length(key);
216
+ if (bytes > MAX_KEY_BYTES) {
217
+ throw new PresignError(`key is ${bytes} bytes of UTF-8 and S3's limit is ${MAX_KEY_BYTES} bytes (the limit counts bytes, not characters, so non-ASCII keys reach it sooner)`);
218
+ }
219
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: refusing C0 controls is the point.
220
+ const control = key.match(/[\x00-\x1f\x7f]/);
221
+ if (control !== null) {
222
+ const code = control[0].charCodeAt(0).toString(16).padStart(4, "0").toUpperCase();
223
+ throw new PresignError(`key contains the control character U+${code} — S3 permits it, but it cannot survive a URL that a human or a log line passes along, and a bare CR or LF splits the request in a careless client`);
224
+ }
225
+ if (key.startsWith("/")) {
226
+ throw new PresignError(`key "${key}" starts with "/" — the canonical URI already supplies it, so this would sign "//…", a different object from the one meant`);
227
+ }
228
+ if (key.includes("//")) {
229
+ throw new PresignError(`key "${key}" contains an empty path segment ("//"). S3 would store it, but URL normalization in most clients collapses it before the request is sent, so the signature would be over a path that never arrives`);
230
+ }
231
+ const segments = key.split("/");
232
+ if (segments.includes(".") || segments.includes("..")) {
233
+ throw new PresignError(`key "${key}" contains a "." or ".." segment. HTTP clients resolve those before sending, so the request would arrive with a different path than the one signed and the store would answer 403 without explaining why`);
234
+ }
235
+ }
236
+ /**
237
+ * Resolve endpoint + style + bucket + key into the Host header and the
238
+ * canonical URI. These two strings are what the signature commits to; every
239
+ * other difference between the two styles follows from them.
240
+ */
241
+ export function resolveTarget(input) {
242
+ const warnings = checkBucket(input.bucket, input.style);
243
+ checkKey(input.key);
244
+ if (input.style === "path") {
245
+ return {
246
+ host: input.endpoint.host,
247
+ origin: `${input.endpoint.scheme}://${input.endpoint.host}`,
248
+ canonicalUri: `/${encodeRfc3986(input.bucket)}/${encodePath(input.key)}`,
249
+ warnings,
250
+ };
251
+ }
252
+ if (input.endpoint.isIpLiteral) {
253
+ throw new PresignError(`endpoint host "${input.endpoint.hostname}" is an IP address, which cannot carry "${input.bucket}." as a subdomain. A store reached by IP — a local MinIO, most commonly — is addressed with addressingStyle "path"`);
254
+ }
255
+ if (input.endpoint.hostname.startsWith(`${input.bucket.toLowerCase()}.`)) {
256
+ throw new PresignError(`endpoint host "${input.endpoint.hostname}" already begins with the bucket name, so virtual-hosted addressing would sign "${input.bucket}.${input.endpoint.hostname}". Pass the store's own origin (e.g. https://s3.us-east-1.amazonaws.com) and let the bucket be added here`);
257
+ }
258
+ const host = `${input.bucket}.${input.endpoint.host}`;
259
+ return {
260
+ host,
261
+ origin: `${input.endpoint.scheme}://${host}`,
262
+ canonicalUri: `/${encodePath(input.key)}`,
263
+ warnings,
264
+ };
265
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@crewhaus/tool-objectstore",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "description": "Deterministic object-store URL signing: SigV4 presigned GET and PUT, computed offline",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test src"
16
+ },
17
+ "dependencies": {
18
+ "@crewhaus/errors": "0.7.0",
19
+ "@crewhaus/tool-builder": "0.7.0",
20
+ "@crewhaus/tool-catalog": "0.7.0",
21
+ "@crewhaus/tool-encode": "0.7.0",
22
+ "zod": "^3.23.8"
23
+ },
24
+ "devDependencies": {
25
+ "@crewhaus/tool-executor": "workspace:*"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "author": {
29
+ "name": "Max Meier",
30
+ "email": "max@crewhaus.ai",
31
+ "url": "https://crewhaus.ai"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/crewhaus/factory.git",
36
+ "directory": "packages/tool-objectstore"
37
+ },
38
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-objectstore#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/crewhaus/factory/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
46
+ }