Skip to content

An empty POST executes with URL-borne ?args=, not with zero arguments #3220

Description

@frenzzy

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

  1. return [] instead of return parsed — matches the stated intent exactly, and makes the untagged path consistent with the tagged one.
  2. Keep parsed but assert it is empty, so a URL-argument POST stays a 400.
  3. 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([[]]);
});

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