diff --git a/packages/eve/src/internal/nitro/host/channel-routes.ts b/packages/eve/src/internal/nitro/host/channel-routes.ts index ec02822d3..877fb13af 100644 --- a/packages/eve/src/internal/nitro/host/channel-routes.ts +++ b/packages/eve/src/internal/nitro/host/channel-routes.ts @@ -17,6 +17,24 @@ import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; // Must stay under `#nitro/virtual/` — the dev bundler's virtual plugin // freezes its resolveId filter at config creation, and that prefix is the +const UNSAFE_JS_CODE_CHAR_MAP: Record = { + "<": "\\u003C", + ">": "\\u003E", + "/": "\\u002F", + "\\": "\\\\", + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + "\0": "\\0", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +function escapeUnsafeJsCodeChars(value: string): string { + return value.replace(/[<>\/\\\b\f\n\r\t\0\u2028\u2029]/g, (char) => UNSAFE_JS_CODE_CHAR_MAP[char] ?? char); +} // only pattern under which channel routes added while `eve dev` runs can // still resolve (see dev-live-virtual-modules.ts). const EVE_CHANNEL_VIRTUAL_ID_PREFIX = "#nitro/virtual/eve-channel/"; @@ -187,7 +205,7 @@ function addChannelVirtualHandler( `import { defineWebSocketHandler } from ${nitroModulePath};`, `import { dispatchChannelWebSocketRequest } from ${dispatchModulePath};`, `const config = ${JSON.stringify(input.artifactsConfig)};`, - `export default defineWebSocketHandler((event) => dispatchChannelWebSocketRequest(event, ${JSON.stringify(routeKey)}, config));`, + `export default defineWebSocketHandler((event) => dispatchChannelWebSocketRequest(event, ${escapeUnsafeJsCodeChars(JSON.stringify(routeKey))}, config));`, ].join("\n"); return; } @@ -215,12 +233,12 @@ function addChannelVirtualHandler( `import { dispatchChannelRequest } from ${dispatchModulePath};`, `const config = ${JSON.stringify(input.artifactsConfig)};`, input.cors === undefined - ? `export default (event) => dispatchChannelRequest(event, ${JSON.stringify(routeKey)}, config);` + ? `export default (event) => dispatchChannelRequest(event, ${escapeUnsafeJsCodeChars(JSON.stringify(routeKey))}, config);` : [ `export default (event) => {`, ` const corsResponse = handleCors(event, cors);`, ` if (corsResponse !== false) return corsResponse;`, - ` return dispatchChannelRequest(event, ${JSON.stringify(routeKey)}, config);`, + ` return dispatchChannelRequest(event, ${escapeUnsafeJsCodeChars(JSON.stringify(routeKey))}, config);`, `};`, ].join("\n"), ].join("\n"); diff --git a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts index ffe216806..212807249 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts @@ -33,8 +33,23 @@ function includesApplicationRoutes(surface: NitroBuildSurface): boolean { return surface === "all" || surface === "app"; } -function includesWorkflowBundles(surface: NitroBuildSurface): boolean { - return includesWorkflowRoute(surface); +const INLINE_JS_UNSAFE_CHAR_MAP: Record = { + "<": "\\u003C", + ">": "\\u003E", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +/** + * Escapes ONLY the characters that {@link JSON.stringify} leaves intact but are + * unsafe to embed directly in inline JS/HTML source (`<`, `>`, and the U+2028 / + * U+2029 line separators). The input is expected to already be a valid JS + * string literal produced by `JSON.stringify`, so backslashes, quotes and + * control characters are already escaped — re-escaping them here would double + * the backslashes and corrupt values such as Windows filesystem paths. + */ +function escapeUnsafeCharsForInlineJs(value: string): string { + return value.replace(/[<>\u2028\u2029]/g, (char) => INLINE_JS_UNSAFE_CHAR_MAP[char] ?? char); } function includesWorkflowRoute(surface: NitroBuildSurface): boolean { @@ -204,6 +219,10 @@ function buildWorkflowFileHandlerSource(input: { lines.push(`import ${JSON.stringify(input.workflowWorldPluginImportSpecifier)};`); } + // NOTE: The generated handler intentionally uses top-level `await`. + // This requires the emitted module to stay ESM (for example `.mjs`) and be + // loaded/transpiled by tooling that supports top-level await. Changing the + // extension/loader pipeline to CommonJS or non-TLA environments will fail at load time. lines.push( `import { getWorld as __eveGetWorkflowWorld } from ${JSON.stringify(input.runtimeImportSpecifier)};`, "", @@ -226,7 +245,7 @@ function buildWorkflowFileHandlerSource(input: { ); } - lines.push("", "export default async ({ req }) => {", " return await POST(req);", "};", ""); + lines.push("", "export default async ({ req }) => {", " return POST(req);", "};", ""); return lines.join("\n"); } @@ -252,6 +271,7 @@ function addFrameworkVirtualHandler( ): void { const virtualId = `#eve-route${input.route}`; const modulePath = stringifyEsmImportSpecifier(input.modulePath); + const escapedArgs = escapeUnsafeCharsForInlineJs(input.args); nitro.options.handlers.push({ handler: virtualId, @@ -260,7 +280,7 @@ function addFrameworkVirtualHandler( }); nitro.options.virtual[virtualId] = [ `import { ${input.handlerExport} } from ${modulePath};`, - `export default async (event) => ${input.handlerExport}(${input.args}, event.req);`, + `export default async (event) => ${input.handlerExport}(${escapedArgs}, event.req);`, ].join("\n"); }