Compare commits

...

5 Commits

Author SHA1 Message Date
autofix-ci[bot]
315b520c3c [autofix.ci] apply automated fixes 2025-08-06 22:17:14 +00:00
Claude Bot
3b158ae20c Clean up verbose comments
Remove unnecessary verbose comments including:
- AWS SDK compatibility explanations
- Byte count annotations in tests
- Step-by-step test descriptions
- 'THIS SHOULD NOW WORK' style comments

Keeps the code clean and focused on functionality.
2025-08-06 22:13:35 +00:00
autofix-ci[bot]
a87d0f171a [autofix.ci] apply automated fixes 2025-08-06 22:12:45 +00:00
Claude Bot
5e697bb3a9 Add comprehensive integration tests for S3 contentLength restrictions
- Add tests that verify actual HTTP behavior with different payload sizes
- Test exact contentLength (should succeed), less than (should fail), more than (should fail)
- Cover both Bun.s3() and S3Client APIs
- Test both contentLength and ContentLength parameter styles
- Add validation tests for negative values
- Include the exact use case from GitHub issue #18240

These tests ensure the contentLength restriction actually works at the HTTP level,
not just URL generation, providing full coverage of the feature behavior.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 22:07:09 +00:00
Claude Bot
e3df3785cf Add contentLength support to S3 presign method (#18240)
- Add content_length field to S3 SignOptions struct
- Update getPresignUrlFrom to handle contentLength and ContentLength options from JS
- Include Content-Length parameter in presigned URL query string
- Add validation for positive content length values
- Add comprehensive test cases including AWS SDK style ContentLength option
- Support both camelCase (contentLength) and PascalCase (ContentLength) for compatibility

This allows restricting content length on S3 presigned URLs, addressing
the missing functionality reported in issue #18240.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 21:59:37 +00:00
4 changed files with 354 additions and 4 deletions

View File

@@ -467,6 +467,7 @@ pub fn getPresignUrlFrom(this: *Blob, globalThis: *jsc.JSGlobalObject, extra_opt
var method: bun.http.Method = .GET;
var expires: usize = 86400; // 1 day default
var content_length: ?i64 = null;
var credentialsWithOptions: S3.S3CredentialsWithOptions = .{
.credentials = this.store.?.data.s3.getCredentials().*,
@@ -487,6 +488,20 @@ pub fn getPresignUrlFrom(this: *Blob, globalThis: *jsc.JSGlobalObject, extra_opt
if (expires_ <= 0) return globalThis.throwInvalidArguments("expiresIn must be greather than 0", .{});
expires = @intCast(expires_);
}
if (try options.getOptional(globalThis, "contentLength", i64)) |content_length_| {
if (content_length_ < 0) {
return globalThis.throwInvalidArguments("contentLength must be a positive number", .{});
}
content_length = content_length_;
}
if (content_length == null) {
if (try options.getOptional(globalThis, "ContentLength", i64)) |content_length_| {
if (content_length_ < 0) {
return globalThis.throwInvalidArguments("ContentLength must be a positive number", .{});
}
content_length = content_length_;
}
}
}
credentialsWithOptions = try s3.getCredentialsWithOptions(options, globalThis);
}
@@ -495,6 +510,7 @@ pub fn getPresignUrlFrom(this: *Blob, globalThis: *jsc.JSGlobalObject, extra_opt
const result = credentialsWithOptions.credentials.signRequest(.{
.path = path,
.method = method,
.content_length = content_length,
.acl = credentialsWithOptions.acl,
.storage_class = credentialsWithOptions.storage_class,
}, false, .{ .expires = expires }) catch |sign_err| {

View File

@@ -399,6 +399,7 @@ pub const S3Credentials = struct {
content_md5: ?[]const u8 = null,
search_params: ?[]const u8 = null,
content_disposition: ?[]const u8 = null,
content_length: ?i64 = null,
acl: ?ACL = null,
storage_class: ?StorageClass = null,
};
@@ -785,9 +786,13 @@ pub const S3Credentials = struct {
const canonical = brk_canonical: {
var stack_fallback = std.heap.stackFallback(512, bun.default_allocator);
const allocator = stack_fallback.get();
var query_parts: std.BoundedArray([]const u8, 10) = .{};
var query_parts: std.BoundedArray([]const u8, 11) = .{};
// Add parameters in alphabetical order: Content-MD5, X-Amz-Acl, X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-Security-Token, X-Amz-SignedHeaders, x-amz-storage-class
// Add parameters in alphabetical order: Content-Length, Content-MD5, X-Amz-Acl, X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-Security-Token, X-Amz-SignedHeaders, x-amz-storage-class
if (signOptions.content_length) |content_length| {
try query_parts.append(try std.fmt.allocPrint(allocator, "Content-Length={}", .{@as(u64, @intCast(content_length))}));
}
if (encoded_content_md5) |encoded_content_md5_value| {
try query_parts.append(try std.fmt.allocPrint(allocator, "Content-MD5={s}", .{encoded_content_md5_value}));
@@ -836,9 +841,13 @@ pub const S3Credentials = struct {
// Build final URL with query parameters in alphabetical order to match canonical request
var url_stack_fallback = std.heap.stackFallback(512, bun.default_allocator);
const url_allocator = url_stack_fallback.get();
var url_query_parts: std.BoundedArray([]const u8, 10) = .{};
var url_query_parts: std.BoundedArray([]const u8, 11) = .{};
// Add parameters in alphabetical order: Content-MD5, X-Amz-Acl, X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-Security-Token, X-Amz-SignedHeaders, x-amz-storage-class, X-Amz-Signature
// Add parameters in alphabetical order: Content-Length, Content-MD5, X-Amz-Acl, X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-Security-Token, X-Amz-SignedHeaders, x-amz-storage-class, X-Amz-Signature
if (signOptions.content_length) |content_length| {
try url_query_parts.append(try std.fmt.allocPrint(url_allocator, "Content-Length={}", .{@as(u64, @intCast(content_length))}));
}
if (encoded_content_md5) |encoded_content_md5_value| {
try url_query_parts.append(try std.fmt.allocPrint(url_allocator, "Content-MD5={s}", .{encoded_content_md5_value}));

View File

@@ -158,6 +158,39 @@ describe.skipIf(!r2Credentials.endpoint && !isCI)("Virtual Hosted-Style", () =>
const url = new URL(presigned);
expect(url.hostname).toBe("bucket.s3.us-east-1.amazonaws.com");
}
{
const client = new Bun.S3Client({
virtualHostedStyle: true,
bucket: "bucket",
accessKeyId: "test",
secretAccessKey: "test",
region: "us-west-2",
});
const presigned = client.presign("filename.txt", {
expiresIn: 3600,
method: "PUT",
contentLength: 200,
});
const url = new URL(presigned);
expect(url.hostname).toBe("bucket.s3.us-west-2.amazonaws.com");
expect(presigned.includes("Content-Length=200")).toBe(true);
expect(presigned.includes("X-Amz-Expires=3600")).toBe(true);
}
{
const client = new Bun.S3Client({
bucket: "bucket",
accessKeyId: "test",
secretAccessKey: "test",
region: "us-east-1",
});
const presigned = client.presign("filename.txt", {
ContentLength: 10000,
});
const url = new URL(presigned);
expect(presigned.includes("Content-Length=10000")).toBe(true);
}
});
it("inspect", () => {
@@ -422,6 +455,77 @@ for (let credentials of allCredentials) {
}
});
it("should enforce contentLength restrictions on S3Client presigned URLs", async () => {
const testContent = "Test data for S3Client";
const contentLength = testContent.length;
const uploadFilename = bucketInName ? `${S3Bucket}/${randomUUID()}-s3client` : `${randomUUID()}-s3client`;
{
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,
headers: {
"Content-Type": "text/plain",
},
});
expect(response.status).toBe(200);
const file = bucket.file(uploadFilename, options);
const downloaded = await file.text();
expect(downloaded).toBe(testContent);
await file.unlink();
}
{
const presignedUrl = bucket.presign(uploadFilename + "-less", {
method: "PUT",
expiresIn: 3600,
contentLength: contentLength,
});
const shortContent = "Short";
const response = await fetch(presignedUrl, {
method: "PUT",
body: shortContent,
headers: {
"Content-Type": "text/plain",
},
});
expect([400, 403]).toContain(response.status);
}
{
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",
},
});
expect([400, 403]).toContain(response.status);
}
});
it("should be able to upload large files using bucket.write + readable Request", async () => {
{
await bucket.write(
@@ -693,6 +797,107 @@ for (let credentials of allCredentials) {
}
});
it("should enforce contentLength restrictions on PUT presigned URLs", async () => {
const testContent = "Hello, Bun!";
const contentLength = testContent.length;
const uploadFilename = tmp_filename + "-contentlength-test";
{
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,
headers: {
"Content-Type": "text/plain",
},
});
expect(response.status).toBe(200);
const downloaded = await s3file.text();
expect(downloaded).toBe(testContent);
await s3file.unlink();
}
{
const s3file = s3(uploadFilename + "-less", options);
const presignedUrl = s3file.presign({
method: "PUT",
expiresIn: 3600,
contentLength: contentLength,
});
const shortContent = "Short";
const response = await fetch(presignedUrl, {
method: "PUT",
body: shortContent,
headers: {
"Content-Type": "text/plain",
},
});
expect([400, 403]).toContain(response.status);
}
{
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";
const response = await fetch(presignedUrl, {
method: "PUT",
body: longContent,
headers: {
"Content-Type": "text/plain",
},
});
expect([400, 403]).toContain(response.status);
}
});
it("should work with ContentLength (AWS SDK style) restrictions", async () => {
const testData = Buffer.alloc(100, "x");
const uploadFilename = tmp_filename + "-aws-style";
const s3file = s3(uploadFilename, options);
const presignedUrl = s3file.presign({
method: "PUT",
expiresIn: 3600,
ContentLength: 100,
});
expect(presignedUrl.includes("Content-Length=100")).toBe(true);
const response = await fetch(presignedUrl, {
method: "PUT",
body: testData,
headers: {
"Content-Type": "application/octet-stream",
},
});
expect(response.status).toBe(200);
const stat = await s3file.stat();
expect(stat.size).toBe(100);
await s3file.unlink();
});
it("should be able to upload large files in one go using Bun.write", async () => {
{
const s3file = s3(tmp_filename, options);
@@ -1230,6 +1435,36 @@ for (let credentials of allCredentials) {
expect(url.includes("X-Amz-Algorithm")).toBe(true);
expect(url.includes("X-Amz-SignedHeaders")).toBe(true);
});
it("should work with contentLength", async () => {
const s3file = s3("s3://bucket/credentials-test", s3Options);
const url = s3file.presign({
expiresIn: 10,
contentLength: 200,
});
expect(url).toBeDefined();
expect(url.includes("Content-Length=200")).toBe(true);
expect(url.includes("X-Amz-Expires=10")).toBe(true);
expect(url.includes("X-Amz-Date")).toBe(true);
expect(url.includes("X-Amz-Signature")).toBe(true);
expect(url.includes("X-Amz-Credential")).toBe(true);
expect(url.includes("X-Amz-Algorithm")).toBe(true);
expect(url.includes("X-Amz-SignedHeaders")).toBe(true);
});
it("should work with ContentLength (AWS SDK style)", async () => {
const s3file = s3("s3://bucket/credentials-test", s3Options);
const url = s3file.presign({
expiresIn: 10,
ContentLength: 10000,
});
expect(url).toBeDefined();
expect(url.includes("Content-Length=10000")).toBe(true);
expect(url.includes("X-Amz-Expires=10")).toBe(true);
expect(url.includes("X-Amz-Date")).toBe(true);
expect(url.includes("X-Amz-Signature")).toBe(true);
expect(url.includes("X-Amz-Credential")).toBe(true);
expect(url.includes("X-Amz-Algorithm")).toBe(true);
expect(url.includes("X-Amz-SignedHeaders")).toBe(true);
});
it("should work with acl", async () => {
const s3file = s3("s3://bucket/credentials-test", s3Options);
const url = s3file.presign({

View File

@@ -0,0 +1,90 @@
import { S3Client } from "bun";
import { describe, expect, it } from "bun:test";
describe("S3 contentLength option in presign (Issue #18240)", () => {
const s3Client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
bucket: "mybucketname",
endpoint: "https://myaccountid.r2.cloudflarestorage.com",
});
it("should support contentLength option in presign method", () => {
const url = s3Client.presign("test/abc", {
expiresIn: 3600, // 1 hour
method: "PUT",
contentLength: 200,
});
expect(url).toBeDefined();
expect(typeof url).toBe("string");
expect(url.includes("Content-Length=200")).toBe(true);
expect(url.includes("X-Amz-Expires=3600")).toBe(true);
});
it("should support ContentLength option (AWS SDK style)", () => {
const url = s3Client.presign("test/abc", {
expiresIn: 3600,
method: "PUT",
ContentLength: 200,
});
expect(url).toBeDefined();
expect(typeof url).toBe("string");
expect(url.includes("Content-Length=200")).toBe(true);
expect(url.includes("X-Amz-Expires=3600")).toBe(true);
});
it("should work without contentLength (backward compatibility)", () => {
const url = s3Client.presign("test/abc", {
expiresIn: 3600,
method: "PUT",
});
expect(url).toBeDefined();
expect(typeof url).toBe("string");
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,
});
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);
});
});