Skip to content

new fn() enters a server function body past every guard the apply trap holds #3242

Description

@frenzzy

Summary

A server function reference is a Proxy over the user's function whose apply trap holds the entire server-side contract: the "cannot call outside of a request" guard, the derived per-call event, the invocation identity, wrapInvocation, and transformDirectResult. The proxy declares no construct trap, and a Proxy without one forwards [[Construct]] straight to the target — so new fn() and Reflect.construct(fn, args) run the body having consulted none of it. It is the only entry that skips even the outside-a-request guard: the body runs where a policy hook could not have been evaluated in the first place, because there is no event for it to be evaluated against. An app whose authorization lives in wrapInvocation has a second, ungated entrance to every server function whose compiled target is a plain function.

Searchable spellings of the same defect: new serverFn(), Reflect.construct on a server function, "server function body runs with no request event", "wrapInvocation not called".

Reproduction

packages/web/repro-construct.mjs, run with plain node (v24.19.0, @solidjs/web 2.0.0-rc.6):

// Minimal repro: `new fn()` on a server function reference.
// Run with plain `node repro-construct.mjs` from packages/web.
import { AsyncLocalStorage } from "node:async_hooks";
import {
  configureServerFunctionsServer,
  createServerReference,
  registerServerReference
} from "@solidjs/web/server-functions/server";

globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();

let bodyRan = 0;
let gateRan = 0;

// the app's authorization middleware: every server function call goes
// through it, and it denies this one.
configureServerFunctionsServer({
  wrapInvocation() {
    gateRan++;
    throw new Error("policy denied");
  }
});

const deleteAccount = createServerReference(
  registerServerReference("deleteAccount", function () {
    bodyRan++; // stands in for the destructive work
    return "deleted";
  })
);

const storage = globalThis[Symbol.for("solid.RequestContext")];
const underRender = run =>
  storage.run({ request: new Request("https://app.example/page"), locals: {} }, run);

function attempt(label, run) {
  bodyRan = gateRan = 0;
  let outcome;
  try {
    run();
    outcome = "no throw";
  } catch (error) {
    outcome = `threw: ${error.message}`;
  }
  console.log(`${label.padEnd(34)} body ran: ${bodyRan}  gate ran: ${gateRan}  ${outcome}`);
}

console.log("-- outside a request --");
attempt("CONTROL  deleteAccount()", () => deleteAccount());
attempt("SUBJECT  new deleteAccount()", () => new deleteAccount());

console.log("-- inside a request, gate denies --");
attempt("CONTROL  deleteAccount()", () => underRender(() => deleteAccount()));
attempt("SUBJECT  new deleteAccount()", () => underRender(() => new deleteAccount()));
attempt("SUBJECT  Reflect.construct(fn,[])", () =>
  underRender(() => Reflect.construct(deleteAccount, []))
);

Measured output, against a bundle built from the pristine tree at f0f7531b:

-- outside a request --
RequestEvent is missing. This is most likely due to accessing `getRequestEvent` non-managed async scope in a partially polyfilled environment. Try moving it above all `await` calls.
CONTROL  deleteAccount()           body ran: 0  gate ran: 0  threw: Cannot call server function outside of a request
SUBJECT  new deleteAccount()       body ran: 1  gate ran: 0  no throw
-- inside a request, gate denies --
CONTROL  deleteAccount()           body ran: 0  gate ran: 1  threw: policy denied
SUBJECT  new deleteAccount()       body ran: 1  gate ran: 0  no throw
SUBJECT  Reflect.construct(fn,[])  body ran: 1  gate ran: 0  no throw

