Skip to content

πŸ› Bug Report β€” Runtime APIs: internal native-stream extraction symbols are reachable and forgeable with typescript_implemented_streamsΒ #8047

Closed

Description

@james-elicx

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, a Blob stream or a socket's readable carries Symbol(kReadableStreamBrand) and Symbol(kExtractNativeSource).
  • A socket's writable carries Symbol(kExtractNativeSink).

From there, user code can:

  • Get the internal objects. body[kExtractNativeSource]() returns the ReadableStreamNativeSource (pull, cancel, tee, expectedLength). socket.writable[kExtractNativeSink]() returns the WritableStreamNativeSink (write, close, abort, pipeFrom, detach), which writes to the socket around the WritableStream.
  • Forge the marker. Define the symbol on an ordinary ReadableStream and the C++ type wrapper treats that stream as native-backed and calls the forged extractor. fetch(), service bindings, JS RPC and KV.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 a pull() 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 missing enqueue, or a byobRequest whose view isn't a Uint8Array, fail with Error: 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.capnp

With 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

  1. jasnell commented on Oct 27, 2026

    @jasnell
    Collaborator

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions