Next.js Server Action arguments are Flight data: use error digests carefully

·4 mins

A Next.js Server Action can receive a JSON-looking body whose strings are interpreted as React Flight tokens. If ordinary input works but $-prefixed input changes the response or produces HTTP 500, inspect the decoded arguments before trying more SQL or shell payloads. Differences between error digests can help separate failure paths, but they cannot establish a vulnerable version or remote code execution on their own.

This matters when testing the request behind an action call. The normal client serializer escapes literal strings for the protocol; manually editing the wire body can change what the server decodes.

The application sees the result of decodeReply

In the tagged Next.js 15.0.0 action handler , fetch-action bodies pass through decodeReply or its streaming counterpart before the selected action runs. The React 19.0.0 reply decoder interprets $-prefixed strings, including escaped literals, special numbers, and references.

For a local decoder, these argument values illustrate the distinction:

Wire string inside the arguments arrayDecoded argument
ordinarystring ordinary
$$literalstring $literal
$Infinitynumber Infinity
$-Infinitynumber -Infinity
$undefinedundefined

A value changing type before the application function runs explains why an otherwise boring search action can behave strangely. Input validation inside that function does not protect an earlier, vulnerable deserializer.

Reproduce the protocol locally before probing further

Use an isolated scratch directory. The following intentionally installs an old, vulnerable package for an offline decoder experiment. Do not deploy it, expose a listener, or feed it untrusted traffic. Delete the directory afterward.

LAB=$(mktemp -d "${TMPDIR:?Set TMPDIR to your scratch directory}/flight-lab.XXXXXX")
cd "$LAB"
npm install --ignore-scripts --no-audit --no-fund --save-exact \
  react@19.0.0 react-dom@19.0.0 react-server-dom-webpack@19.0.0

Save this as check.cjs in that directory, then run node --conditions=react-server check.cjs:

const assert = require('node:assert/strict');
const { decodeReply } = require('react-server-dom-webpack/server.node');

async function check() {
  const cases = [
    ['ordinary', 'ordinary'],
    ['$$literal', '$literal'],
    ['$Infinity', Infinity],
    ['$-Infinity', -Infinity],
    ['$undefined', undefined],
  ];
  for (const [wire, expected] of cases) {
    const args = await decodeReply(JSON.stringify([wire]), {});
    assert.ok(Array.isArray(args));
    assert.equal(args[0], expected);
  }
  console.log('5 Flight decoding cases passed');
}
check().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

This checks protocol behavior, not exploitability. For an authorized application assessment, reproduce the actual resolved packages and runtime conditions. Next.js may bundle its React implementation; a standalone npm package is not automatically an exact framework replica.

Preserve the decoded root’s expected shape, too. The tagged Next.js handler invokes actionHandler.apply(null, boundActionArguments). A root that resolves to a primitive string fails that call’s array-like argument requirement before the intended action behavior can be tested. Keep transport decoding and action invocation as separate checkpoints.

What an error digest can tell you

The Next.js 15.0.0 error handler generates digests using stringHash over error text and stack information in several paths, unless a digest already exists. One path uses:

err.digest = stringHash(err.message + (err.stack || '')).toString()

Different parser branches can throw from different stack locations and yield different digests even when the visible error message is generic. Record the complete response, status, and digest for each controlled input; repeat baseline requests to check stability. Correlate differences with logs in the local replica.

A digest is a lossy fingerprint. Build paths, package versions, runtime stacks, and existing application digests can alter it. Distinct digests suggest distinct error material; matching digests do not prove identical execution paths. HTTP 500 alone does not even locate the failure in Flight decoding.

Separate protocol reachability from React2Shell

React’s CVE-2025-55182 advisory confirms unauthenticated RCE through a flaw in decoding Server Function payloads. Ordinary Flight tokens also exist in correct protocol handling. Observing the transformations above therefore does not diagnose that CVE.

For exploit validation, first reproduce the affected decoder in a private replica and demonstrate a harmless, deterministic effect there. Keep that result separate from any authorized remote confirmation. The experiment above deliberately stops at benign decoding.

For prevention, follow the current React and framework advisories, update the resolved server-side packages, rebuild, and verify what the running artifact actually contains. Do not use the historical demonstration versions as a patch recommendation. Authenticate and authorize actions independently, validate decoded arguments, and avoid exposing decoded-input debug echoes. Those controls do not replace fixing a vulnerable decoder.

When a $ probe changes behavior, capture the full response and reproduce that transformation locally before assigning a CVE or an impact rating.