mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
Compare commits
5 Commits
bun-v1.3.2
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
315b520c3c | ||
|
|
3b158ae20c | ||
|
|
a87d0f171a | ||
|
|
5e697bb3a9 | ||
|
|
e3df3785cf |
@@ -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| {
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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({
|
||||
|
||||
90
test/regression/issue/s3-content-length.test.ts
Normal file
90
test/regression/issue/s3-content-length.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user