@crabcrop/url 0.1.0 → 0.2.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/README.md CHANGED
@@ -48,7 +48,10 @@ const group = purgeGroupSignature("your-secret", "thumb");
48
48
 
49
49
  // Upload signatures for the server's PUT /upload/<signature>/<name>
50
50
  // endpoint live in a third domain, under their own derived subkey.
51
+ // Pass an expiry (unix seconds) for a token that stops working
52
+ // then; its path is /upload/<signature>/exp:<expiry>/<name>.
51
53
  const upload = uploadSignature("your-secret", "heroes/photo.jpg");
54
+ const expiring = uploadSignature("your-secret", "heroes/photo.jpg", 1893456000);
52
55
  ```
53
56
 
54
57
  Pass `null` as the secret to produce the literal `unsafe` signature segment, for servers running with `CRABCROP_ALLOW_UNSAFE` or in presets-only mode.
package/dist/index.d.ts CHANGED
@@ -16,9 +16,12 @@ export declare function sign(secret: string, remainder: string): string;
16
16
  */
17
17
  export declare function purgeSignature(secret: string, target: string): string;
18
18
  /**
19
- * The purge-group signature for a cache group: a purge
20
- * signature over the literal string `purge-group/<group>`,
21
- * which appears nowhere in the request URL. The helper
19
+ * The purge-group signature for a cache group: HMAC-SHA256
20
+ * of the group name under a subkey derived from the secret
21
+ * (the raw HMAC of the secret over the label
22
+ * `crabcrop/purge-group-key`), its own domain beside the
23
+ * purge subkey, so a per-target purge signature can never
24
+ * be transplanted to the purge-group endpoint. The helper
22
25
  * exists so nobody has to know the signed string. This is
23
26
  * the signature segment for
24
27
  * `POST /purge-group/<signature>/<group>`.
@@ -31,8 +34,17 @@ export declare function purgeGroupSignature(secret: string, group: string): stri
31
34
  * so neither an image signature nor a purge signature can
32
35
  * ever be replayed as an upload signature. This is the
33
36
  * signature segment for `PUT /upload/<signature>/<name>`.
37
+ *
38
+ * With `expires` (unix seconds), the signature covers
39
+ * `exp:<expires>/<name>` instead, and the request path is
40
+ * `PUT /upload/<signature>/exp:<expires>/<name>`: the
41
+ * expiry sits inside the signed string, so it can neither
42
+ * be stripped nor extended, and the server refuses the
43
+ * upload once that second is reached. Delegated upload
44
+ * tokens should always use the expiring form; without it
45
+ * a token replays until the secret rotates.
34
46
  */
35
- export declare function uploadSignature(secret: string, name: string): string;
47
+ export declare function uploadSignature(secret: string, name: string, expires?: number): string;
36
48
  export interface BuildOptions {
37
49
  /** Unix-seconds expiry, emitted as a leading `exp:` segment. */
38
50
  expires?: number;
package/dist/index.js CHANGED
@@ -26,15 +26,19 @@ export function purgeSignature(secret, target) {
26
26
  return createHmac("sha256", subkey).update(target, "utf8").digest("hex");
27
27
  }
28
28
  /**
29
- * The purge-group signature for a cache group: a purge
30
- * signature over the literal string `purge-group/<group>`,
31
- * which appears nowhere in the request URL. The helper
29
+ * The purge-group signature for a cache group: HMAC-SHA256
30
+ * of the group name under a subkey derived from the secret
31
+ * (the raw HMAC of the secret over the label
32
+ * `crabcrop/purge-group-key`), its own domain beside the
33
+ * purge subkey, so a per-target purge signature can never
34
+ * be transplanted to the purge-group endpoint. The helper
32
35
  * exists so nobody has to know the signed string. This is
33
36
  * the signature segment for
34
37
  * `POST /purge-group/<signature>/<group>`.
35
38
  */
36
39
  export function purgeGroupSignature(secret, group) {
37
- return purgeSignature(secret, `purge-group/${group}`);
40
+ const subkey = Buffer.from(sign(secret, "crabcrop/purge-group-key"), "hex");
41
+ return createHmac("sha256", subkey).update(group, "utf8").digest("hex");
38
42
  }
39
43
  /**
40
44
  * The upload signature for an object name: HMAC-SHA256 of
@@ -43,10 +47,20 @@ export function purgeGroupSignature(secret, group) {
43
47
  * so neither an image signature nor a purge signature can
44
48
  * ever be replayed as an upload signature. This is the
45
49
  * signature segment for `PUT /upload/<signature>/<name>`.
50
+ *
51
+ * With `expires` (unix seconds), the signature covers
52
+ * `exp:<expires>/<name>` instead, and the request path is
53
+ * `PUT /upload/<signature>/exp:<expires>/<name>`: the
54
+ * expiry sits inside the signed string, so it can neither
55
+ * be stripped nor extended, and the server refuses the
56
+ * upload once that second is reached. Delegated upload
57
+ * tokens should always use the expiring form; without it
58
+ * a token replays until the secret rotates.
46
59
  */
47
- export function uploadSignature(secret, name) {
60
+ export function uploadSignature(secret, name, expires) {
48
61
  const subkey = Buffer.from(sign(secret, "crabcrop/upload-key"), "hex");
49
- return createHmac("sha256", subkey).update(name, "utf8").digest("hex");
62
+ const signed = expires === undefined ? name : `exp:${expires}/${name}`;
63
+ return createHmac("sha256", subkey).update(signed, "utf8").digest("hex");
50
64
  }
51
65
  /** Whether `id` is a well-formed key ID (1-32 of `[A-Za-z0-9_-]`). */
52
66
  export function isValidKeyId(id) {
@@ -175,8 +189,14 @@ export function verifyUrlSignature(secret, input) {
175
189
  return matches(secret);
176
190
  }
177
191
  if (signature.keyId !== undefined) {
178
- const candidate = secret[signature.keyId];
179
- return candidate === undefined ? false : matches(candidate);
192
+ // Own properties only: key IDs are attacker-shaped text
193
+ // (`constructor`, `toString`, `__proto__` are all legal
194
+ // IDs), and an inherited lookup would hand a non-string
195
+ // to the HMAC and throw instead of answering false.
196
+ const candidate = Object.hasOwn(secret, signature.keyId)
197
+ ? secret[signature.keyId]
198
+ : undefined;
199
+ return typeof candidate === "string" ? matches(candidate) : false;
180
200
  }
181
201
  return Object.values(secret).some(matches);
182
202
  }
@@ -89,10 +89,13 @@ function encodeArg(text) {
89
89
  }
90
90
  function className(what, name) {
91
91
  const trimmed = name.trim();
92
- if (trimmed.length < 1 ||
93
- trimmed.length > 64 ||
94
- !/^[\p{L}\p{N} _.\-/]+$/u.test(trimmed)) {
95
- fail(what, name, "1 to 64 characters of letters, digits, space, `_`, `.`, `-`, `/`");
92
+ // The server's bound is on UTF-8 bytes, not UTF-16 code
93
+ // units: a name of multibyte characters can pass a
94
+ // length check here and still be rejected there, so
95
+ // measure the encoded form, as the explainer does.
96
+ const bytes = new TextEncoder().encode(trimmed).length;
97
+ if (bytes < 1 || bytes > 64 || !/^[\p{L}\p{N} _.\-/]+$/u.test(trimmed)) {
98
+ fail(what, name, "1 to 64 UTF-8 bytes of letters, digits, space, `_`, `.`, `-`, `/`");
96
99
  }
97
100
  return encodeArg(name);
98
101
  }
@@ -175,8 +178,12 @@ export const ops = {
175
178
  },
176
179
  /** `preset:thumb`; expands server-side. */
177
180
  preset(name) {
178
- if (name === "" || name.includes("/") || name.includes(":")) {
179
- fail("preset name", name, "a non-empty preset name without `/` or `:`");
181
+ // The server can only configure names from this
182
+ // charset (its preset file validation), so anything
183
+ // outside it is a reference that can only ever answer
184
+ // unknown_preset; refuse it here instead.
185
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
186
+ fail("preset name", name, "letters, digits, `_`, `-` (the server's preset name charset)");
180
187
  }
181
188
  return `preset:${name}`;
182
189
  },
package/package.json CHANGED
@@ -21,7 +21,7 @@
21
21
  "test": "node --test"
22
22
  },
23
23
  "type": "module",
24
- "version": "0.1.0",
24
+ "version": "0.2.0",
25
25
  "devDependencies": {
26
26
  "@types/node": "^26.6.5",
27
27
  "typescript": "^5.9.3"