diff --git a/server/common/getPresignedUrl.test.ts b/server/common/getPresignedUrl.test.ts new file mode 100644 index 000000000..8260ece53 --- /dev/null +++ b/server/common/getPresignedUrl.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; + +// Regression guard: a trailing space in S3_BUCKET_NAME (or the keys) once made +// every presigned PUT fail with 400 InvalidBucketName. These values must be +// trimmed before they reach the bucket name / SigV4 credential. +describe("getPresignedUrl whitespace hardening", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let getPresignedUrl: any; + + beforeAll(async () => { + // Whitespace-padded credentials, set before import so the s3 client (built + // at module load) sees them. + process.env.ACCESS_KEY = "AKIATESTKEY1234567890 "; + process.env.SECRET_KEY = "secretsecretsecretsecretsecretsecret1234\n"; + ({ getPresignedUrl } = await import("@/server/common/getPresignedUrl")); + }); + + afterAll(() => { + delete process.env.ACCESS_KEY; + delete process.env.SECRET_KEY; + delete process.env.S3_BUCKET_NAME; + }); + + it("trims a trailing space in S3_BUCKET_NAME so the PUT targets the real bucket", async () => { + process.env.S3_BUCKET_NAME = "codu.uploads "; // <- the prod footgun + const url = await getPresignedUrl("image/png", 123, { + kind: "uploads", + userId: "u1", + }); + expect(url).toContain("/codu.uploads/"); + expect(url).not.toContain("codu.uploads%20"); + expect(url).not.toContain("codu.uploads "); + }); + + it("trims whitespace in the access key used to sign the URL", async () => { + process.env.S3_BUCKET_NAME = "codu.uploads"; + const url = await getPresignedUrl("image/png", 123, { + kind: "uploads", + userId: "u1", + }); + const cred = new URL(url).searchParams.get("X-Amz-Credential") ?? ""; + expect(cred.startsWith("AKIATESTKEY1234567890/")).toBe(true); + expect(cred).not.toContain("%20"); + }); +}); diff --git a/server/common/getPresignedUrl.ts b/server/common/getPresignedUrl.ts index a20eee233..099a4e674 100644 --- a/server/common/getPresignedUrl.ts +++ b/server/common/getPresignedUrl.ts @@ -42,13 +42,14 @@ export const getPresignedUrl = async ( const Key = getKey(config, extension); const putCommand = new PutObjectCommand({ - Bucket: process.env.S3_BUCKET_NAME, + // Trim: a stray space in S3_BUCKET_NAME (invisible in host UIs) makes S3 + // reject every PUT with 400 InvalidBucketName. + Bucket: process.env.S3_BUCKET_NAME?.trim(), Key, ContentType: `image/${fileType}`, ContentLength: fileSize, }); - // @FIX TS ERROR const putUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: 3600, }); diff --git a/utils/s3helpers.ts b/utils/s3helpers.ts index 00d4382ca..e70b1717d 100644 --- a/utils/s3helpers.ts +++ b/utils/s3helpers.ts @@ -1,14 +1,17 @@ import { S3Client } from "@aws-sdk/client-s3"; -const hasAccessKeys = process.env.ACCESS_KEY && process.env.SECRET_KEY; +// Trim: stray whitespace in the key env vars otherwise breaks SigV4 signing. +const accessKeyId = process.env.ACCESS_KEY?.trim(); +const secretAccessKey = process.env.SECRET_KEY?.trim(); +const hasAccessKeys = accessKeyId && secretAccessKey; export const s3Client = new S3Client({ region: "eu-west-1", ...(hasAccessKeys ? { credentials: { - accessKeyId: process.env.ACCESS_KEY || "", - secretAccessKey: process.env.SECRET_KEY || "", + accessKeyId, + secretAccessKey, }, } : {}),