Repository navigation
π Bug Report β Runtime APIs: internal native-stream extraction symbols are reachable and forgeable with typescript_implemented_streamsΒ #8047
Description
Description
With typescript_implemented_streams, the extraction markers on native-backed streams are reachable from user code. native.ts describes kExtractNativeSource as coming "from the runtime's API-symbol registry, unreachable from user code", and js-readable-stream.h says ReadableStreamNativeSource "is never exposed to user code". But the markers are installed as own properties, so Object.getOwnPropertySymbols() returns them:
- A
fetch()response body, aBlobstream or a socket'sreadablecarriesSymbol(kReadableStreamBrand)andSymbol(kExtractNativeSource). - A socket's
writablecarriesSymbol(kExtractNativeSink).
From there, user code can:
- Get the internal objects.
body[kExtractNativeSource]()returns theReadableStreamNativeSource(pull,cancel,tee,expectedLength).socket.writable[kExtractNativeSink]()returns theWritableStreamNativeSink(write,close,abort,pipeFrom,detach), which writes to the socket around theWritableStream. - Forge the marker. Define the symbol on an ordinary
ReadableStreamand the C++ type wrapper treats that stream as native-backed and calls the forged extractor.fetch(), service bindings, JS RPC andKV.put()then send whatever source the extractor returns instead of the stream's own chunks. The extractor can return another body's source (that body's bytes are sent), a cancelled source (an empty body is sent and the call succeeds), or a source with apull()in flight (the call hangs).text()and the other consumers ignore the marker and read the stream itself. - Break the pull contract.
pull()calls with a controller the conduit wouldn't pass, such as one missingenqueue, or abyobRequestwhoseviewisn't aUint8Array, fail withError: internal error.
I didn't find a memory-safety problem. pullByob reads into its own scratch buffer and re-checks the view before copying, and the extractors type-check what they return. All of this stays inside the worker's own code. The C++ implementation has none of these properties (Object.getOwnPropertySymbols(response.body) is []).
Reproduction
worker.js:
export default {
async test(ctrl, env) {
const body = (await env.UPSTREAM.fetch("http://upstream/")).body;
const symbols = Object.getOwnPropertySymbols(body);
console.log(`own symbols on a fetch() response body: ${JSON.stringify(symbols.map(String))}`);
const extract = symbols.find((symbol) => String(symbol) === "Symbol(kExtractNativeSource)");
if (extract) {
const source = body[extract]();
console.log(`body[kExtractNativeSource]() returned ${Object.prototype.toString.call(source)}`);
// An ordinary stream carrying the marker is treated as native-backed.
const mine = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("my body"));
controller.close();
},
});
Object.defineProperty(mine, extract, { value: () => source });
const sent = await (await env.UPSTREAM.fetch("http://upstream/echo", { method: "POST", body: mine })).text();
console.log(`fetch() with my stream sent ${JSON.stringify(sent)}`);
}
if (symbols.length) throw new Error("internal symbols are reachable");
},
};upstream.js:
export default {
async fetch(request) {
if (request.method === "POST") return new Response(request.body);
return new Response("upstream body");
},
};config.capnp:
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [(name = "main", worker = .main), (name = "upstream", worker = .upstream)],
# typescript_implemented_streams only starts with this autogate.
autogates = ["workerd-autogate-per-isolate-javascript-bootstrap"],
);
const main :Workerd.Worker = (
modules = [(name = "worker", esModule = embed "worker.js")],
compatibilityDate = "2026-10-27",
compatibilityFlags = ["typescript_implemented_streams"],
bindings = [(name = "UPSTREAM", service = "upstream")],
);
const upstream :Workerd.Worker = (
modules = [(name = "upstream", esModule = embed "upstream.js")],
compatibilityDate = "2026-10-27",
);workerd test --experimental config.capnpWith the flag (workerd 2026-10-27, @cloudflare/workerd-darwin-arm64@1.20261027.1):
own symbols on a fetch() response body: ["Symbol(kReadableStreamBrand)","Symbol(kExtractNativeSource)"]
body[kExtractNativeSource]() returned [object ReadableStreamNativeSource]
fetch() with my stream sent "upstream body"
[ FAIL ] main
With compatibilityFlags = [] (C++ streams):
own symbols on a fetch() response body: []
[ PASS ] main
Expected behavior
The markers and the objects behind them aren't reachable from user code, for example via private symbols or private fields rather than own string-described symbols. A user-defined property can't make an ordinary stream count as native-backed.
Activity
jasnell commentedon Oct 27, 2026
For now, this is known and not something we need to track. It's already on the list of things to revisit before flipping the switch to go live.