Skip to content

Commit 43088cf

Browse files
committed
Require exact attachment filenames
1 parent 1ac188f commit 43088cf

6 files changed

Lines changed: 74 additions & 13 deletions

File tree

DESIGN.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,10 @@ Permanent retention is deliberately not the default. It would eventually exhaust
206206

207207
### Public attachment URLs
208208

209-
- Each URL contains an identifier with at least 128 bits of cryptographic randomness.
209+
- Each URL contains a UUID v4 identifier with 122 bits of cryptographic randomness.
210210
- The service exposes no listing, search, or sequential identifier endpoint.
211-
- The filename portion of the URL is cosmetic and is not used as the storage key.
211+
- The filename portion is not used as the storage key, but it must exactly match the stored
212+
attachment filename.
212213
- Possession of the URL grants access to the file.
213214

214215
Opaque URLs are public capability URLs, not access control equivalent to a private GitHub repository. They are suitable for nonsensitive screenshots and build artifacts. Secrets, customer data, private source archives, and other confidential files must not be uploaded.

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,19 @@ npm run dev
245245
- Public URLs are capability URLs, not private-repository authorization.
246246
- The service does not scan downloads for malware.
247247

248+
### Attachment enumeration
249+
250+
The private R2 bucket is not exposed for direct public access, and the Worker exposes no attachment
251+
list or search route. Each public attachment URL combines a cryptographically random UUID with the
252+
exact filename recorded at upload time; the filename is URL-encoded in the request path. Attachment
253+
`GET` and `HEAD` requests with an incorrect filename receive the same `404` response as a missing
254+
attachment, while missing or malformed filename paths also return `404`.
255+
256+
These controls make blind enumeration impractical, but they are not access control. Filenames are
257+
often predictable, and anyone who obtains a complete attachment URL can access it until it expires
258+
or is deleted. Upload only nonsensitive files, and avoid exposing attachment URLs in logs or other
259+
unintended locations.
260+
248261
## Design and license
249262

250263
See [DESIGN.md](DESIGN.md) for the original proposal and design rationale.

openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ components:
138138
name: filename
139139
in: path
140140
required: true
141-
description: Cosmetic display filename; the opaque ID selects the object.
141+
description: Exact attachment filename, URL-encoded in the request path.
142142
schema: { type: string }
143143
schemas:
144144
Attachment:

src/attachment.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,39 @@ import { readObjectPrefix } from "./config";
22
import { errorResponse } from "./http";
33

44
const ATTACHMENT_PATH =
5-
/^\/a\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?:\/[^/]*)?$/i;
5+
/^\/a\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/([^/]+)$/i;
66

77
const INLINE_TYPES = new Set(["image/gif", "image/jpeg", "image/png", "image/webp"]);
88

9+
export interface AttachmentPath {
10+
filename: string;
11+
id: string;
12+
}
13+
914
export function attachmentKey(id: string, env: Pick<Env, "OBJECT_PREFIX">): string {
1015
return `${readObjectPrefix(env)}${id}`;
1116
}
1217

