From 2c9d74bb0e911e1f92d3de31496a15979e32d1f2 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Feb 2023 16:57:02 -0500 Subject: [PATCH 1/7] Ensure stack traces are removed from all server side errors --- .changeset/tidy-jokes-sparkle.md | 5 + integration/error-sanitization-test.ts | 358 +++++++++++++++++++++ integration/helpers/create-fixture.ts | 8 +- packages/remix-server-runtime/errors.ts | 38 ++- packages/remix-server-runtime/responses.ts | 18 +- packages/remix-server-runtime/server.ts | 61 ++-- 6 files changed, 444 insertions(+), 44 deletions(-) create mode 100644 .changeset/tidy-jokes-sparkle.md create mode 100644 integration/error-sanitization-test.ts diff --git a/.changeset/tidy-jokes-sparkle.md b/.changeset/tidy-jokes-sparkle.md new file mode 100644 index 00000000000..9862266a585 --- /dev/null +++ b/.changeset/tidy-jokes-sparkle.md @@ -0,0 +1,5 @@ +--- +"@remix-run/server-runtime": patch +--- + +Ensure stack traces are removed from all server side errors in production diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts new file mode 100644 index 00000000000..7c3cd1bc0a8 --- /dev/null +++ b/integration/error-sanitization-test.ts @@ -0,0 +1,358 @@ +import { test, expect } from "@playwright/test"; +import { ServerMode } from "@remix-run/server-runtime/mode"; + +import { createFixture, js } from "./helpers/create-fixture"; +import type { Fixture } from "./helpers/create-fixture"; + +const routeFiles = { + "app/root.jsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/index.jsx": js` + import { useLoaderData, useLocation } from "@remix-run/react"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + throw new Error("Loader Error"); + } + return "LOADER" + } + + export default function Component() { + let data = useLoaderData(); + let location = useLocation(); + + if (location.search.includes('render')) { + throw new Error("Render Error"); + } + + return ( + <> +

Index Route

+

{JSON.stringify(data)}

+ + ); + } + + export function ErrorBoundary({ error }) { + return ( + <> +

Index Error

+

{"MESSAGE:" + error.message}

+ {error.stack ?

{"STACK:" + error.stack}

: null} + + ); + } + `, + + "app/routes/defer.jsx": js` + import * as React from 'react'; + import { defer } from "@remix-run/server-runtime"; + import { Await, useLoaderData, useRouteError } from "@remix-run/react"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + return defer({ + lazy: Promise.reject(new Error("REJECTED")), + }) + } + return defer({ + lazy: Promise.resolve("RESOLVED"), + }) + } + + export default function Component() { + let data = useLoaderData(); + + return ( + <> +

Defer Route

+ Loading...

}> + }> + {(val) =>

{val}

} +
+
+ + ); + } + + function AwaitError() { + let error = useRouteError(); + return ( + <> +

Defer Error

+

{error}

+ + ); + } + + export function ErrorBoundary({ error }) { + return ( + <> +

Index Error

+

{"MESSAGE:" + error.message}

+ {error.stack ?

{"STACK:" + error.stack}

: null} + + ); + } + `, + + "app/routes/resource.jsx": js` + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + throw new Error("Loader Error"); + } + return "RESOURCE LOADER" + } + `, +}; + +test.describe("Error Sanitization", () => { + let fixture: Fixture; + let oldConsoleError: () => void; + + test.beforeEach(() => { + oldConsoleError = console.error; + console.error = () => {}; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("serverMode=production", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: routeFiles, + }); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + // TODO: is this expected or should we get "Unexpected Server Error" here? + expect(html).toMatch('{"message":"REJECTED"}'); + expect(html).not.toMatch(/stack/i); + }); + + test("returns data without errors", async () => { + let response = await fixture.requestData("/", "routes/index"); + let text = await response.text(); + expect(text).toMatch("LOADER"); + expect(text).not.toMatch("MESSAGE:"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in data requests", async () => { + let response = await fixture.requestData("/?loader", "routes/index"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + }); + + test("returns deferred data without errors", async () => { + let response = await fixture.requestData("/defer", "routes/defer"); + let text = await response.text(); + expect(text).toMatch("RESOLVED"); + expect(text).not.toMatch("REJECTED"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let response = await fixture.requestData("/defer?loader", "routes/defer"); + let text = await response.text(); + expect(text).toBe( + '{"lazy":"__deferred_promise:lazy"}\n\n' + + 'error:{"lazy":{"message":"Unexpected Server Error"}}\n\n' + ); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestData( + "/resource?loader", + "routes/resource" + ); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + }); + + test("sanitizes mismatched route errors in data requests", async () => { + let response = await fixture.requestData("/", "not-a-route"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + }); + }); + + test.describe("serverMode=development", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: routeFiles, + }, + ServerMode.Development + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("

MESSAGE:Loader Error"); + expect(html).toMatch("

STACK:Error: Loader Error"); + expect(html).toMatch( + 'errors":{"routes/index":{"message":"Loader Error","stack":"Error: Loader Error\\n' + ); + }); + + test("does not sanitize render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("

MESSAGE:Render Error"); + expect(html).toMatch("

STACK:Error: Render Error"); + expect(html).toMatch( + 'errors":{"routes/index":{"message":"Render Error","stack":"Error: Render Error\\n' + ); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("does not sanitize defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch('{"message":"REJECTED"}'); + // TODO: I think we should be getting the stack here too? + expect(html).toMatch(/stack/i); + }); + + test("returns data without errors", async () => { + let response = await fixture.requestData("/", "routes/index"); + let text = await response.text(); + expect(text).toMatch("LOADER"); + expect(text).not.toMatch("MESSAGE:"); + expect(text).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in data requests", async () => { + let response = await fixture.requestData("/?loader", "routes/index"); + let text = await response.text(); + expect(text).toMatch( + '{"message":"Loader Error","stack":"Error: Loader Error' + ); + }); + + test("returns deferred data without errors", async () => { + let response = await fixture.requestData("/defer", "routes/defer"); + let text = await response.text(); + expect(text).toMatch("RESOLVED"); + expect(text).not.toMatch("REJECTED"); + expect(text).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in deferred data requests", async () => { + let response = await fixture.requestData("/defer?loader", "routes/defer"); + let text = await response.text(); + expect(text).toMatch( + 'error:{"lazy":{"message":"REJECTED","stack":"Error: REJECTED' + ); + }); + + test("does not sanitize loader errors in resource requests", async () => { + let response = await fixture.requestData( + "/resource?loader", + "routes/resource" + ); + let text = await response.text(); + expect(text).toMatch( + '{"message":"Loader Error","stack":"Error: Loader Error' + ); + }); + + test("sanitizes mismatched route errors in data requests", async () => { + let response = await fixture.requestData("/", "not-a-route"); + let text = await response.text(); + expect(text).toMatch( + '{"message":"Route \\"not-a-route\\" does not match URL \\"/\\"","stack":"Error: Route \\"not-a-route\\" does not match URL \\"/\\"' + ); + }); + }); +}); diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 4238d6a3b75..d5828db1f59 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -6,7 +6,7 @@ import getPort from "get-port"; import stripIndent from "strip-indent"; import { sync as spawnSync } from "cross-spawn"; import type { JsonObject } from "type-fest"; -import type { ServerMode } from "@remix-run/server-runtime/mode"; +import { ServerMode } from "@remix-run/server-runtime/mode"; import type { FutureConfig } from "@remix-run/server-runtime/entry"; import type { ServerBuild } from "../../build/node_modules/@remix-run/server-runtime"; @@ -34,11 +34,11 @@ export function json(value: JsonObject) { return JSON.stringify(value, null, 2); } -export async function createFixture(init: FixtureInit) { +export async function createFixture(init: FixtureInit, mode?: ServerMode) { let projectDir = await createFixtureProject(init); let buildPath = path.resolve(projectDir, "build"); let app: ServerBuild = await import(buildPath); - let handler = createRequestHandler(app, "production"); + let handler = createRequestHandler(app, mode || ServerMode.Production); let requestDocument = async (href: string, init?: RequestInit) => { let url = new URL(href, "test://test"); @@ -106,7 +106,7 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { "*", createExpressHandler({ build: fixture.build, - mode: mode || "production", + mode: mode || ServerMode.Production, }) ); diff --git a/packages/remix-server-runtime/errors.ts b/packages/remix-server-runtime/errors.ts index 85f98407f8c..e53704d874c 100644 --- a/packages/remix-server-runtime/errors.ts +++ b/packages/remix-server-runtime/errors.ts @@ -1,6 +1,8 @@ import type { StaticHandlerContext } from "@remix-run/router"; import { isRouteErrorResponse } from "@remix-run/router"; +import { ServerMode } from "./mode"; + /** * This thing probably warrants some explanation. * @@ -50,6 +52,24 @@ export interface ThrownResponse { data: T; } +export function sanitizeError(error: T, serverMode: ServerMode) { + if (error instanceof Error && serverMode !== ServerMode.Development) { + let sanitized = new Error("Unexpected Server Error"); + sanitized.stack = undefined; + return sanitized; + } + return error; +} + +export function sanitizeErrors( + errors: NonNullable, + serverMode: ServerMode +) { + return Object.entries(errors).reduce((acc, [routeId, error]) => { + return Object.assign(acc, { [routeId]: sanitizeError(error, serverMode) }); + }, {}); +} + // must be type alias due to inference issues on interfaces // https://github.com/microsoft/TypeScript/issues/15300 export type SerializedError = { @@ -57,15 +77,20 @@ export type SerializedError = { stack?: string; }; -export async function serializeError(error: Error): Promise { +export function serializeError( + error: Error, + serverMode: ServerMode +): SerializedError { + let sanitized = sanitizeError(error, serverMode); return { - message: error.message, - stack: error.stack, + message: sanitized.message, + stack: sanitized.stack, }; } export function serializeErrors( - errors: StaticHandlerContext["errors"] + errors: StaticHandlerContext["errors"], + serverMode: ServerMode ): StaticHandlerContext["errors"] { if (!errors) return null; let entries = Object.entries(errors); @@ -76,9 +101,10 @@ export function serializeErrors( if (isRouteErrorResponse(val)) { serialized[key] = { ...val, __type: "RouteErrorResponse" }; } else if (val instanceof Error) { + let sanitized = sanitizeError(val, serverMode); serialized[key] = { - message: val.message, - stack: val.stack, + message: sanitized.message, + stack: sanitized.stack, __type: "Error", }; } else { diff --git a/packages/remix-server-runtime/responses.ts b/packages/remix-server-runtime/responses.ts index 967c10eb24f..2605edffccb 100644 --- a/packages/remix-server-runtime/responses.ts +++ b/packages/remix-server-runtime/responses.ts @@ -5,6 +5,7 @@ import { } from "@remix-run/router"; import { serializeError } from "./errors"; +import type { ServerMode } from "./mode"; export type TypedDeferredData> = Pick< DeferredData, @@ -142,7 +143,8 @@ function isTrackedPromise(value: any): value is TrackedPromise { const DEFERRED_VALUE_PLACEHOLDER_PREFIX = "__deferred_promise:"; export function createDeferredReadableStream( deferredData: DeferredData, - signal: AbortSignal + signal: AbortSignal, + serverMode: ServerMode ): any { let encoder = new TextEncoder(); let stream = new ReadableStream({ @@ -172,7 +174,8 @@ export function createDeferredReadableStream( controller, encoder, preresolvedKey, - deferredData.data[preresolvedKey] as TrackedPromise + deferredData.data[preresolvedKey] as TrackedPromise, + serverMode ); } @@ -182,7 +185,8 @@ export function createDeferredReadableStream( controller, encoder, settledKey, - deferredData.data[settledKey] as TrackedPromise + deferredData.data[settledKey] as TrackedPromise, + serverMode ); } }); @@ -199,14 +203,18 @@ function enqueueTrackedPromise( controller: any, encoder: TextEncoder, settledKey: string, - promise: TrackedPromise + promise: TrackedPromise, + serverMode: ServerMode ) { if ("_error" in promise) { controller.enqueue( encoder.encode( "error:" + JSON.stringify({ - [settledKey]: serializeError(promise._error), + [settledKey]: + promise._error instanceof Error + ? serializeError(promise._error, serverMode) + : promise._error, }) + "\n\n" ) diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 0c6503a3588..7c778d469a0 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -14,7 +14,7 @@ import type { AppLoadContext } from "./data"; import type { ServerBuild } from "./build"; import type { EntryContext } from "./entry"; import { createEntryRouteModules } from "./entry"; -import { serializeError, serializeErrors } from "./errors"; +import { sanitizeErrors, serializeError, serializeErrors } from "./errors"; import { getDocumentHeadersRR } from "./headers"; import invariant from "./invariant"; import { ServerMode, isServerMode } from "./mode"; @@ -89,7 +89,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( let match = matches!.find((match) => match.route.id == routeId)!; response = await build.entry.module.handleDataRequest(response, { context: loadContext, - params: match.params, + params: match ? match.params : {}, request, }); } @@ -159,7 +159,11 @@ async function handleDataRequestRR( if (DEFERRED_SYMBOL in response) { let deferredData = response[DEFERRED_SYMBOL] as DeferredData; - let body = createDeferredReadableStream(deferredData, request.signal); + let body = createDeferredReadableStream( + deferredData, + request.signal, + serverMode + ); let init = deferredData.init || {}; let headers = new Headers(init.headers); headers.set("Content-Type", "text/remix-deferred"); @@ -174,26 +178,24 @@ async function handleDataRequestRR( return error; } - let status = 500; - let errorInstance = error; - - if (isRouteErrorResponse(error)) { - status = error.status; - errorInstance = error.error || errorInstance; - } + let status = isRouteErrorResponse(error) ? error.status : 500; + let errorInstance = + isRouteErrorResponse(error) && error.error + ? error.error + : error instanceof Error + ? error + : new Error("Unexpected Server Error"); if (serverMode !== ServerMode.Test && !request.signal.aborted) { console.error(errorInstance); } - if ( - serverMode === ServerMode.Development && - errorInstance instanceof Error - ) { - return errorBoundaryError(errorInstance, status); - } - - return errorBoundaryError(new Error("Unexpected Server Error"), status); + return json(serializeError(errorInstance, serverMode), { + status, + headers: { + "X-Remix-Error": "yes", + }, + }); } } @@ -270,6 +272,11 @@ async function handleDocumentRequestRR( return context; } + // Sanitize errors outside of development environments + if (context.errors) { + context.errors = sanitizeErrors(context.errors, serverMode); + } + // Restructure context.errors to the right Catch/Error Boundary if (build.future.v2_errorBoundary !== true) { differentiateCatchVersusErrorBoundaries(build, context); @@ -285,7 +292,7 @@ async function handleDocumentRequestRR( state: { loaderData: context.loaderData, actionData: context.actionData, - errors: serializeErrors(context.errors), + errors: serializeErrors(context.errors, serverMode), }, future: build.future, dev: build.dev, @@ -309,6 +316,11 @@ async function handleDocumentRequestRR( error ); + // Sanitize errors outside of development environments + if (context.errors) { + context.errors = sanitizeErrors(context.errors, serverMode); + } + // Restructure context.errors to the right Catch/Error Boundary if (build.future.v2_errorBoundary !== true) { differentiateCatchVersusErrorBoundaries(build, context); @@ -322,7 +334,7 @@ async function handleDocumentRequestRR( state: { loaderData: context.loaderData, actionData: context.actionData, - errors: serializeErrors(context.errors), + errors: serializeErrors(context.errors, serverMode), }, future: build.future, }), @@ -373,15 +385,6 @@ async function handleResourceRequestRR( } } -async function errorBoundaryError(error: Error, status: number) { - return json(await serializeError(error), { - status, - headers: { - "X-Remix-Error": "yes", - }, - }); -} - function returnLastResortErrorResponse(error: any, serverMode?: ServerMode) { if (serverMode !== ServerMode.Test) { console.error(error); From 4e2096a668c788d71a904ad16bbca33d15c6c09c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 6 Mar 2023 17:15:44 -0500 Subject: [PATCH 2/7] Fix up tests expecting unsanitized errors --- integration/error-boundary-test.ts | 19 +++++--- .../__tests__/server-test.ts | 46 +++++++++++++++---- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts index e660a20ff04..a8360494e78 100644 --- a/integration/error-boundary-test.ts +++ b/integration/error-boundary-test.ts @@ -418,7 +418,9 @@ test.describe("ErrorBoundary", () => { await page.waitForSelector("#child-error"); // Preserves parent loader data expect(await app.getHtml("#parent-data")).toMatch("PARENT"); - expect(await app.getHtml("#child-error")).toMatch("Broken!"); + expect(await app.getHtml("#child-error")).toMatch( + "Unexpected Server Error" + ); }); test("renders own boundary in fetcher action submission without action from other routes", async ({ @@ -988,9 +990,12 @@ test.describe("Default ErrorBoundary", () => { test.describe("When the root route does not have a boundary", () => { test.beforeAll(async () => { - fixture = await createFixture({ - files: getFiles({ includeRootErrorBoundary: false }), - }); + fixture = await createFixture( + { + files: getFiles({ includeRootErrorBoundary: false }), + }, + ServerMode.Development + ); appFixture = await createAppFixture(fixture, ServerMode.Development); }); @@ -1074,7 +1079,8 @@ test.describe("Default ErrorBoundary", () => { expect(res.status).toBe(500); let text = await res.text(); expect(text).toMatch("Root Error Boundary"); - expect(text).toMatch("Loader Error"); + expect(text).toMatch("Unexpected Server Error"); + expect(text).not.toMatch("Loader Error"); expect(text).not.toMatch("Application Error"); }); @@ -1083,7 +1089,8 @@ test.describe("Default ErrorBoundary", () => { expect(res.status).toBe(500); let text = await res.text(); expect(text).toMatch("Root Error Boundary"); - expect(text).toMatch("Render Error"); + expect(text).toMatch("Unexpected Server Error"); + expect(text).not.toMatch("Render Error"); expect(text).not.toMatch("Application Error"); }); }); diff --git a/packages/remix-server-runtime/__tests__/server-test.ts b/packages/remix-server-runtime/__tests__/server-test.ts index 51b3a0353fb..8c1d33708a3 100644 --- a/packages/remix-server-runtime/__tests__/server-test.ts +++ b/packages/remix-server-runtime/__tests__/server-test.ts @@ -1235,7 +1235,9 @@ describe("shared server runtime", () => { expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER); let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); - expect(context.errors!.root.message).toBe("index"); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", }); @@ -1278,7 +1280,11 @@ describe("shared server runtime", () => { expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER); let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); - expect(context.errors!["routes/index"].message).toBe("index"); + expect(context.errors!["routes/index"]).toBeInstanceOf(Error); + expect(context.errors!["routes/index"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/index"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", }); @@ -1326,7 +1332,9 @@ describe("shared server runtime", () => { expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER); let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); - expect(context.errors!.root.message).toBe("test"); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: null, "routes/test": null, @@ -1375,7 +1383,9 @@ describe("shared server runtime", () => { expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER); let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); - expect(context.errors!.root.message).toBe("index"); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: null, "routes/index": null, @@ -1424,7 +1434,11 @@ describe("shared server runtime", () => { expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER); let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); - expect(context.errors!["routes/test"].message).toBe("test"); + expect(context.errors!["routes/test"]).toBeInstanceOf(Error); + expect(context.errors!["routes/test"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/test"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", "routes/test": null, @@ -1473,7 +1487,11 @@ describe("shared server runtime", () => { expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER); let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); - expect(context.errors!["routes/index"].message).toBe("index"); + expect(context.errors!["routes/index"]).toBeInstanceOf(Error); + expect(context.errors!["routes/index"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/index"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", "routes/index": null, @@ -1530,7 +1548,11 @@ describe("shared server runtime", () => { expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER); let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); - expect(context.errors!["routes/__layout"].message).toBe("action"); + expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error); + expect(context.errors!["routes/__layout"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/__layout"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", "routes/__layout": null, @@ -1588,7 +1610,11 @@ describe("shared server runtime", () => { expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER); let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); - expect(context.errors!["routes/__layout"].message).toBe("action"); + expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error); + expect(context.errors!["routes/__layout"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/__layout"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", "routes/__layout": null, @@ -1624,7 +1650,7 @@ describe("shared server runtime", () => { calledBefore = true; return ogHandleDocumentRequest.call(null, arguments); }) as any; - let handler = createRequestHandler(build, ServerMode.Test); + let handler = createRequestHandler(build, ServerMode.Development); let request = new Request(`${baseUrl}/`, { method: "get" }); @@ -1637,7 +1663,7 @@ describe("shared server runtime", () => { expect(calls.length).toBe(2 * DATA_CALL_MULTIPIER); let context = calls[1][3].staticHandlerContext; expect(context.errors.root).toBeTruthy(); - expect(context.errors.root.message).toBe("thrown"); + expect(context.errors!.root.message).toBe("thrown"); expect(context.loaderData).toEqual({}); }); From ef3042ebe5046573b62088e387ba28ab2301af5f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 6 Mar 2023 18:14:53 -0500 Subject: [PATCH 3/7] Add sanitization to defer errors --- integration/error-sanitization-test.ts | 23 +++++++------- integration/helpers/create-fixture.ts | 24 +++++++++++--- packages/remix-react/components.tsx | 44 +++++++++++++++----------- 3 files changed, 57 insertions(+), 34 deletions(-) diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 7c3cd1bc0a8..a26e407275e 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -140,9 +140,12 @@ test.describe("Error Sanitization", () => { test.describe("serverMode=production", () => { test.beforeAll(async () => { - fixture = await createFixture({ - files: routeFiles, - }); + fixture = await createFixture( + { + files: routeFiles, + }, + ServerMode.Production + ); }); test("renders document without errors", async () => { @@ -183,7 +186,7 @@ test.describe("Error Sanitization", () => { expect(html).toMatch("Defer Route"); expect(html).toMatch("RESOLVED"); expect(html).not.toMatch("MESSAGE:"); - expect(html).not.toMatch(/stack/i); + expect(html).not.toMatch(/"stack":/i); }); test("sanitizes defer errors in document requests", async () => { @@ -191,9 +194,8 @@ test.describe("Error Sanitization", () => { let html = await response.text(); expect(html).toMatch("Defer Error"); expect(html).not.toMatch("RESOLVED"); - // TODO: is this expected or should we get "Unexpected Server Error" here? - expect(html).toMatch('{"message":"REJECTED"}'); - expect(html).not.toMatch(/stack/i); + expect(html).toMatch('{"message":"Unexpected Server Error"}'); + expect(html).not.toMatch(/"stack":/i); }); test("returns data without errors", async () => { @@ -291,7 +293,7 @@ test.describe("Error Sanitization", () => { expect(html).toMatch("Defer Route"); expect(html).toMatch("RESOLVED"); expect(html).not.toMatch("MESSAGE:"); - expect(html).not.toMatch(/stack/i); + expect(html).not.toMatch(/"stack":/i); }); test("does not sanitize defer errors in document requests", async () => { @@ -299,9 +301,8 @@ test.describe("Error Sanitization", () => { let html = await response.text(); expect(html).toMatch("Defer Error"); expect(html).not.toMatch("RESOLVED"); - expect(html).toMatch('{"message":"REJECTED"}'); - // TODO: I think we should be getting the stack here too? - expect(html).toMatch(/stack/i); + // TODO: Is our ServerMode.Development not making it into the build somehow? + expect(html).toMatch('{"message":"REJECTED","stack":"}'); }); test("returns data without errors", async () => { diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index d5828db1f59..ff390ea226f 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -35,7 +35,7 @@ export function json(value: JsonObject) { } export async function createFixture(init: FixtureInit, mode?: ServerMode) { - let projectDir = await createFixtureProject(init); + let projectDir = await createFixtureProject(init, mode); let buildPath = path.resolve(projectDir, "build"); let app: ServerBuild = await import(buildPath); let handler = createRequestHandler(app, mode || ServerMode.Production); @@ -141,7 +141,8 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { //////////////////////////////////////////////////////////////////////////////// export async function createFixtureProject( - init: FixtureInit = {} + init: FixtureInit = {}, + mode?: ServerMode ): Promise { let template = init.template ?? "node-template"; let integrationTemplateDir = path.join(__dirname, template); @@ -193,17 +194,30 @@ export async function createFixtureProject( } await writeTestFiles(init, projectDir); - build(projectDir, init.buildStdio, init.sourcemap); + build(projectDir, init.buildStdio, init.sourcemap, mode); return projectDir; } -function build(projectDir: string, buildStdio?: Writable, sourcemap?: boolean) { +function build( + projectDir: string, + buildStdio?: Writable, + sourcemap?: boolean, + mode?: ServerMode +) { let buildArgs = ["node_modules/@remix-run/dev/dist/cli.js", "build"]; if (sourcemap) { buildArgs.push("--sourcemap"); } - let buildSpawn = spawnSync("node", buildArgs, { cwd: projectDir }); + + console.log("Building with NODE_ENV =", mode || ServerMode.Production); + let buildSpawn = spawnSync("node", buildArgs, { + cwd: projectDir, + env: { + ...process.env, + NODE_ENV: mode || ServerMode.Production, + }, + }); // These logs are helpful for debugging. Remove comments if needed. // console.log("spawning @remix-run/dev/cli.js `build`:\n"); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 092d4e52e6f..f854ff5aa72 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -815,8 +815,9 @@ export function Scripts(props: ScriptProps) { : [ "__remixContext.p = function(v,e,p,x) {", " if (typeof e !== 'undefined') {", - " x=new Error(e.message);", - process.env.NODE_ENV === "development" ? `x.stack=e.stack;` : "", + process.env.NODE_ENV === "development" + ? " x=new Error(e.message);\n x.stack=e.stack;" + : ' x=new Error("Unexpected Server Error");\n x.stack=undefined;', " p=Promise.reject(x);", " } else {", " p=Promise.resolve(v);", @@ -835,8 +836,9 @@ export function Scripts(props: ScriptProps) { "__remixContext.r = function(i,k,v,e,p,x) {", " p = __remixContext.t[i][k];", " if (typeof e !== 'undefined') {", - " x=new Error(e.message);", - process.env.NODE_ENV === "development" ? `x.stack=e.stack;` : "", + process.env.NODE_ENV === "development" + ? " x=new Error(e.message);\n x.stack=e.stack;" + : ' x=new Error("Unexpected Server Error");\n x.stack=undefined;', " p.e(x);", " } else {", " p.r(v);", @@ -866,13 +868,16 @@ export function Scripts(props: ScriptProps) { } else { let trackedPromise = deferredData.data[key] as TrackedPromise; if (typeof trackedPromise._error !== "undefined") { - let toSerialize: { message: string; stack?: string } = { - message: trackedPromise._error.message, - stack: undefined, - }; - if (process.env.NODE_ENV === "development") { - toSerialize.stack = trackedPromise._error.stack; - } + let toSerialize: { message: string; stack?: string } = + process.env.NODE_ENV === "development" + ? { + message: trackedPromise._error.message, + stack: trackedPromise._error.stack, + } + : { + message: "Unexpected Server Error", + stack: undefined, + }; return `${JSON.stringify( key )}:__remixContext.p(!1, ${escapeHtml( @@ -1078,13 +1083,16 @@ function ErrorDeferredHydrationScript({ routeId: string; }) { let error = useAsyncError() as Error; - let toSerialize: { message: string; stack?: string } = { - message: error.message, - stack: undefined, - }; - if (process.env.NODE_ENV === "development") { - toSerialize.stack = error.stack; - } + let toSerialize: { message: string; stack?: string } = + process.env.NODE_ENV === "development" + ? { + message: error.message, + stack: error.stack, + } + : { + message: "Unexpected Server Error", + stack: undefined, + }; return (