From c97ff9dc54dde9fd63adb616a5eee3502014bb1a Mon Sep 17 00:00:00 2001 From: Sergi Gonzalez <31130069+TheHypnoo@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:08:26 +0200 Subject: [PATCH 1/4] test(fetch): cover symbol-keyed own properties on Request/Response/Headers Node-oracle parity case for writing, reading, overwriting, enumerating and deleting symbol-keyed properties on native fetch objects, directly and through Reflect.set on an untyped parameter. Perry currently segfaults on the first Reflect.set against a Request. --- .../node-suite/fetch/symbol-property-set.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test-parity/node-suite/fetch/symbol-property-set.ts diff --git a/test-parity/node-suite/fetch/symbol-property-set.ts b/test-parity/node-suite/fetch/symbol-property-set.ts new file mode 100644 index 00000000000..c74f18bc0fd --- /dev/null +++ b/test-parity/node-suite/fetch/symbol-property-set.ts @@ -0,0 +1,47 @@ +// Symbol-keyed own properties on native fetch objects (Request / Response / +// Headers). Frameworks tag these objects with private symbols — e.g. Astro's +// node adapter does `Reflect.set(request, clientAddressSymbol, ip)` on every +// request — so the write must behave like on any ordinary object. + +const registered = Symbol.for("parity.fetch.registered"); +const local = Symbol("parity.fetch.local"); + +function exercise(label: string, obj: any) { + console.log(label, "Reflect.set registered:", Reflect.set(obj, registered, "r1")); + console.log(label, "Reflect.get registered:", Reflect.get(obj, registered)); + + obj[local] = "l1"; + console.log(label, "direct get local:", obj[local]); + + obj[registered] = "r2"; + console.log(label, "overwrite registered:", obj[registered]); + + const syms = Object.getOwnPropertySymbols(obj); + console.log(label, "own symbols include registered:", syms.includes(registered)); + console.log(label, "own symbols include local:", syms.includes(local)); + + console.log(label, "delete local:", delete obj[local], obj[local]); +} + +function viaUntypedParam(label: string, target: any) { + // Reach the write through an untyped parameter, the shape framework code uses. + console.log(label, "param Reflect.set:", Reflect.set(target, local, "p1")); + console.log(label, "param get:", target[local]); +} + +const req = new Request("http://localhost/path", { method: "POST" }); +const res = new Response("body", { status: 201 }); +const headers = new Headers({ "x-a": "1" }); + +exercise("request", req); +exercise("response", res); +exercise("headers", headers); + +viaUntypedParam("request", new Request("http://localhost/")); +viaUntypedParam("response", new Response(null)); +viaUntypedParam("headers", new Headers()); + +// The native state must survive the symbol writes. +console.log("request still works:", req.method, req.url); +console.log("response still works:", res.status); +console.log("headers still works:", headers.get("x-a")); From 847accd9c58eea7f59fdaef000aa6962a40a50ef Mon Sep 17 00:00:00 2001 From: Sergi Gonzalez <31130069+TheHypnoo@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:20:58 +0200 Subject: [PATCH 2/4] fix(runtime): reject the handle band before reading a symbol receiver's GcHeader set_symbol_property and symbol_property_is_non_writable gated the frozen/non-extensible GcHeader read on obj_key >= 0x10000 plus is_valid_obj_ptr, which does not exclude the handle band. Web Fetch handles (Request/Response/Headers) sit above that floor, so writing a symbol-keyed property on them, e.g. Reflect.set(new Request(url), sym, v), dereferenced unmapped memory. Both sites now use try_read_gc_header, which classifies the whole handle band before any dereference. --- .../fetch-symbol-property-handle-band-segv.md | 18 +++++++ crates/perry-runtime/src/symbol/properties.rs | 50 ++++++++++--------- 2 files changed, 44 insertions(+), 24 deletions(-) create mode 100644 changelog.d/fetch-symbol-property-handle-band-segv.md diff --git a/changelog.d/fetch-symbol-property-handle-band-segv.md b/changelog.d/fetch-symbol-property-handle-band-segv.md new file mode 100644 index 00000000000..0222f9fbe8a --- /dev/null +++ b/changelog.d/fetch-symbol-property-handle-band-segv.md @@ -0,0 +1,18 @@ +Fixed `Reflect.set`/`obj[sym] = v` segfaulting on a native Web Fetch handle +(`Request`/`Response`/`Headers`) — e.g. `Reflect.set(new Request(url), Symbol.for("x"), v)`, +the pattern Astro's `@astrojs/node` adapter uses to tag every request with its +client address. `set_symbol_property`'s frozen/non-extensible guard (and its +`symbol_property_is_non_writable` mirror) gated the `GcHeader` dereference on a +hand-typed `obj_key >= 0x10000` floor — an order of magnitude below +`HANDLE_BAND_MAX` (0x100000) — paired with `is_valid_obj_ptr`, whose doc +explicitly warns it does *not* reject the handle band by itself. A Web Fetch +handle id (`[0x40000, 0xE0000)`) sailed through both checks and +`(obj_key - GC_HEADER_SIZE) as *const GcHeader` read unmapped memory. Both call +sites now use `try_read_gc_header`, which classifies the whole handle band +(and alignment) before any dereference and returns `None` for handle +receivers — the same guard already used elsewhere in the runtime for this +exact class of bug (#1843/#4004/#4800). + +Test: `test-parity/node-suite/fetch/symbol-property-set.ts` (Reflect.set/get, +direct `obj[sym]`, overwrite, `Object.getOwnPropertySymbols`, delete, and an +untyped-parameter write, on `Request`/`Response`/`Headers`). diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index 5b1247156b0..8b323a1b5ea 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -422,24 +422,27 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 // like string-keyed ones: an existing prop is non-writable when frozen // (or its per-symbol attrs say so), a new prop is forbidden when // non-extensible. Only heap receivers carry the GC flag word. - if !native_async_resource - && (obj_f64.to_bits() >> 48) == 0x7FFD - && obj_key >= 0x10000 - && crate::object::is_valid_obj_ptr(obj_key as *const u8) - { - let gc = (obj_key - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - let flags = (*gc)._reserved; - if has_own_data { - if flags & crate::gc::OBJ_FLAG_FROZEN != 0 { - return value_f64; - } - if let Some(attrs) = get_symbol_property_attrs(obj_key, sym_key) { - if !attrs.writable() { + // + // `try_read_gc_header` rejects the whole handle band (and misaligned or + // slab addresses) before dereferencing. A hand-typed `>= 0x10000` floor + // plus `is_valid_obj_ptr` is not enough: Web Fetch handles + // (Request/Response/Headers) live above that floor, and + // `Reflect.set(new Request(url), sym, v)` read an unmapped GcHeader. + if !native_async_resource && (obj_f64.to_bits() >> 48) == 0x7FFD { + if let Some(gc) = crate::value::addr_class::try_read_gc_header(obj_key) { + let flags = gc._reserved; + if has_own_data { + if flags & crate::gc::OBJ_FLAG_FROZEN != 0 { return value_f64; } + if let Some(attrs) = get_symbol_property_attrs(obj_key, sym_key) { + if !attrs.writable() { + return value_f64; + } + } + } else if flags & crate::gc::OBJ_FLAG_NO_EXTEND != 0 { + return value_f64; } - } else if flags & crate::gc::OBJ_FLAG_NO_EXTEND != 0 { - return value_f64; } } if !has_own_data { @@ -498,15 +501,14 @@ pub(crate) fn symbol_property_is_non_writable(obj_f64: f64, sym_f64: f64) -> boo if obj_key == 0 || sym_key == 0 { return false; } - // Only heap receivers carry the GC integrity flag word. - if (obj_f64.to_bits() >> 48) == 0x7FFD - && obj_key >= 0x10000 - && crate::object::is_valid_obj_ptr(obj_key as *const u8) - { - let gc = (obj_key - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - let flags = unsafe { (*gc)._reserved }; - if flags & crate::gc::OBJ_FLAG_FROZEN != 0 { - return true; + // Only heap receivers carry the GC integrity flag word; see + // `set_symbol_property` for why this must reject the whole handle band. + if (obj_f64.to_bits() >> 48) == 0x7FFD { + if let Some(gc) = unsafe { crate::value::addr_class::try_read_gc_header(obj_key) } { + let flags = gc._reserved; + if flags & crate::gc::OBJ_FLAG_FROZEN != 0 { + return true; + } } } get_symbol_property_attrs(obj_key, sym_key).is_some_and(|attrs| !attrs.writable()) From 77941ce1d3477aa89a4f87ea183a2e850e8e9906 Mon Sep 17 00:00:00 2001 From: Sergi Gonzalez <31130069+TheHypnoo@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:40:21 +0200 Subject: [PATCH 3/4] test(parity): ratchet the fetch node-suite floor to 13/13 The new symbol-property-set parity test passes; the floor rises by exactly that test. --- test-parity/node_suite_baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 2ea471f5ac9..1a183cc8bb2 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -68,8 +68,8 @@ "total": 69 }, "fetch": { - "pass": 12, - "total": 12 + "pass": 13, + "total": 13 }, "fs": { "pass": 168, From 17b1d843e36f892e92c6911455f7bce9d21497bd Mon Sep 17 00:00:00 2001 From: Sergi Gonzalez <31130069+TheHypnoo@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:43:31 +0200 Subject: [PATCH 4/4] docs: key the changelog fragment to PR 11327 --- ...nd-segv.md => 11327-fetch-symbol-property-handle-band-segv.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{fetch-symbol-property-handle-band-segv.md => 11327-fetch-symbol-property-handle-band-segv.md} (100%) diff --git a/changelog.d/fetch-symbol-property-handle-band-segv.md b/changelog.d/11327-fetch-symbol-property-handle-band-segv.md similarity index 100% rename from changelog.d/fetch-symbol-property-handle-band-segv.md rename to changelog.d/11327-fetch-symbol-property-handle-band-segv.md