Summary
ee73e053 ("accept empty server function posts", for #3214) is described as treating an empty POST body as a zero-argument call. The implementation returns parsed, which by that point may already hold arguments decoded from the URL query. So an untagged empty POST executes with whatever ?args= says, not with zero arguments.
That makes this the only POST shape where ?args= is honoured. Every other POST discards it.
Measured
next @ ee73e053, same registered function, body an empty stream, Sec-Fetch-Site: same-origin:
empty body, no query status=200 received=[]
empty body + ?args status=200 received=["pwned"]
Mutation test — disabling the new condition and rebuilding:
empty body, no query status=400 received="NEVER-RAN"
empty body + ?args status=400 received="NEVER-RAN"
So the new line is what admits both, and the second row is a behaviour change the commit message does not describe.
Where
packages/web/server-functions/src/server.ts:1232
if (decoded === undefined) {
if (bodyFormat === null && (await request.clone().arrayBuffer()).byteLength === 0) {
return parsed; // <- may already carry URL-derived arguments
}
Compare the tagged branch twelve lines above, which returns the decoded body and drops parsed entirely:
if (bodyFormat === BodyFormat.Serialized || bodyFormat === BodyFormat.Json) {
…
return stripUnsafeArgumentKeys(decoded);
}
Not a cross-origin hole
I checked this before writing it up: the CSRF gate is on by default (server.ts:549), runs before argument parsing, and refuses every cross-site shape — foreign <form method=post>, fetch(mode:'no-cors'), Origin: null, and requests with no proof at all, all 403 with the body never running. Mutating the gate flips every one of those to executed, so that check is real. Reaching this path needs a same-origin request, i.e. you already have XSS.
So this is a correctness inconsistency, not a vulnerability. Filing it because the intent ("zero-argument calls") and the behaviour ("URL-argument calls") differ, and because argument provenance seems like something worth keeping deliberate.
A second, smaller point on the same line
The runtime now calls a function on arguments it was never sent — a declared parameter silently becomes undefined where the call previously refused. The comment eight lines below describes exactly that failure as the reason #3130 was fixed:
Refusing is the point: substituting undefined for the body calls the function on an argument it was never sent, and the mutation commits and answers 200 (#3130).
The cases are genuinely different — #3130 was an unusable format tag (arguments were sent, and could not be read), this is a genuinely empty body (nothing was sent). But the consequence for deleteAccount(confirmToken) behind a truncating proxy is the same shape, and the changeset does not mention that arity is unchecked.
Options
return [] instead of return parsed — matches the stated intent exactly, and makes the untagged path consistent with the tagged one.
- Keep
parsed but assert it is empty, so a URL-argument POST stays a 400.
- Keep the behaviour and say so in the changeset, since URL-borne arguments on a POST are then a supported shape.
(1) is one word and is what the commit message already promises.
Regression test
it("treats an empty POST as zero arguments, not as URL arguments", async () => {
const seen = [];
registerServerFunction(id, async (...args) => { seen.push(args); return "ok"; });
const response = await handleServerFunctionRequest(
emptyPost(`/_server/${id}?args=%5B%22pwned%22%5D`)
);
expect(response.status).toBe(200);
expect(seen).toEqual([[]]);
});
Summary
ee73e053("accept empty server function posts", for #3214) is described as treating an empty POST body as a zero-argument call. The implementation returnsparsed, which by that point may already hold arguments decoded from the URL query. So an untagged empty POST executes with whatever?args=says, not with zero arguments.That makes this the only POST shape where
?args=is honoured. Every other POST discards it.Measured
next@ee73e053, same registered function, body an empty stream,Sec-Fetch-Site: same-origin:Mutation test — disabling the new condition and rebuilding:
So the new line is what admits both, and the second row is a behaviour change the commit message does not describe.
Where
packages/web/server-functions/src/server.ts:1232Compare the tagged branch twelve lines above, which returns the decoded body and drops
parsedentirely:Not a cross-origin hole
I checked this before writing it up: the CSRF gate is on by default (
server.ts:549), runs before argument parsing, and refuses every cross-site shape — foreign<form method=post>,fetch(mode:'no-cors'),Origin: null, and requests with no proof at all, all403with the body never running. Mutating the gate flips every one of those to executed, so that check is real. Reaching this path needs a same-origin request, i.e. you already have XSS.So this is a correctness inconsistency, not a vulnerability. Filing it because the intent ("zero-argument calls") and the behaviour ("URL-argument calls") differ, and because argument provenance seems like something worth keeping deliberate.
A second, smaller point on the same line
The runtime now calls a function on arguments it was never sent — a declared parameter silently becomes
undefinedwhere the call previously refused. The comment eight lines below describes exactly that failure as the reason #3130 was fixed:The cases are genuinely different — #3130 was an unusable format tag (arguments were sent, and could not be read), this is a genuinely empty body (nothing was sent). But the consequence for
deleteAccount(confirmToken)behind a truncating proxy is the same shape, and the changeset does not mention that arity is unchecked.Options
return []instead ofreturn parsed— matches the stated intent exactly, and makes the untagged path consistent with the tagged one.parsedbut assert it is empty, so a URL-argument POST stays a 400.(1) is one word and is what the commit message already promises.
Regression test