Skip to content

πŸ› Bug Report β€” Runtime APIs: Legacy module registry recurses without bound (SIGSEGV) when the module fallback service returns a self-redirectΒ #7626

Open

Description

@gcharang

What happened

ModuleRegistryImpl::resolve (src/workerd/jsg/modules.h) caches every redirect the module fallback service returns in fallbackServiceRedirects and immediately re-enters resolve with specifier.parent().eval(redirect). When the redirect target evaluates to the specifier being resolved, the cache entry maps a path to itself. The next call misses the registry, hits the cached-redirect branch, and recurses with the same path until the stack overflows. workerd dies with *** Received signal #11: Segmentation fault, with no KJ exception and no mention of the module.

The common way to hit this is a node:* builtin that is not enabled at the Worker's compat date/flags. workerd roots node:/cloudflare:/workerd: specifiers (/node:os) and asks the fallback for specifier=node:os. A fallback that answers 301 Location: /node:os (as @cloudflare/vitest-pool-workers < 0.21.1 did; see cloudflare/workers-sdk#14590 and #14882) produces the self-redirect. The pool now avoids it, but any fallback service can still crash the runtime this way.

Versions

  • workerd 1.20260929.1 (@cloudflare/workerd-linux-64, release commit 82b85cf) and 1.20260903.1. The code is unchanged on main at 752ba4d.
  • Linux x86_64 (Ubuntu 24.04, kernel 7.0.0-28-generic), Node 22.22.0 for the fallback server.

Reproduction (no vitest/miniflare)

config.capnp

using Workerd = import "/workerd/workerd.capnp";

const config :Workerd.Config = (
  services = [ (name = "main", worker = .mainWorker) ],
  sockets = [ (name = "http", address = "127.0.0.1:18787", http = (), service = "main") ],
);

const mainWorker :Workerd.Worker = (
  modules = [ (name = "main.mjs", esModule = embed "main.mjs") ],
  # node:os is a builtin only from 2025-09-15 (or with enable_nodejs_os_module).
  compatibilityDate = "2025-06-01",
  compatibilityFlags = ["nodejs_compat"],
  moduleFallback = "127.0.0.1:18788",
);

main.mjs

export default {
  async fetch() {
    const os = await import('node:os');
    return new Response(typeof os.EOL);
  },
};

fallback.mjs

import http from 'node:http';

http
  .createServer((req, res) => {
    const url = new URL(req.url, 'http://fallback');
    const specifier = url.searchParams.get('specifier');
    console.log(`fallback ${req.headers['x-resolve-method']} specifier=${specifier} referrer=${url.searchParams.get('referrer')}`);
    if (specifier === 'node:os' || specifier === '/node:os') {
      res.writeHead(301, { Location: '/node:os' });
    } else {
      res.writeHead(404);
    }
    res.end();
  })
  .listen(18788, '127.0.0.1');

Run:

node fallback.mjs &
workerd serve config.capnp --experimental &
curl http://127.0.0.1:18787/

Expected

The import rejects with Error: No such module "node:os" (what workerd reports for the same Worker without moduleFallback), or with an error naming the redirect loop. The process stays up.

Actual

fallback import specifier=node:os referrer=/main.mjs
*** Received signal #11: Segmentation fault
stack: workerd@0x27f0168 workerd@0x27f09fc workerd@0x27f09fc … (25Γ— 0x27f09fc)
curl: (52) Empty reply from server

Symbolized with addr2line against the unstripped 1.20260929.1 binary: kj::Path::evalPart ← kj::PathPtr::eval ← ModuleRegistryImpl<JsgWorkerdIsolate_TypeWrapper>::resolve ← resolve ← resolve …. There is one fallback request, then the crash.

Controls: adding "enable_nodejs_os_module" to compatibilityFlags makes the request return string. Removing moduleFallback gives a clean No such module "node:os".

Suggested fix

Port the two guards from the new registry (jsg/modules-new.c++: the str == normalizedSpecifier alias check and the recursed bound) into ModuleRegistryImpl::resolve, covering both redirect paths (the cached-redirect branch and the fresh-redirect branch). A redirect to the specifier being resolved, or a second redirect hop for the same specifier, should yield kj::none, so the caller raises No such module. Optionally, key fallbackServiceRedirects by method as well: today import and require share one cache entry, although a fallback service may answer them differently.

Activity

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