Skip to content

Commit c88acef

Browse files
Renjiroyalpinto007
andauthored
fix(upload): derive R2 key extension from content type, not filename (#77)
* fix(upload): derive R2 key extension from content type, not filename getPresignedUploadUrl trusted the extension off the user-supplied filename even though contentType is already validated against ALLOWED_TYPES in the presign route. A hostile filename could put an arbitrary extension on a public object key. Map the validated content type to an extension instead, and keep the original filename only as sanitized object metadata. * style: prettier --------- Co-authored-by: royalpinto007 <royalpinto007@gmail.com>
1 parent 767123d commit c88acef

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

lib/r2/upload.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { putObjectCommand, getSignedUrl } = vi.hoisted(() => ({
4+
putObjectCommand: vi.fn((input: unknown) => ({ input })),
5+
getSignedUrl: vi.fn(async () => "https://upload.example/signed"),
6+
}));
7+
8+
vi.mock("@aws-sdk/client-s3", () => ({
9+
S3Client: vi.fn(() => ({})),
10+
PutObjectCommand: putObjectCommand,
11+
GetObjectCommand: vi.fn((input: unknown) => ({ input })),
12+
}));
13+
14+
vi.mock("@aws-sdk/s3-request-presigner", () => ({
15+
getSignedUrl,
16+
}));
17+
18+
import { getPresignedUploadUrl } from "./upload";
19+
20+
describe("getPresignedUploadUrl", () => {
21+
const originalEnv = { ...process.env };
22+
23+
beforeEach(() => {
24+
process.env.R2_ACCOUNT_ID = "test-account";
25+
process.env.R2_ACCESS_KEY_ID = "test-key";
26+
process.env.R2_SECRET_ACCESS_KEY = "test-secret";
27+
process.env.R2_BUCKET_NAME = "test-bucket";
28+
process.env.R2_PUBLIC_URL = "https://cdn.example.com";
29+
putObjectCommand.mockClear();
30+
});
31+
32+
afterEach(() => {
33+
process.env = { ...originalEnv };
34+
});
35+
36+
it.each([
37+
["image/jpeg", "jpg"],
38+
["image/png", "png"],
39+
["image/webp", "webp"],
40+
["image/gif", "gif"],
41+
])("maps %s to a .%s key", async (contentType, ext) => {
42+
const { key } = await getPresignedUploadUrl("whatever.html", contentType);
43+
44+
expect(key).toMatch(new RegExp(`^screenshots/[^/]+\\.${ext}$`));
45+
});
46+
47+
it("ignores a hostile filename with a disallowed extension", async () => {
48+
const { key } = await getPresignedUploadUrl("payload.html", "image/png");
49+
50+
expect(key.endsWith(".html")).toBe(false);
51+
expect(key.endsWith(".png")).toBe(true);
52+
});
53+
54+
it("falls back to .bin for an unrecognized content type", async () => {
55+
const { key } = await getPresignedUploadUrl(
56+
"file",
57+
"application/octet-stream",
58+
);
59+
60+
expect(key).toMatch(/^screenshots\/[^/]+\.bin$/);
61+
});
62+
63+
it("passes the sanitized original filename as object metadata, not the key", async () => {
64+
await getPresignedUploadUrl("my photo #1!.png", "image/png");
65+
66+
const command = putObjectCommand.mock.calls[0][0] as {
67+
Metadata?: Record<string, string>;
68+
};
69+
expect(command.Metadata?.["original-filename"]).toBe("my_photo__1_.png");
70+
});
71+
});

lib/r2/upload.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,27 @@ import { getR2PublicBaseUrl } from "@/lib/utils/urls";
99

1010
const R2_ENDPOINT = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
1111

12+
const EXT_BY_CONTENT_TYPE: Record<string, string> = {
13+
"image/jpeg": "jpg",
14+
"image/png": "png",
15+
"image/webp": "webp",
16+
"image/gif": "gif",
17+
};
18+
19+
/**
20+
* Maps a validated content type to a storage extension. Never derive the
21+
* extension from a user-supplied filename — it isn't checked against the
22+
* allowlist the route validates `contentType` with.
23+
*/
24+
function extensionForContentType(contentType: string): string {
25+
return EXT_BY_CONTENT_TYPE[contentType] ?? "bin";
26+
}
27+
28+
/** Strips the filename down to characters safe for an S3 metadata header. */
29+
function sanitizeFilenameForMetadata(filename: string): string {
30+
return filename.replace(/[^\w.-]/g, "_").slice(0, 255);
31+
}
32+
1233
function getR2Client() {
1334
return new S3Client({
1435
region: "auto",
@@ -38,14 +59,15 @@ export async function getPresignedUploadUrl(
3859
contentType: string,
3960
folder = "screenshots",
4061
): Promise<PresignUploadResult> {
41-
const ext = filename.split(".").pop() ?? "bin";
62+
const ext = extensionForContentType(contentType);
4263
const key = `${folder}/${randomUUID()}.${ext}`;
4364

4465
const client = getR2Client();
4566
const command = new PutObjectCommand({
4667
Bucket: process.env.R2_BUCKET_NAME!,
4768
Key: key,
4869
ContentType: contentType,
70+
Metadata: { "original-filename": sanitizeFilenameForMetadata(filename) },
4971
});
5072

5173
const uploadUrl = await getSignedUrl(client, command, { expiresIn: 300 });

0 commit comments

Comments
 (0)