You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
## Summary
A `wrapInvocation` passed as a per-handler option to `handleServerFunctionRequest` wraps only the function the wire addressed. If that function's body calls another server function directly — an ordinary in-process call, no HTTP hop — the second body runs with no wrap at all, inside the very request the option was given for, at any depth. A per-handler wrap that declines a call (`throw new Response(null, { status: 403 })`) therefore cannot decline anything but the entry point: the request completes 200 and the nested body has already run. The identical hook installed server-wide via `configureServerFunctionsServer` does cover both calls, so the guarantee an integration gets depends on which of the two spellings it used.
This merges three symptoms that read as separate: (1) **per-handler `wrapInvocation` never fires with `context.direct === true`**; (2) **a per-handler authorization wrap is bypassed by a nested server-function call**; (3) **the apply trap's source comment claims a wrap "can't be bypassed by calling the function during a render" without saying that only the configured hook earns that**. One mechanism produces all three. The third is documentation-only by construction and is discussed under Options.
## Reproduction`packages/web` at `f0f7531b` (`2.0.0-rc.6`), built with `npm run build -w packages/web`, Node v24.19.0. `repro.mjs` resolves `@solidjs/web` to that package.
```js// node repro.mjsimport { AsyncLocalStorage } from"node:async_hooks";
import {
configureServerFunctionsServer,
createServerReference,
handleServerFunctionRequest,
registerServerFunction,
registerServerReference
} from"@solidjs/web/server-functions/server";
globalThis[Symbol.for("solid.RequestContext")] =newAsyncLocalStorage();
// `<p>-outer` is what the wire addresses; its body calls `<p>-inner`// in-process, which (at depth 3) calls `<p>-leaf`.functionchain(p, depth=2) {
constran= [];
constleaf=createServerReference(
registerServerReference(`${p}-leaf`, () => (ran.push("leaf"), "the leaf secret"))
);
constinner=createServerReference(
registerServerReference(`${p}-inner`, async () => {
ran.push("inner");
return depth >2?leaf() :"the inner secret";
})
);
registerServerFunction(`${p}-outer`, async () =>inner());
return () =>ran.join("+") ||"-";
}
constpost=id=>newRequest(`https://app.example/_server/data/${id}`, {
method:"POST",
body:"[]",
headers: {
"Sec-Fetch-Site":"same-origin",
"content-type":"application/json",
"X-Server-Function-Format":"8",
"X-Server-Function-Instance":"server-function:test"
}
});
consttrace=seen=> (run, ctx) => {
seen.push(`${ctx.id}:${ctx.direct?"direct":"http"}`);
returnrun();
};
constrow= (label, seen, res, ran) =>console.log(
label.padEnd(30) +`wrap saw [${seen.join(", ")}]`.padEnd(64) +`status=${res.status} bodies ran: ${ran()}`
);
// CONTROL: the same wrap, installed as the SERVER-WIDE configured hook.
{
constseen= [], ran =chain("cfg");
configureServerFunctionsServer({ wrapInvocation:trace(seen) });
row("CONTROL configured hook", seen, awaithandleServerFunctionRequest(post("cfg-outer")), ran);
configureServerFunctionsServer({ wrapInvocation:null });
}
// The same wrap, arriving as a per-handler option.
{
constseen= [], ran =chain("opt");
constres=awaithandleServerFunctionRequest(post("opt-outer"), { wrapInvocation:trace(seen) });
row("per-handler option", seen, res, ran);
}
// Depth 3: outer -> inner -> leaf, all in-process, one request.
{
constseen= [], ran =chain("deep", 3);
constres=awaithandleServerFunctionRequest(post("deep-outer"), { wrapInvocation:trace(seen) });
row("per-handler, 3 deep", seen, res, ran);
}
// A per-handler wrap that REFUSES the nested call.
{
constseen= [], ran =chain("deny");
constres=awaithandleServerFunctionRequest(post("deny-outer"), {
wrapInvocation: (run, ctx) => {
seen.push(`${ctx.id}:${ctx.direct?"direct":"http"}`);
if (ctx.id==="deny-inner") thrownewResponse(null, { status:403 });
returnrun();
}
});
row("per-handler denies inner", seen, res, ran);
}
The CONTROL row is the contrast: the server-wide hook sees cfg-outer:httpandcfg-inner:direct. The same function object, handed to the same request through options.wrapInvocation, sees only the entry — and in the last row the refusal it was written to perform never gets the chance to fire: deny-inner's body ran and the response is 200.
For reference, the same script against the tree with the fix below applied:
There is no road from here to the per-handler option: the option is a local in the HTTP dispatch function, and the apply trap is reached through an ordinary function call from inside the dispatched body.
3030-3031 — where the per-handler option is resolved, and 3166-3167 — the only place the resolved value is ever used, around the entry invocation. Between those two points nothing records the wrap anywhere the nested call can find it.
440-445 — the option's TSDoc states the limitation as intended behaviour: "except it only applies to HTTP dispatch (a per-request option can't see direct SSR calls)". Read as written about a document render this is true; read about a call made inside the dispatch the option was given for, it is not — that call is HTTP dispatch, just not its first frame.
2765-2774 — the handler's option-list TSDoc, same split.
933-936 — the apply trap's comment advertising the property without naming which hook earns it: "direct SSR calls run through the same policy as HTTP dispatch, so per-function middleware built on it can't be bypassed by calling the function during a render."
Provenance. The implementation arrived in this repo with 89a0531c ("Absorb expressions into Solid and collapse the rxcore seam.", 2026-08-25), which landed the runtime absorbed from @dom-expressions/runtime at packages/web/src/server-functions/server.js; earlier history is not in this repo. 71821959 ("Migrate the absorbed DOM runtime to TypeScript…", 2026-08-25) moved it to the current path and lines, unchanged in this respect. The behaviour was a decision, not a slip: 687a9937 ("Document the response-head lifecycle, composeMiddleware, and the wrapInvocation seam", 2026-08-05) wrote it into RFC 10 twenty days earlier — "the configured hook also wraps direct SSR calls … the per-request option applies to HTTP dispatch only" (documentation/solid-2.0/10-server-functions.md:65). 2c90ae6b (the revert of #3059) rewrote line 938-939 but only back to its pre-#3059 single-expression form; it did not introduce this. 30f9387d (#3156, per-call locals copy) created the derived event the fix below keys on, but did not cause the gap.
Not the same defect as the !== undefined resolution on line 3031 (a null option disabling the configured wrap rather than deferring to it). That is a resolution bug in the same expression; this one is a scope bug in a different code path, and they go red against different assertions.
Why it matters
The realistic path: an adapter derives policy from the request — a per-tenant guard, a per-route gate, a request-scoped audit wrap — and therefore cannot install it once at boot. The per-handler option is the seam built for exactly that. In that setup a server function that clears the gate and then calls a second server function in-process runs the second body with no gate at all. The author of the entry function often does not experience the nested call as a call across a boundary: on the server the reference is just a function in scope, so getOrder() calling getCustomer() looks like ordinary composition, and the fact that getCustomer is separately reachable — with its own guard — is invisible at the call site. Row 3 shows the gap does not stop at one level.
The honest limits:
It needs an integration that passes wrapInvocation per handler. Anything using configureServerFunctionsServer({ wrapInvocation }) — which is what the TSDoc and RFC 10's own example steer you to, and what the CONTROL row exercises — is unaffected. No adapter in this repository passes the per-handler option; it is an API contract for framework authors, so the population at risk is whoever has already built on it.
It needs a server function whose body calls another server function directly on the server. A client that calls both functions gets two HTTP requests, each with its own options, each wrapped.
It is not remotely triggerable and there is no untrusted input involved. A caller cannot make an entry function call something it does not already call. The exposure is that a policy an integration believes it installed is absent on a path it did not think about — a gap in a guarantee, not a hole an attacker reaches through.
I would not describe this as an exploitable vulnerability. I would describe it as a seam that does not hold on one of its two spellings, and whose docs promise it does not have to.
Options
Record the request's wrap on the request event and inherit it onto the derived event the apply trap builds. A WeakMap keyed on the event, mirroring INVOCATIONS exactly — same keying, same lifetime, same reason for not living on event.locals (user/integration space, and Concurrent direct server-function calls share the render's locals object and overwrite each other #3156's per-call copy gives it the wrong lifetime). Dispatch records the resolved wrap; the apply trap reads it off the incoming event, prefers it over config.wrapInvocation, and copies it to the derived event so depth falls out with no recursion. Cost: three small hunks, no new API, no new async-context store. Trade-off: it is a behaviour change — a per-handler wrap that today runs once per request may now run several times, so a wrap that counts invocations or assumes direct === false changes meaning.
Fix the documentation only. Say plainly in both TSDoc blocks and RFC 10 that per-request policy which must hold across in-process calls belongs in configureServerFunctionsServer, and that the per-handler option covers the entry point alone. Zero code, zero behaviour change, no risk to existing users. Trade-off: two hooks named wrapInvocation keep meaning different things, and per-request-derived policy (per tenant, per route) is left with no seam that covers what the request actually executes — the case the option exists to serve.
Reuse the INVOCATIONS record — store { id, wrap } instead of a second WeakMap. One less map. Trade-off: getServerFunctionInvocation() is public and returns that record, so the policy hook would become readable by application code; identity and policy are different lifetimes and different audiences.
Put the wrap on the event as a public field (event.serverFunctionWrap). Discoverable, and an integration could override it mid-request. Trade-off: it makes runtime plumbing part of the event's public shape, which is the thing the INVOCATIONS comment already argues against.
A dedicated AsyncLocalStorage owned by server-functions. Survives any event derivation, not just the one the apply trap makes. Trade-off: a second async-context store beside the RequestContext ALS the runtime already parks on the global, with its own bundling and its own failure mode when the two disagree.
Swap config.wrapInvocation for the duration of dispatch. Tempting one-liner. It is wrong under concurrency: two overlapping requests would trade policies. Listed only so it stays rejected.
Remove the per-handler option entirely; one configured hook. Smallest surface, and the ambiguity disappears with it. Trade-off: breaks whoever uses the option today and pushes per-request policy into reading the event from inside the configured hook.
Recommendation: (1). In Solid's minimalism terms it adds no API and no new mechanism — it reuses the keying decision the file already made for invocation identity, and rides the derived event #3156 already creates, so nesting works at any depth without a traversal. It also collapses two hooks that share a name into one meaning, which is less surface to document than the current carve-out.
Two calls are yours, not mine to assert:
Behaviour change vs. documentation. The narrow behaviour was written down deliberately in 687a9937. Choosing (1) amends a documented decision; choosing (2) keeps it and makes it explicit that only the configured hook is a hop-by-hop seam. Both are defensible; what is not defensible is the current state, where the code advertises the strong property in a comment and delivers it on only one of the two hooks.
The render-time half stays a comment fix either way. A direct call made during document SSR, outside any handleServerFunctionRequest, belongs to no handler invocation, so no per-request option can exist for it — that part of 440-445 is simply true. What is wrong there is 933-936, which advertises the render-bypass guarantee without saying the configured hook is the one that earns it. A test cannot pin a comment; the correction belongs in the same change as (1) if you take it, and is the whole of (2) if you do not.
/** * The per-handler `wrapInvocation` and the in-process call made UNDER it. * * A server function's body may call another server function directly — the * reference is in scope, and on the server calling it runs the original * in-process rather than going back out over HTTP (that is what * `createServerReference` is for). Both calls belong to one request. * * The configured wrap covers both, on purpose: `createServerReference`'s * apply trap reads `config.wrapInvocation`, so "per-function middleware * built on it can't be bypassed by calling the function during a render". * The per-handler OPTION reads nothing — it is threaded through the HTTP * dispatch tail only. For a document render that is a fact of scope: a * per-request option cannot exist for a call that is not a request, and * `HandleServerFunctionRequestOptions` says so ("it only applies to HTTP * dispatch"). Inside the handler it is not: the nested call happens within * the option's own dynamic extent, under the option's own event, and the * option is the only policy an adapter that wires per-request (per-tenant * policy derived from the request, a per-route gate) has. * * So an adapter that gates with the option gates the function the wire * addressed and nothing that function reaches in-process — the shape a * hop-by-hop authorization check exists to prevent. The invariant pinned * here: whichever wrap owns a request owns every server-function body * entered while handling it, not just the entry point. * * Only the nested leg is pinned. The render-time leg — a direct call made * during document SSR, outside any `handleServerFunctionRequest` — has no * per-request option to consult and cannot be fixed in code; there the * source comment above the apply trap, which advertises the guarantee * without naming which of the two hooks earns it, is what is wrong. * * 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{createRequestEvent}from"@solidjs/web";import{configureServerFunctionsServer,createServerReferenceascreateServerSideReference,handleServerFunctionRequest,registerServerFunction,registerServerReference}from"@solidjs/web/server-functions/server";constRequestContext=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. */constNO_HOOK=nullasany;beforeAll(()=>{(globalThisasany)[RequestContext]=newAsyncLocalStorage();});afterEach(()=>{configureServerFunctionsServer({wrapInvocation: NO_HOOK});});afterAll(()=>{delete(globalThisasany)[RequestContext];});constcreateEvent=(request: Request)=>createRequestEvent(request);functionpost(id: string){returnnewRequest(`https://app.example/_server/data/${id}`,{method: "POST",body: "[]",headers: {"Sec-Fetch-Site": "same-origin","content-type": "application/json","X-Server-Function-Format": "8","X-Server-Function-Instance": "server-function:test"}});}/** An entry function whose body calls a second server function in-process. */functionregisterPair(prefix: string){letinnerRan=0;constinner=createServerSideReference(registerServerReference(`${prefix}-inner`,()=>{innerRan++;return"the inner secret";}));registerServerFunction(`${prefix}-outer`,async()=>(innerasany)());return{ranInner: ()=>innerRan,reset: ()=>(innerRan=0)};}describe("the wrap that owns a request owns the calls made under it",()=>{it("names both the wire call and the call its body makes, when configured",async()=>{constseen: string[]=[];constpair=registerPair("nested-configured");configureServerFunctionsServer({wrapInvocation: (run,context)=>{seen.push(`${context.id}:${context.direct ? "direct" : "http"}`);returnrun();}});constresponse=awaithandleServerFunctionRequest(post("nested-configured-outer"),{
createEvent
});expect(response.status).toBe(200);expect(pair.ranInner()).toBe(1);// the configured hook is a real hop-by-hop seam: it sees the entry and// the in-process call the entry madeexpect(seen).toStrictEqual(["nested-configured-outer:http","nested-configured-inner:direct"]);});it("names both when the wrap arrives as a per-handler option instead",async()=>{constseen: string[]=[];constpair=registerPair("nested-option");constresponse=awaithandleServerFunctionRequest(post("nested-option-outer"),{
createEvent,wrapInvocation: (run,context)=>{seen.push(`${context.id}:${context.direct ? "direct" : "http"}`);returnrun();}});expect(response.status).toBe(200);expect(pair.ranInner()).toBe(1);// today: only ["nested-option-outer:http"] — the nested body ran with// no policy at all, inside the very request the option was given forexpect(seen).toStrictEqual(["nested-option-outer:http","nested-option-inner:direct"]);});it("stops the nested body when the request's wrap declines, not only the entry",async()=>{constpair=registerPair("nested-deny");constresponse=awaithandleServerFunctionRequest(post("nested-deny-outer"),{
createEvent,wrapInvocation: (run,context)=>{// an authorization check that clears the entry point and refuses// what it reaches — the reason a gate runs per invocationif(context.id==="nested-deny-inner")thrownewResponse(null,{status: 403});returnrun();}});// today: 200, and `ranInner()` is 1 — the refusal never had the chance// to fire because the nested invocation never consulted the optionexpect(pair.ranInner()).toBe(0);expect(response.status).toBe(403);});});
Against f0f7531b the first case passes (that is the configured hook, the CONTROL) and the other two fail — expected [ 'nested-option-outer:http' ] to strictly equal [ 'nested-option-outer:http', …(1) ] and expected 1 to be +0 on the denied inner body — so the spec is red on exactly the scope gap and stays green on the behaviour that already works.
Measured output at
f0f7531b:The CONTROL row is the contrast: the server-wide hook sees
cfg-outer:httpandcfg-inner:direct. The same function object, handed to the same request throughoptions.wrapInvocation, sees only the entry — and in the last row the refusal it was written to perform never gets the chance to fire:deny-inner's body ran and the response is 200.For reference, the same script against the tree with the fix below applied:
Where
All line numbers are
packages/web/server-functions/src/server.tsatf0f7531b.938-939— the apply trap (the direct in-process call path) consultsconfig.wrapInvocationand nothing else:There is no road from here to the per-handler option: the option is a local in the HTTP dispatch function, and the apply trap is reached through an ordinary function call from inside the dispatched body.
3030-3031— where the per-handler option is resolved, and3166-3167— the only place the resolved value is ever used, around the entry invocation. Between those two points nothing records the wrap anywhere the nested call can find it.440-445— the option's TSDoc states the limitation as intended behaviour: "except it only applies to HTTP dispatch (a per-request option can't see direct SSR calls)". Read as written about a document render this is true; read about a call made inside the dispatch the option was given for, it is not — that call is HTTP dispatch, just not its first frame.2765-2774— the handler's option-list TSDoc, same split.933-936— the apply trap's comment advertising the property without naming which hook earns it: "direct SSR calls run through the same policy as HTTP dispatch, so per-function middleware built on it can't be bypassed by calling the function during a render."Provenance. The implementation arrived in this repo with
89a0531c("Absorb expressions into Solid and collapse the rxcore seam.", 2026-08-25), which landed the runtime absorbed from@dom-expressions/runtimeatpackages/web/src/server-functions/server.js; earlier history is not in this repo.71821959("Migrate the absorbed DOM runtime to TypeScript…", 2026-08-25) moved it to the current path and lines, unchanged in this respect. The behaviour was a decision, not a slip:687a9937("Document the response-head lifecycle, composeMiddleware, and thewrapInvocationseam", 2026-08-05) wrote it into RFC 10 twenty days earlier — "the configured hook also wraps direct SSR calls … the per-request option applies to HTTP dispatch only" (documentation/solid-2.0/10-server-functions.md:65).2c90ae6b(the revert of #3059) rewrote line 938-939 but only back to its pre-#3059 single-expression form; it did not introduce this.30f9387d(#3156, per-calllocalscopy) created the derived event the fix below keys on, but did not cause the gap.Not the same defect as the
!== undefinedresolution on line 3031 (anulloption disabling the configured wrap rather than deferring to it). That is a resolution bug in the same expression; this one is a scope bug in a different code path, and they go red against different assertions.Why it matters
The realistic path: an adapter derives policy from the request — a per-tenant guard, a per-route gate, a request-scoped audit wrap — and therefore cannot install it once at boot. The per-handler option is the seam built for exactly that. In that setup a server function that clears the gate and then calls a second server function in-process runs the second body with no gate at all. The author of the entry function often does not experience the nested call as a call across a boundary: on the server the reference is just a function in scope, so
getOrder()callinggetCustomer()looks like ordinary composition, and the fact thatgetCustomeris separately reachable — with its own guard — is invisible at the call site. Row 3 shows the gap does not stop at one level.The honest limits:
wrapInvocationper handler. Anything usingconfigureServerFunctionsServer({ wrapInvocation })— which is what the TSDoc and RFC 10's own example steer you to, and what the CONTROL row exercises — is unaffected. No adapter in this repository passes the per-handler option; it is an API contract for framework authors, so the population at risk is whoever has already built on it.I would not describe this as an exploitable vulnerability. I would describe it as a seam that does not hold on one of its two spellings, and whose docs promise it does not have to.
Options
Record the request's wrap on the request event and inherit it onto the derived event the apply trap builds. A
WeakMapkeyed on the event, mirroringINVOCATIONSexactly — same keying, same lifetime, same reason for not living onevent.locals(user/integration space, and Concurrent direct server-function calls share the render's locals object and overwrite each other #3156's per-call copy gives it the wrong lifetime). Dispatch records the resolved wrap; the apply trap reads it off the incoming event, prefers it overconfig.wrapInvocation, and copies it to the derived event so depth falls out with no recursion. Cost: three small hunks, no new API, no new async-context store. Trade-off: it is a behaviour change — a per-handler wrap that today runs once per request may now run several times, so a wrap that counts invocations or assumesdirect === falsechanges meaning.Fix the documentation only. Say plainly in both TSDoc blocks and RFC 10 that per-request policy which must hold across in-process calls belongs in
configureServerFunctionsServer, and that the per-handler option covers the entry point alone. Zero code, zero behaviour change, no risk to existing users. Trade-off: two hooks namedwrapInvocationkeep meaning different things, and per-request-derived policy (per tenant, per route) is left with no seam that covers what the request actually executes — the case the option exists to serve.Reuse the
INVOCATIONSrecord — store{ id, wrap }instead of a secondWeakMap. One less map. Trade-off:getServerFunctionInvocation()is public and returns that record, so the policy hook would become readable by application code; identity and policy are different lifetimes and different audiences.Put the wrap on the event as a public field (
event.serverFunctionWrap). Discoverable, and an integration could override it mid-request. Trade-off: it makes runtime plumbing part of the event's public shape, which is the thing theINVOCATIONScomment already argues against.A dedicated
AsyncLocalStorageowned by server-functions. Survives any event derivation, not just the one the apply trap makes. Trade-off: a second async-context store beside theRequestContextALS the runtime already parks on the global, with its own bundling and its own failure mode when the two disagree.Swap
config.wrapInvocationfor the duration of dispatch. Tempting one-liner. It is wrong under concurrency: two overlapping requests would trade policies. Listed only so it stays rejected.Remove the per-handler option entirely; one configured hook. Smallest surface, and the ambiguity disappears with it. Trade-off: breaks whoever uses the option today and pushes per-request policy into reading the event from inside the configured hook.
Recommendation: (1). In Solid's minimalism terms it adds no API and no new mechanism — it reuses the keying decision the file already made for invocation identity, and rides the derived event #3156 already creates, so nesting works at any depth without a traversal. It also collapses two hooks that share a name into one meaning, which is less surface to document than the current carve-out.
Two calls are yours, not mine to assert:
Behaviour change vs. documentation. The narrow behaviour was written down deliberately in
687a9937. Choosing (1) amends a documented decision; choosing (2) keeps it and makes it explicit that only the configured hook is a hop-by-hop seam. Both are defensible; what is not defensible is the current state, where the code advertises the strong property in a comment and delivers it on only one of the two hooks.The render-time half stays a comment fix either way. A direct call made during document SSR, outside any
handleServerFunctionRequest, belongs to no handler invocation, so no per-request option can exist for it — that part of440-445is simply true. What is wrong there is933-936, which advertises the render-bypass guarantee without saying the configured hook is the one that earns it. A test cannot pin a comment; the correction belongs in the same change as (1) if you take it, and is the whole of (2) if you do not.Regression test
packages/web/test/server/server-functions-wrap-nested-direct.spec.tsx:Against
f0f7531bthe first case passes (that is the configured hook, the CONTROL) and the other two fail —expected [ 'nested-option-outer:http' ] to strictly equal [ 'nested-option-outer:http', …(1) ]andexpected 1 to be +0on the denied inner body — so the spec is red on exactly the scope gap and stays green on the behaviour that already works.