13-
export function parseAttachmentId(pathname: string): string | null {
14-
return ATTACHMENT_PATH.exec(pathname)?.[1]?.toLowerCase() ?? null;
18+
export function parseAttachmentPath(pathname: string): AttachmentPath | null {
19+
const match = ATTACHMENT_PATH.exec(pathname);
20+
const id = match?.[1];
21+
const encodedFilename = match?.[2];
22+
if (id === undefined || encodedFilename === undefined) {
23+
return null;
24+
}
25+
26+
try {
27+
return { filename: decodeURIComponent(encodedFilename), id: id.toLowerCase() };
28+
} catch {
29+
return null;
30+
}
1531
}
1632

17-
export async function serveAttachment(request: Request, env: Env, id: string): Promise<Response> {
33+
export async function serveAttachment(
34+
request: Request,
35+
env: Env,
36+
attachment: AttachmentPath,
37+
): Promise<Response> {
1838
if (request.method === "OPTIONS") {
1939
return new Response(null, {
2040
status: 204,
@@ -26,7 +46,7 @@ export async function serveAttachment(request: Request, env: Env, id: string): P
2646
});
2747
}
2848

29-
const key = attachmentKey(id, env);
49+
const key = attachmentKey(attachment.id, env);
3050
const object =
3151
request.method === "HEAD"
3252
? await env.ATTACHMENTS.head(key)
@@ -36,6 +56,13 @@ export async function serveAttachment(request: Request, env: Env, id: string): P
3656
return errorResponse(404, "attachment_not_found", "The attachment does not exist.");
3757
}
3858

59+
if (object.customMetadata?.filename !== attachment.filename) {
60+
if (hasBody(object)) {
61+
await object.body.cancel();
62+
}
63+
return errorResponse(404, "attachment_not_found", "The attachment does not exist.");
64+
}
65+
3966
if (isExpired(object.customMetadata?.expiresAt)) {
4067
if (hasBody(object)) {
4168
await object.body.cancel();

src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseAttachmentId, serveAttachment } from "./attachment";
1+
import { parseAttachmentPath, serveAttachment } from "./attachment";
22
import { errorResponse, jsonResponse } from "./http";
33
import { handleDelete, handleQuota, handleUpload } from "./upload";
44

@@ -25,12 +25,12 @@ export default {
2525
return await handleDelete(request, env, url.pathname);
2626
}
2727

28-
const attachmentId = parseAttachmentId(url.pathname);
28+
const attachment = parseAttachmentPath(url.pathname);
2929
if (
30-
attachmentId !== null &&
30+
attachment !== null &&
3131
(request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS")
3232
) {
33-
return await serveAttachment(request, env, attachmentId);
33+
return await serveAttachment(request, env, attachment);
3434
}
3535

3636
return errorResponse(404, "not_found", "The requested endpoint does not exist.");

test/attachment.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe("attachment delivery", () => {
2424
inline: "1",
2525
});
2626

27-
const response = await fetchWorker(`/a/${IMAGE_ID}/anything.png`);
27+
const response = await fetchWorker(`/a/${IMAGE_ID}/screen%20shot.png`);
2828

2929
expect(response.status).toBe(200);
3030
expect(response.headers.get("content-type")).toBe("image/png");
@@ -36,6 +36,23 @@ describe("attachment delivery", () => {
3636
expect(new Uint8Array(await response.arrayBuffer())).toEqual(IMAGE_BYTES);
3737
});
3838

39+
it("requires the exact decoded attachment filename", async () => {
40+
const filename = "résumé #1%.png";
41+
await seedObject(IMAGE_ID, IMAGE_BYTES, { filename, inline: "1" });
42+
43+
const matching = await fetchWorker(`/a/${IMAGE_ID}/${encodeURIComponent(filename)}`);
44+
expect(matching.status).toBe(200);
45+
46+
const mismatched = await fetchWorker(`/a/${IMAGE_ID}/resume.png`);
47+
expect(mismatched.status).toBe(404);
48+
await expect(mismatched.json()).resolves.toMatchObject({
49+
error: { code: "attachment_not_found" },
50+
});
51+
52+
expect((await fetchWorker(`/a/${IMAGE_ID}`)).status).toBe(404);
53+
expect((await fetchWorker(`/a/${IMAGE_ID}/bad%encoding.png`)).status).toBe(404);
54+
});
55+
3956
it("forces active content to download", async () => {
4057
const id = "36f38491-c389-4f50-a7de-2f77707ea088";
4158
await seedObject(id, new TextEncoder().encode("<h1>test</h1>"), {
@@ -79,6 +96,9 @@ describe("attachment delivery", () => {
7996
const expired = await fetchWorker(`/a/${expiredId}/expired.png`, { method: "HEAD" });
8097
expect(expired.status).toBe(410);
8198

99+
const disguisedExpired = await fetchWorker(`/a/${expiredId}/other.png`, { method: "HEAD" });
100+
expect(disguisedExpired.status).toBe(404);
101+
82102
const missing = await fetchWorker("/a/7928e125-8d61-45ec-a2d3-2a5f639d342e/missing.png");
83103
expect(missing.status).toBe(404);
84104
await expect(missing.json()).resolves.toMatchObject({

0 commit comments

Comments
 (0)