Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions src/runtime/webcore/Body.zig
Original file line number Diff line number Diff line change
Expand Up @@ -764,16 +764,26 @@ pub const Value = union(Tag) {
blob.content_type = mimeType.value;
blob.content_type_allocated = allocated;
blob.content_type_was_set = true;
if (blob.store != null) {
blob.store.?.mime_type = mimeType;
if (blob.store) |store| {
// The Store is refcounted and can outlive this Blob (e.g. via
// blob.slice()). When `mimeType.value` is heap-allocated it is
// owned by `blob.content_type` and freed in Blob.deinit, so the
// Store must hold its own copy rather than alias the Blob's.
store.setMimeType(
if (allocated)
.{ .value = bun.handleOom(bun.default_allocator.dupe(u8, mimeType.value)), .category = mimeType.category }
else
mimeType,
allocated,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
if (!blob.content_type_was_set and blob.store != null) {
blob.content_type = MimeType.text.value;
blob.content_type_allocated = false;
blob.content_type_was_set = true;
blob.store.?.mime_type = MimeType.text;
blob.store.?.setMimeType(MimeType.text, false);
}
try promise.resolve(global, blob.toJS(global));
},
Expand Down Expand Up @@ -1440,16 +1450,26 @@ pub fn Mixin(comptime Type: type) type {
blob.content_type = mimeType.value;
blob.content_type_allocated = allocated;
blob.content_type_was_set = true;
if (blob.store != null) {
blob.store.?.mime_type = mimeType;
if (blob.store) |store| {
// The Store is refcounted and can outlive this Blob (e.g. via
// blob.slice()). When `mimeType.value` is heap-allocated it is
// owned by `blob.content_type` and freed in Blob.deinit, so the
// Store must hold its own copy rather than alias the Blob's.
store.setMimeType(
if (allocated)
.{ .value = bun.handleOom(bun.default_allocator.dupe(u8, mimeType.value)), .category = mimeType.category }
else
mimeType,
allocated,
);
}
}
}
if (!blob.content_type_was_set and blob.store != null) {
blob.content_type = MimeType.text.value;
blob.content_type_allocated = false;
blob.content_type_was_set = true;
blob.store.?.mime_type = MimeType.text;
blob.store.?.setMimeType(MimeType.text, false);
}
}
return jsc.JSPromise.resolvedPromiseValue(globalObject, blob.toJS(globalObject));
Expand Down
21 changes: 21 additions & 0 deletions src/runtime/webcore/blob/Store.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ const Store = @This();
data: Data,

mime_type: MimeType = .none,
/// When true, `mime_type.value` is heap-allocated via `bun.default_allocator`
/// and owned by this Store. `deinit` frees it. Every other `mime_type.value`
/// must be a static string (registry entry or comptime literal).
mime_type_allocated: bool = false,
ref_count: std.atomic.Value(u32) = .init(1),
is_all_ascii: ?bool = null,
allocator: std.mem.Allocator,
Expand Down Expand Up @@ -49,6 +53,17 @@ pub fn hasOneRef(this: *const Store) bool {
return this.ref_count.load(.monotonic) == 1;
}

/// Replace `mime_type`, freeing any previous heap-allocated value first.
/// When `allocated` is true, `mime` must own a `bun.default_allocator`
/// allocation that the Store takes ownership of.
pub fn setMimeType(this: *Store, mime: MimeType, allocated: bool) void {
if (this.mime_type_allocated) {
bun.default_allocator.free(@constCast(this.mime_type.value));
}
this.mime_type = mime;
this.mime_type_allocated = allocated;
}

/// Caller is responsible for derefing the Store.
pub fn toAnyBlob(this: *Store) ?Blob.Any {
if (this.hasOneRef()) {
Expand Down Expand Up @@ -179,6 +194,12 @@ pub fn deref(this: *Blob.Store) void {
pub fn deinit(this: *Blob.Store) void {
const allocator = this.allocator;

if (this.mime_type_allocated) {
bun.default_allocator.free(@constCast(this.mime_type.value));
this.mime_type = .none;
this.mime_type_allocated = false;
}

switch (this.data) {
.bytes => |*bytes| {
bytes.deinit();
Expand Down
85 changes: 85 additions & 0 deletions test/js/web/fetch/blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,88 @@ test("dupe() preserves allocated content_type for Body clone", () => {
expect(originalType).toStartWith("multipart/form-data; boundary=");
expect(clonedType).toBe(originalType);
});

test.each([
["Body.Value.resolve (fetch)", true],
["getBlobWithThisValue (sync Response)", false],
])("response.blob() does not alias store.mime_type with blob.content_type: %s", async (_, useFetch) => {
// Regression: when the response Content-Type is not a known static mime
// (e.g. "image/png"), `MimeType.init` heap-allocates `.value`. Both the
// returned Blob's `content_type` (owned, freed in Blob.deinit) and the
// shared Store's `mime_type.value` (not owned) were set to the same
// pointer. A slice shares the Store via refcount; when the original Blob
// is collected first its deinit frees `content_type`, leaving the Store's
// `mime_type.value` dangling. Reading `sliced.type` then falls through to
// `store.mime_type.value` -> use-after-free.
//
// Run in a subprocess so the ASAN crash surfaces as a non-zero exit code
// and the UAF read can't corrupt the test runner's heap on release builds.
// Must be a category MimeType.init() heap-allocates for (not text/plain,
// text/html, application/json, etc.).
const makeBlob = useFetch
? // Serve a streaming body so fetch() resolves while the body is still
// .Locked and res.blob() goes through Body.Value.resolve's .getBlob arm
// (not the synchronous getBlobWithThisValue path the other case covers).
// Keep `await using server` at module scope — wrapping it in a block
// adds an async-dispose resume point whose stack frame conservatively
// roots the original blob and defeats the GC step below.
`await using server = Bun.serve({
port: 0,
fetch: () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(Buffer.from("hello "));
},
async pull(controller) {
await Bun.sleep(50);
controller.enqueue(Buffer.from("world"));
controller.close();
},
}),
{ headers: { "Content-Type": "image/png" } },
),
});
let blob = await (await fetch(server.url)).blob();`
: `let blob = await new Response("hello world", { headers: { "Content-Type": "image/png" } }).blob();`;

const src = `
${makeBlob}
if (blob.type !== "image/png") throw new Error("precondition: unexpected type " + blob.type);

// slice() shares the Store (refcount++) but gets content_type = ""
// because the parent's content_type is heap-allocated.
const sliced = blob.slice(0, 1);

// Drop the original so only 'sliced' holds the Store, then force GC so
// the original Blob's finalizer runs and frees its content_type.
blob = null;
Bun.gc(true);
await Bun.sleep(0);
Bun.gc(true);

// getType(): content_type.len == 0 -> store.mime_type.value. Previously
// that pointer aliased the original Blob's freed content_type; now the
// Store owns its own copy. ASAN reports use-after-poison here pre-fix.
process.stdout.write(sliced.type);
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", src],
env: {
...bunEnv,
// Skip slow symbolization so a pre-fix ASAN crash exits promptly
// instead of pushing the test past its default timeout. symbolize=0
// defeats LSan's symbol-name suppressions, so disable leak checks in
// this subprocess too (we're checking for UAF, not leaks).
ASAN_OPTIONS: (bunEnv.ASAN_OPTIONS ? bunEnv.ASAN_OPTIONS + ":" : "") + "symbolize=0:detect_leaks=0",
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stderr).toBe("");
expect(stdout).toBe("image/png");
expect(exitCode).toBe(0);
});
Loading