The CONTROL rows are the same reference, same process, one keyword apart: calling it is refused both outside a request and by the gate; constructing it runs the body in both cases and the gate never sees the call. (The RequestEvent is missing line is Solid's own warning from the first CONTROL row — the guard doing its job.)

What the constructed body actually sees, measured separately with a probe function reading the ambient accessors inside itself:

apply:
   getRequestEvent() === render event: false
   event.serverOnly: true
   getServerFunctionInvocation(): { id: 'probe' }
construct:
   getRequestEvent() === render event: true
   event.serverOnly: undefined
   getServerFunctionInvocation(): undefined

So construction does not merely skip the wrap — it skips the derived event (locals copy, #3156), the serverOnly flag, and the invocation identity. Outside a request there is no event at all.

With the construct trap in place, the same script prints:

-- outside a request --
CONTROL  deleteAccount()           body ran: 0  gate ran: 0  threw: Cannot call server function outside of a request
SUBJECT  new deleteAccount()       body ran: 0  gate ran: 0  threw: Cannot construct a server function: server functions are called, not constructed.
-- inside a request, gate denies --
CONTROL  deleteAccount()           body ran: 0  gate ran: 1  threw: policy denied
SUBJECT  new deleteAccount()       body ran: 0  gate ran: 0  threw: Cannot construct a server function: server functions are called, not constructed.
SUBJECT  Reflect.construct(fn,[])  body ran: 0  gate ran: 0  threw: Cannot construct a server function: server functions are called, not constructed.

Where

One site, in the server half of the server-function runtime.

  • packages/web/server-functions/src/server.ts:1083const proxy = new Proxy(fn, {, the reference proxy in createServerReference. In the pristine tree the same line is packages/web/server-functions/src/server.ts:901, and the handler list ends at :960 with no construct member.
  • packages/web/server-functions/src/server.ts:1093-1095 — the apply trap and its guard, if (!ogEvt) throw new Error("Cannot call server function outside of a request"). This is the invariant the missing trap breaks. Pristine: :911-913.
  • packages/web/server-functions/src/server.ts:1151-1165 — the construct trap added by the fix, refusing with "Cannot construct a server function: server functions are called, not constructed."

Provenance: the server-side proxy — apply trap, outside-a-request guard and wrapInvocation together — was introduced in 89a0531 ("Absorb expressions into Solid and collapse the rxcore seam.", 2026-08-25), then in packages/web/src/server-functions/server.js; 7182195 ("Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders.", same day) moved it to its present path. git log -S 'new Proxy(fn' --all returns exactly those two commits. The construct trap was absent from the first version onward: this is an omission at introduction, not a regression a neighbouring fix created. 0a2fcf0 ("fix(web): scope deferred server function work", 2026-09-02) is the most recent commit to rework this same trap and did not add one.

The client half (packages/web/server-functions/src/client.ts:899, pristine :867) has no construct trap either, but its target is an arrow function, so new there throws a native TypeError: not a constructor already. Nothing to fix on that side.

Why it matters

Reachability is narrower than the trap's absence suggests, and the limit is worth stating plainly before the harm.

[[Construct]] on a Proxy is only permitted when the target is a constructor. The compiler hands registerServerReference the user's function verbatim — packages/compiler/__tests__/directives/fixtures/object-property/expected.server.js emits function saveRecord(data) { … } and function (id) { … } unchanged — so the shape the author wrote decides it. Measured, on the pristine bundle:

function () {}           body ran: 1  no throw
async function () {}     body ran: 0  threw: TypeError: ref is not a constructor
() => {}                 body ran: 0  threw: TypeError: ref is not a constructor
async () => {}           body ran: 0  threw: TypeError: ref is not a constructor
function* () {}          body ran: 0  threw: TypeError: ref is not a constructor
class-ish (constructor)  body ran: 1  no throw

Only a synchronous function (declaration or expression) is exposed. The common case — export async function saveTodo(todo) { "use server"; … } — is already refused by the language, for reasons that have nothing to do with this runtime.

Nothing a browser client sends reaches this. The HTTP dispatch path in handleServerFunctionRequest calls; it never constructs. The caller has to be server-side code already holding the reference, and it has to construct rather than call. That is not ordinary application code — it is generic machinery handed a value it does not introspect: a DI container instantiating a registered provider, a plugin or effect runner that does new handler(...) when the handler looks like a constructor, a deserializer reviving a value by its constructor, or library glue built on Reflect.construct. This is an unusual integration, and calling it anything else would be overstating it. There is no remote-attacker path here on its own.

What it does cost is the integrity of the one policy seam the runtime advertises. wrapInvocation is documented as covering direct SSR calls precisely so per-function middleware "can't be bypassed by calling the function during a render" — that is the comment sitting in the apply trap. An app that puts its authorization there has a hole whose size is exactly the set of sync function-shaped server functions plus whatever in its dependency tree constructs values it is given. The outside-a-request case is the worse half: there the body runs with no event at all, so a body that reads getRequestEvent() for tenant or user context either throws deep inside the business logic or, worse, reads whatever ambient event happens to be in scope, as the probe above shows it does when construction happens during a render.

Options

A. Refuse construction in a construct trap. Four lines, no allocation, no change to any path anyone writes deliberately. new is required to hand back an object, and a server function's return value is under no obligation to be one, so refusing states the true contract rather than inventing a coercion. It is technically a behaviour change: today new syncServerFn() "works" for the sync-function shape, returning the constructed this. Someone relying on that seems remote, but it is a change.

B. Route construction through the apply trapconstruct(target, thisArg, args) { return proxy(...args); }. Keeps the "one road into the body" invariant literally, and inside a request it raises the existing gate; outside one it raises the existing guard, so the error messages stay familiar. The cost is semantic mud: the trap must return an object or the engine throws an opaque TypeError, so new fn() on a function returning a string fails with a message about proxy invariants rather than about server functions, while one returning a Promise silently "succeeds" and hands back a Promise from a new expression. A caller that constructed by accident gets a confusing result instead of a clear refusal.

C. Make the target non-constructible — have createServerReference proxy an arrow wrapper instead of the user's function, so the engine refuses natively with no runtime code. Zero bytes in the trap list, but it moves the target identity: the get trap's target[prop] fallthrough, fn.length, fn.name, and anything reading the original function's own properties now see the wrapper, and the compiler deliberately passes the user's function through untouched. That is a larger blast radius than the problem.

D. Document it and leave the runtime alone. Cheapest, and defensible if the position is that server functions are only ever reached through the compiler's own call sites. It does mean wrapInvocation keeps being described as covering every direct call while a spelling exists that it does not cover, so the doc would have to say so explicitly.

E. Fix it at the adapter layer (SolidStart, or whichever integration owns the render). Not really available: the reference is handed to application code, and only the proxy that created it can see construction happen.

Recommendation: A. In Solid's terms it is the minimal statement of a rule the runtime already enforces one line above — the body is reachable through apply or not at all — and it costs a trap that does nothing but throw. B is the only serious alternative and it buys uniformity at the price of a worse error for the caller who did not mean to construct; whether that trade is worth making, and whether removing the accidental new-works behaviour needs a changeset note, is a call for the maintainer rather than something this report should assert.

Regression test

packages/web/test/server/server-functions-construct-trap.spec.tsx:

/**
 * `new fn()` is a call, and every road into a server function body has to
 * be one road.
 *
 * `createServerReference` returns a Proxy over the user's function with a
 * `get` trap (identity, metadata, the invoke channel) and an `apply` trap.
 * The apply trap is where the whole server-side contract lives: the "cannot
 * call a server function outside of a request" guard, the derived event
 * with its copied locals, the invocation identity, `wrapInvocation`, and
 * `transformDirectResult`.
 *
 * A Proxy with no `construct` trap forwards construction straight to the
 * target. So `new fn()` — or `Reflect.construct(fn, args)`, which is what a
 * generic dispatcher, a DI container, or a serializer reviving a value
 * reaches for — runs the body having consulted none of it: no request
 * scope, no event, and no authorization hook. It is the one entry that
 * skips even the outside-a-request guard, so it does not merely bypass
 * policy, it runs the body somewhere policy could not have been evaluated
 * in the first place.
 *
 * The invariant: the body is reachable through the apply trap or not at
 * all. Whether construction is routed through it or refused outright, it
 * cannot be a second, unguarded entrance.
 *
 * Like the other server-function specs, these run against the built bundles
 * (server-functions/dist/*, wired up in vite.config.server.mjs).
 */
import { AsyncLocalStorage } from "node:async_hooks";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
  configureServerFunctionsServer,
  createServerReference as createServerSideReference,
  registerServerReference
} from "@solidjs/web/server-functions/server";

const RequestContext = Symbol.for("solid.RequestContext");

/**
 * `configure` ignores `undefined` — that is its spelling of "not
 * overriding" — so undoing a hook between tests takes a value. `null` is
 * the falsy "nothing configured" every read site tests for; the cast is
 * only because the option type describes hooks, not their absence.
 */
const NO_HOOK = null as any;

beforeAll(() => {
  (globalThis as any)[RequestContext] = new AsyncLocalStorage();
});

afterEach(() => {
  configureServerFunctionsServer({ wrapInvocation: NO_HOOK });
});

afterAll(() => {
  delete (globalThis as any)[RequestContext];
});

/** Runs `fn` under an established request scope, as a render would. */
function underRender<T>(fn: () => T): T {
  const storage = (globalThis as any)[RequestContext] as AsyncLocalStorage<unknown>;
  return storage.run({ request: new Request("https://app.example/page"), locals: {} }, fn);
}

describe("construction is not a second entrance to the body", () => {
  it("refuses `new fn()` outside a request, as calling it does", () => {
    let bodyRan = 0;
    const call = createServerSideReference(
      registerServerReference("construct-outside", function () {
        bodyRan++;
      })
    );

    // the road next to it is guarded; this one is not. The message is left
    // to the fix — routing construction through the apply trap raises the
    // existing "outside of a request" guard, refusing it outright says so
    // in its own words — what is pinned is that the body is not reached.
    let threw = false;
    try {
      new (call as any)();
    } catch {
      threw = true;
    }
    // today: { threw: false, bodyRan: 1 } — the body ran with no request
    // event in scope
    expect({ threw, bodyRan }).toStrictEqual({ threw: true, bodyRan: 0 });
  });

  it("does not let `new fn()` walk around the authorization gate inside a request", () => {
    let bodyRan = 0;
    let gateRan = 0;
    configureServerFunctionsServer({
      wrapInvocation: () => {
        gateRan++;
        throw new Error("policy denied");
      }
    });
    const call = createServerSideReference(
      registerServerReference("construct-gated", function (this: any) {
        bodyRan++;
        this.secret = "the secret";
      })
    );

    // whichever way construction is answered — routed through the apply
    // trap, or refused as not-a-call — it must not reach the body
    let threw = false;
    try {
      underRender(() => new (call as any)());
    } catch {
      threw = true;
    }
    // today: { threw: false, bodyRan: 1 }, and the gate never saw the call.
    // `gateRan` is deliberately not asserted: 1 if construction routes
    // through the apply trap, 0 if it is refused before one exists.
    expect({ threw, bodyRan, gateRan }).toStrictEqual({ threw: true, bodyRan: 0, gateRan });
  });

  it("refuses Reflect.construct too — the spelling a generic caller uses", () => {
    let bodyRan = 0;
    const call = createServerSideReference(
      registerServerReference("construct-reflect", function () {
        bodyRan++;
      })
    );

    let threw = false;
    try {
      Reflect.construct(call as any, []);
    } catch {
      threw = true;
    }
    expect({ threw, bodyRan }).toStrictEqual({ threw: true, bodyRan: 0 });
  });
});

Against the pristine bundle all three fail with { threw: false, bodyRan: 1 } where { threw: true, bodyRan: 0 } is expected — the body ran on every construction spelling; with the construct trap the file is 3 passed. The middle test deliberately does not assert gateRan, so it stays green under either option A or option B.

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