diff --git a/.changeset/wild-pots-repeat.md b/.changeset/wild-pots-repeat.md new file mode 100644 index 000000000..7f30570d2 --- /dev/null +++ b/.changeset/wild-pots-repeat.md @@ -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. diff --git a/packages/start/src/fns/serialization.spec.ts b/packages/start/src/fns/serialization.spec.ts new file mode 100644 index 000000000..a78fa5100 --- /dev/null +++ b/packages/start/src/fns/serialization.spec.ts @@ -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) { + 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"); + }); +}); diff --git a/packages/start/src/fns/serialization.ts b/packages/start/src/fns/serialization.ts index e09e1b004..4335661b5 100644 --- a/packages/start/src/fns/serialization.ts +++ b/packages/start/src/fns/serialization.ts @@ -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: * @@ -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( @@ -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) {