Root cause
Injection & unsafe execution introduced
Add Swagger UI support to OpenAPI Reference Plugin
How AI contributed: Direct introductionPublished Mar 20, 2026 in
middleapi/orpc
A Stored Cross-Site Scripting (XSS) vulnerability exists in the OpenAPI documentation generation of orpc. If an attacker can control any field within the OpenAPI specification (such as info.description), they can break out of the JSON context and execute arbitrary JavaScript when a user views the generated API documentation. In the packages/openapi/src/plugins/openapi-reference.ts file, the renderDocsHtml() function takes an OpenAPI spec object and embeds it directly into the HTML response using a template literal: <script id="spec" type="application/json">${JSON.stringify(spec)}</script> The JSON.stringify() function does not escape HTML characters like < or >. Therefore, if an attacker provides a string containing </script><script>..., the browser will prematurely close the application/json script block and execute the subsequent malicious script block. Proof of Concept (PoC) 1. Create an API router with orpc and configure the OpenAPI plugin. 2. In the API specification, inject a malicious payload into a field like description: 3. Generate and serve the documentation HTML. 4. When a developer or user navigates to the API documentation URL, the browser parses the HTML, breaks out of the JSON block, and immediately executes the alert() payload. Impact If an application generates its OpenAPI specifications dynamically based on user-controlled inputs (or if a rogue developer modifies the spec), it leads to Stored XSS. When an administrator or developer views the API docs, the script executes in their browser, potentially leading to session hijacking or unauthorized API calls on their behalf. Remediation Do not use raw JSON.stringify() to embed data directly into HTML templates. Instead, safely serialize the JSON by escaping HTML-sensitive characters (like < and >) or use a secure HTML serialization library (such as serialize-javascript or devalue) before embedding it into the <script> tag. Disclosure & Credits If this report is helpful, I kindly request that you publish this as a formal GitHub Security Advisory and assign me credit for the discovery. Thank you for maintaining this project!
Copilot member inserts unescaped stringifyJSON(swaggerConfig) into a script-tag SwaggerUIBundle call; parent Scalar path used HTML-attribute escaping.
Root cause
Add Swagger UI support to OpenAPI Reference Plugin
Fix
Serializes Swagger and Scalar configuration through JSON-for-HTML escaping before embedding it in script elements, preventing specification content from terminating the script and executing markup.
Case facts
@@ -34,6 +34,13 @@ export interface OpenAPIReferencePluginOptions<T extends Context> extends OpenAP */ docsTitle?: Value<Promisable<string>, [StandardHandlerInterceptorOptions<T>]> + /**+ * The UI library to use for rendering the API reference.+ *+ * @default 'scalar'+ */+ uiType?: 'scalar' | 'swagger'+ /** * Arbitrary configuration object for the UI. */@@ -49,10 +56,18 @@ export interface OpenAPIReferencePluginOptions<T extends Context> extends OpenAP /** * URL of the external script bundle for the reference UI. *- * @default 'https://cdn.jsdelivr.net/npm/@scalar/api-reference'+ * - For Scalar: defaults to 'https://cdn.jsdelivr.net/npm/@scalar/api-reference'+ * - For Swagger UI: defaults to 'https://unpkg.com/[email protected]/swagger-ui-bundle.js' */ docsScriptUrl?: Value<Promisable<string>, [StandardHandlerInterceptorOptions<T>]> + /**+ * URL of the external CSS bundle for the reference UI (used by Swagger UI).+ *+ * @default 'https://unpkg.com/[email protected]/swagger-ui.css' (for Swagger UI)+ */+ docsCssUrl?: Value<Promisable<string>, [StandardHandlerInterceptorOptions<T>]>+ /** * Override function to generate the full HTML for the docs page. */@@ -62,7 +77,9 @@ export interface OpenAPIReferencePluginOptions<T extends Context> extends OpenAP head: string, scriptUrl: string, config: Record<string, unknown> | undefined,- spec: OpenAPI.Document+ spec: OpenAPI.Document,+ uiType: 'scalar' | 'swagger',+ cssUrl?: string ) => string } @@ -73,7 +90,9 @@ export class OpenAPIReferencePlugin<T extends Context> implements StandardHandle private readonly docsPath: Exclude<OpenAPIReferencePluginOptions<T>['docsPath'], undefined> private readonly docsTitle: Exclude<OpenAPIReferencePluginOptions<T>['docsTitle'], undefined> private readonly docsHead: Exclude<OpenAPIReferencePluginOptions<T>['docsHead'], undefined>+ private readonly uiType: Exclude<OpenAPIReferencePluginOptions<T>['uiType'], undefined> private readonly docsScriptUrl: Exclude<OpenAPIReferencePluginOptions<T>['docsScriptUrl'], undefined>+ private readonly docsCssUrl: OpenAPIReferencePluginOptions<T>['docsCssUrl'] private readonly docsConfig: OpenAPIReferencePluginOptions<T>['docsConfig'] private readonly renderDocsHtml: Exclude<OpenAPIReferencePluginOptions<T>['renderDocsHtml'], undefined> @@ -82,20 +101,76 @@ export class OpenAPIReferencePlugin<T extends Context> implements StandardHandle this.docsPath = options.docsPath ?? '/' this.docsTitle = options.docsTitle ?? 'API Reference' this.docsConfig = options.docsConfig ?? undefined- this.docsScriptUrl = options.docsScriptUrl ?? 'https://cdn.jsdelivr.net/npm/@scalar/api-reference'+ this.uiType = options.uiType ?? 'scalar'++ // Set default script URL based on UI type+ this.docsScriptUrl = options.docsScriptUrl ?? (+ this.uiType === 'swagger'+ ? 'https://unpkg.com/[email protected]/swagger-ui-bundle.js'+ : 'https://cdn.jsdelivr.net/npm/@scalar/api-reference'+ )++ // Set CSS URL for Swagger UI+ this.docsCssUrl = options.docsCssUrl ?? (+ this.uiType === 'swagger'+ ? 'https://unpkg.com/[email protected]/swagger-ui.css'+ : undefined+ )+ this.docsHead = options.docsHead ?? '' this.specPath = options.specPath ?? '/spec.json' this.generator = new OpenAPIGenerator(options) const esc = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>') - this.renderDocsHtml = options.renderDocsHtml ?? ((specUrl, title, head, scriptUrl, config, spec) => {- const finalConfig = {- content: stringifyJSON(spec),- ...config,+ this.renderDocsHtml = options.renderDocsHtml ?? ((specUrl, title, head, scriptUrl, config, spec, uiType, cssUrl) => {+ if (uiType === 'swagger') {+ // Swagger UI configuration+ const swaggerConfig = {@@ -49,6 +49,8 @@ export interface OpenAPIReferencePluginOptions<T extends Context> extends OpenAP /** * HTML to inject into the <head> of the docs page. *+ * @warning This is not escaped special characters, so must be used with caution to avoid XSS vulnerabilities.+ * * @default '' */ docsHead?: Value<Promisable<string>, [StandardHandlerInterceptorOptions<T>]>@@ -121,7 +123,25 @@ export class OpenAPIReferencePlugin<T extends Context> implements StandardHandle this.specPath = options.specPath ?? '/spec.json' this.generator = new OpenAPIGenerator(options) - const esc = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')+ /** Escapes a string for safe embedding in an HTML attribute value. */+ const escapeHtmlEntities = (s: string) => s+ .replace(/&/g, '&')+ .replace(/"/g, '"')+ .replace(/</g, '<')+ .replace(/>/g, '>')++ /**+ * Serialises a value to JSON safe for HTML embedding (attribute or <script>).+ * Uses Unicode escapes instead of HTML entities so JSON.parse reconstructs+ * the original values without corruption. Cannot be merged with `esc` —+ * HTML entities inside <script> are not decoded by the JS engine.+ */+ const escapeJsonForHtml = (obj: object) => stringifyJSON(obj)+ .replace(/&/g, '\\u0026')+ .replace(/'/g, '\\u0027')+ .replace(/</g, '\\u003C')+ .replace(/>/g, '\\u003E')+ .replace(/\//g, '\\u002F') this.renderDocsHtml = options.renderDocsHtml ?? ((specUrl, title, head, scriptUrl, config, spec, docsProvider, cssUrl) => { let body: string@@ -145,11 +165,15 @@ export class OpenAPIReferencePlugin<T extends Context> implements StandardHandle <body> <div id="app"></div> - <script src="${esc(scriptUrl)}"></script>+ <script src="${escapeHtmlEntities(scriptUrl)}"></script> + <!-- IMPORTANT: assign to a variable first to prevent ), ( in values breaking the call expression. -->+ <!-- IMPORTANT: escapeJsonForHtml ensures <, > cannot terminate the </script> tag prematurely. --> <script>+ const swaggerConfig = ${escapeJsonForHtml(swaggerConfig).replace(/"(SwaggerUIBundle\.[^"]+)"/g, '$1')}+ window.onload = () => {- window.ui = SwaggerUIBundle(${stringifyJSON(swaggerConfig).replace(/"(SwaggerUIBundle\.[^"]+)"/g, '$1')})+ window.ui = SwaggerUIBundle(swaggerConfig) } </script> </body>@@ -163,12 +187,16 @@ export class OpenAPIReferencePlugin<T extends Context> implements StandardHandle body = ` <body>- <div id="app" data-config="${esc(stringifyJSON(scalarConfig))}"></div>-- <script src="${esc(scriptUrl)}"></script>-+ <div id="app"></div>+ + <script src="${escapeHtmlEntities(scriptUrl)}"></script>+ + <!-- IMPORTANT: assign to a variable first to prevent ), ( in values breaking the call expression. -->+ <!-- IMPORTANT: escapeJsonForHtml ensures <, > cannot terminate the </script> tag prematurely. --> <script>- Scalar.createApiReference('#app', JSON.parse(document.getElementById('app').dataset.config))+ const scalarConfig = ${escapeJsonForHtml(scalarConfig)}++ Scalar.createApiReference('#app', scalarConfig) </script> </body> `@@ -180,8 +208,8 @@ export class OpenAPIReferencePlugin<T extends Context> implements StandardHandle <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" />- <title>${esc(title)}</title>- ${cssUrl ? `<link rel="stylesheet" type="text/css" href="${esc(cssUrl)}" />` : ''}+ <title>${escapeHtmlEntities(title)}</title>+ ${cssUrl ? `<link rel="stylesheet" type="text/css" href="${escapeHtmlEntities(cssUrl)}" />` : ''} ${head} </head> ${body}AI-assisted change 1ee25da8e9360bfea44ea57bcf286c0f72c8dd315c132c773a57e3b2eb2f4f7f · Fix e525d8aec081183434e797083b77c565034395de4517efc09922cdd2455ee69f