Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wild-pots-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": patch
---

Don't send server error stack traces to the client in production builds. When a server function throws, the error is serialized and rethrown on the client, and seroval included `Error.prototype.stack` by default, leaking server file paths and internal function names. Stacks are still serialized in development.
78 changes: 78 additions & 0 deletions packages/start/src/fns/serialization.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

async function loadSerialization(prod: boolean) {
vi.stubEnv("PROD", prod as any);
vi.stubEnv("DEV", !prod as any);
vi.resetModules();
return await import("./serialization.ts");
}

async function readStream(stream: ReadableStream<Uint8Array>) {
return await new Response(stream).text();
}

function createError() {
function inner() {
throw new Error("my server error");
}
try {
inner();
throw new Error("unreachable");
} catch (error) {
return error as Error;
}
}

describe("serialization", () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("omits the error stack from the JSON stream in production", async () => {
const { serializeToJSONStream } = await loadSerialization(true);
const error = createError();

const payload = await readStream(serializeToJSONStream(error));

expect(payload).toContain("my server error");
expect(payload).not.toContain("stack");
expect(payload).not.toContain("serialization.spec.ts");
});

it("omits the error stack from the JS stream in production", async () => {
const { serializeToJSStream } = await loadSerialization(true);
const error = createError();

const payload = await readStream(serializeToJSStream("server-fn:0", error));

expect(payload).toContain("my server error");
expect(payload).not.toContain("serialization.spec.ts");
});

it("keeps the error stack in development", async () => {
const { serializeToJSONStream } = await loadSerialization(false);
const error = createError();

const payload = await readStream(serializeToJSONStream(error));

expect(payload).toContain("my server error");
expect(payload).toContain("stack");
expect(payload).toContain("serialization.spec.ts");
});

it("round-trips an error carrying a stack even when serialization strips it", async () => {
const dev = await loadSerialization(false);
const withStack = await readStream(dev.serializeToJSONStream(createError()));

const prod = await loadSerialization(true);
const parsed = (await prod.deserializeFromJSONString(withStack)) as Error;

expect(parsed).toBeInstanceOf(Error);
expect(parsed.message).toBe("my server error");
expect(parsed.stack).toContain("serialization.spec.ts");
});
});
24 changes: 23 additions & 1 deletion packages/start/src/fns/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ const DEFAULT_PLUGINS = [
const MAX_SERIALIZATION_DEPTH_LIMIT = 64;
const DISABLED_FEATURES = Feature.RegExp;

/**
* An error thrown by a server function is serialized and rethrown on the
* client, and seroval includes `Error.prototype.stack` by default. In
* production that leaks server file paths, internal function names and the
* shape of the deployment to anyone who can trigger a throw, so strip it.
* Development keeps the stack: that's where it's actually useful, and the
* paths it exposes are the developer's own.
*
* Only applied when writing; parsing leaves `DISABLED_FEATURES` alone so an
* incoming payload that does carry a stack still deserializes.
*/
const SERIALIZE_DISABLED_FEATURES = import.meta.env.PROD
? DISABLED_FEATURES | Feature.ErrorPrototypeStack
: DISABLED_FEATURES;

/**
* `crossSerializeStream` historically ran with every feature enabled, so only
* add the stack removal here rather than the full serialize set.
*/
const JS_SERIALIZE_DISABLED_FEATURES = import.meta.env.PROD ? Feature.ErrorPrototypeStack : 0;

/**
* Alexis:
*
Expand Down Expand Up @@ -71,6 +92,7 @@ export function serializeToJSStream(id: string, value: any) {
start(controller) {
crossSerializeStream(value, {
scopeId: id,
disabledFeatures: JS_SERIALIZE_DISABLED_FEATURES,
plugins: DEFAULT_PLUGINS,
onSerialize(data: string, initial: boolean) {
controller.enqueue(
Expand All @@ -92,7 +114,7 @@ export function serializeToJSONStream(value: any) {
return new ReadableStream({
start(controller) {
toCrossJSONStream(value, {
disabledFeatures: DISABLED_FEATURES,
disabledFeatures: SERIALIZE_DISABLED_FEATURES,
depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT,
plugins: DEFAULT_PLUGINS,
onParse(node) {
Expand Down
Loading