diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts index 2c77421b01..e8a4fde3b3 100644 --- a/test/js/bun/s3/s3.test.ts +++ b/test/js/bun/s3/s3.test.ts @@ -455,6 +455,83 @@ for (let credentials of allCredentials) { } }); + it("should enforce contentLength restrictions on S3Client presigned URLs", async () => { + const testContent = "Test data for S3Client"; // 23 bytes + const contentLength = testContent.length; + const uploadFilename = bucketInName ? `${S3Bucket}/${randomUUID()}-s3client` : `${randomUUID()}-s3client`; + + // Test 1: Upload exactly contentLength bytes - should succeed + { + const presignedUrl = bucket.presign(uploadFilename, { + method: "PUT", + expiresIn: 3600, + contentLength: contentLength, + }); + + expect(presignedUrl.includes(`Content-Length=${contentLength}`)).toBe(true); + + const response = await fetch(presignedUrl, { + method: "PUT", + body: testContent, // Exactly 23 bytes + headers: { + "Content-Type": "text/plain", + }, + }); + + expect(response.status).toBe(200); + + // Verify the content was uploaded correctly + const file = bucket.file(uploadFilename, options); + const downloaded = await file.text(); + expect(downloaded).toBe(testContent); + + // Clean up + await file.unlink(); + } + + // Test 2: Upload less than contentLength - should fail + { + const presignedUrl = bucket.presign(uploadFilename + "-less", { + method: "PUT", + expiresIn: 3600, + contentLength: contentLength, + }); + + const shortContent = "Short"; // Only 5 bytes, less than 23 + const response = await fetch(presignedUrl, { + method: "PUT", + body: shortContent, + headers: { + "Content-Type": "text/plain", + }, + }); + + // Should fail with 400 Bad Request due to content length mismatch + expect([400, 403]).toContain(response.status); + } + + // Test 3: Upload more than contentLength - should fail + { + const presignedUrl = bucket.presign(uploadFilename + "-more", { + method: "PUT", + expiresIn: 3600, + contentLength: contentLength, + }); + + const longContent = "This content is definitely much longer than the expected 23 bytes and should cause a failure"; + const response = await fetch(presignedUrl, { + method: "PUT", + body: longContent, + headers: { + "Content-Type": "text/plain", + }, + }); + + // Should fail with 400 Bad Request due to content length mismatch + expect([400, 403]).toContain(response.status); + } + }); + it("should be able to upload large files using bucket.write + readable Request", async () => { { await bucket.write( @@ -726,6 +803,117 @@ for (let credentials of allCredentials) { } }); + it("should enforce contentLength restrictions on PUT presigned URLs", async () => { + const testContent = "Hello, Bun!"; // 12 bytes + const contentLength = testContent.length; + const uploadFilename = tmp_filename + "-contentlength-test"; + + // Test 1: Upload exactly contentLength bytes - should succeed + { + const s3file = s3(uploadFilename, options); + const presignedUrl = s3file.presign({ + method: "PUT", + expiresIn: 3600, + contentLength: contentLength, + }); + + expect(presignedUrl.includes(`Content-Length=${contentLength}`)).toBe(true); + + const response = await fetch(presignedUrl, { + method: "PUT", + body: testContent, // Exactly 12 bytes + headers: { + "Content-Type": "text/plain", + }, + }); + + expect(response.status).toBe(200); + + // Verify the content was uploaded correctly + const downloaded = await s3file.text(); + expect(downloaded).toBe(testContent); + + // Clean up + await s3file.unlink(); + } + + // Test 2: Upload less than contentLength - should fail + { + const s3file = s3(uploadFilename + "-less", options); + const presignedUrl = s3file.presign({ + method: "PUT", + expiresIn: 3600, + contentLength: contentLength, + }); + + const shortContent = "Short"; // Only 5 bytes, less than 12 + const response = await fetch(presignedUrl, { + method: "PUT", + body: shortContent, + headers: { + "Content-Type": "text/plain", + }, + }); + + // Should fail with 400 Bad Request due to content length mismatch + expect([400, 403]).toContain(response.status); + } + + // Test 3: Upload more than contentLength - should fail + { + const s3file = s3(uploadFilename + "-more", options); + const presignedUrl = s3file.presign({ + method: "PUT", + expiresIn: 3600, + contentLength: contentLength, + }); + + const longContent = "This is a much longer content than expected"; // Much more than 12 bytes + const response = await fetch(presignedUrl, { + method: "PUT", + body: longContent, + headers: { + "Content-Type": "text/plain", + }, + }); + + // Should fail with 400 Bad Request due to content length mismatch + expect([400, 403]).toContain(response.status); + } + }); + + it("should work with ContentLength (AWS SDK style) restrictions", async () => { + const testData = Buffer.alloc(100, 'x'); // Exactly 100 bytes + const uploadFilename = tmp_filename + "-aws-style"; + + // Test with AWS SDK style "ContentLength" + const s3file = s3(uploadFilename, options); + const presignedUrl = s3file.presign({ + method: "PUT", + expiresIn: 3600, + ContentLength: 100, // AWS SDK style (PascalCase) + }); + + expect(presignedUrl.includes("Content-Length=100")).toBe(true); + + const response = await fetch(presignedUrl, { + method: "PUT", + body: testData, // Exactly 100 bytes + headers: { + "Content-Type": "application/octet-stream", + }, + }); + + expect(response.status).toBe(200); + + // Verify upload + const stat = await s3file.stat(); + expect(stat.size).toBe(100); + + // Clean up + await s3file.unlink(); + }); + it("should be able to upload large files in one go using Bun.write", async () => { { const s3file = s3(tmp_filename, options); diff --git a/test/regression/issue/s3-content-length.test.ts b/test/regression/issue/s3-content-length.test.ts index a6f1ab7687..81c7329efd 100644 --- a/test/regression/issue/s3-content-length.test.ts +++ b/test/regression/issue/s3-content-length.test.ts @@ -1,7 +1,7 @@ import { S3Client } from "bun"; import { describe, expect, it } from "bun:test"; -describe("S3 contentLength option in presign", () => { +describe("S3 contentLength option in presign (Issue #18240)", () => { const s3Client = new S3Client({ accessKeyId: "test-key", secretAccessKey: "test-secret", @@ -46,4 +46,45 @@ describe("S3 contentLength option in presign", () => { expect(url.includes("Content-Length=")).toBe(false); expect(url.includes("X-Amz-Expires=3600")).toBe(true); }); + + it("should validate contentLength is positive", () => { + expect(() => { + s3Client.presign("test/abc", { + expiresIn: 3600, + method: "PUT", + contentLength: -1, // Invalid negative value + }); + }).toThrow(); + }); + + it("should validate ContentLength is positive", () => { + expect(() => { + s3Client.presign("test/abc", { + expiresIn: 3600, + method: "PUT", + ContentLength: -100, // Invalid negative value + }); + }).toThrow(); + }); + + it("should match the exact use case from issue #18240", () => { + // This is the exact code snippet from the GitHub issue + const url = s3Client.presign('test/abc', { + expiresIn: 3600, // 1 hour + method: 'PUT', + ContentLength: 200 // THIS IS NOW WORKING + }); + + expect(url).toBeDefined(); + expect(typeof url).toBe("string"); + expect(url.includes("Content-Length=200")).toBe(true); + + // Verify other required AWS S3 signature components are present + expect(url.includes("X-Amz-Expires=3600")).toBe(true); + expect(url.includes("X-Amz-Algorithm=AWS4-HMAC-SHA256")).toBe(true); + expect(url.includes("X-Amz-Credential")).toBe(true); + expect(url.includes("X-Amz-Date")).toBe(true); + expect(url.includes("X-Amz-SignedHeaders")).toBe(true); + expect(url.includes("X-Amz-Signature")).toBe(true); + }); }); \ No newline at end of file