Skip to content

A returned Error's non-enumerable slots ship unsanitized — the #3235 guard walks Object.keys but seroval encodes getOwnPropertyNames #3268

Description

@frenzzy

Summary

5cee0f77 (#3235) widened the result-encoding guard to descend Error-prototyped carriers, so a failure channel returned under an Error gets sanitized. The descent enumerates the carrier with Object.keys, but seroval encodes an Error's own properties with Object.getOwnPropertyNames. Everything under a non-enumerable own slot — cause (non-enumerable by spec since ES2022), AggregateError.errors, any defineProperty context slot — and its whole subtree is therefore never walked, never sanitized, and ships to the client verbatim on a head-already-committed 200.

The demonstrated payload is a Postgres DSN with credentials, which is exactly the shape a driver error carries in its cause.

Reproduction

const SECRET = "conn=postgres://u:SECRET_PW@db";

registerServerFunction("err", async () => {
  const e = new Error("query failed");
  Object.defineProperty(e, "cause", { value: new Error(SECRET), enumerable: false, configurable: true });
  return { failure: e };                       // returned as a value, not thrown
});

// CONTROL: identical secret on a non-enumerable slot of a PLAIN object
registerServerFunction("obj", async () => {
  const o = { msg: "query failed" };
  Object.defineProperty(o, "cause", { value: new Error(SECRET), enumerable: false, configurable: true });
  return { failure: o };
});

NODE_ENV=production, both posted through handleServerFunctionRequest:

ERROR carrier  : secretOnWire=true
OBJECT control : secretOnWire=false

The asymmetry is the point: a plain object's non-enumerable slot is correctly left alone (it is never encoded), but the Error carrier's is encoded by seroval and shipped unsanitized. Symbol-keyed slots do not leak; the exposure is Error own string-keyed non-enumerable slots.

Where

packages/web/server-functions/src/server.ts:2568

return new Frame(OBJECT, value, next, Object.keys(value), descriptors);

The Object.keys(value) walk was correct for plain objects but under-covers an Error, whose channel-bearing slots seroval reaches through Object.getOwnPropertyNames. Introduced by 5cee0f77 (#3235) — the widening that made Error carriers guarded in the first place; it closed the enumerable case and left the non-enumerable one.

Why it matters

Reachable by any anonymous caller of any server function that returns (does not throw) an Error carrying a live failure reason under a non-enumerable slot. cause is the ordinary place a wrapped driver or domain error puts its context, and it is non-enumerable by default — so return { error: dbError } after a caught query is enough, with no unusual code. The runtime's own guard (#3235) is what promises this is sanitized; it is not, for the slot most likely to hold the secret.

Same rebuild also drops the Error's stack (V8 installs it as an own accessor bound to the original error, which Object.create(prototype, descriptors) does not reproduce) and strips receiver-bound state from an Error subclass with an own accessor over private fields — both fallout of the same Object.keys + Object.create rebuild, both lower-stakes than the leak.

Options

  1. Walk Reflect.ownKeys(value) (or Object.getOwnPropertyNames + own symbols) for the carrier, matching what seroval encodes, so every slot the codec will emit is a slot the guard descended. Minimal and it makes the walk and the encoder agree — which is the invariant the guard depends on.
  2. Special-case Error carriers to walk their non-enumerable own slots explicitly. Narrower, but it leaves the walk and the encoder able to drift again for the next carrier type.

(1) is the durable fix: the guard should enumerate exactly what the encoder will.

Regression test

it("sanitizes a channel under an Error's non-enumerable slot", async () => {
  registerServerFunction(id, async () => {
    const e = new Error("x");
    Object.defineProperty(e, "cause", { value: new Error("SECRET"), enumerable: false, configurable: true });
    return { failure: e };
  });
  const body = await (await handleServerFunctionRequest(post(id))).text();
  expect(body).not.toContain("SECRET");
});

Reverting the walk to Object.keys makes body carry SECRET, so the test earns its place.

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