@@ -72,6 +72,11 @@ const INCREMENTAL_REQUEST_CLOSE_TIMEOUT_MS = 1_000;
7272// retain VM source-map registrations for the same idle period.
7373const STREAM_CONTEXT_RELEASE_TIMEOUT_MS = STREAM_CHUNK_TIMEOUT_MS ;
7474const INCREMENTAL_RESPONSE_FINISH_TIMEOUT_MS = STREAM_CONTEXT_RELEASE_TIMEOUT_MS ;
75+ const HTML_ESCAPE_REPLACEMENTS : Record < string , string > = {
76+ '&' : '&' ,
77+ '<' : '<' ,
78+ '>' : '>' ,
79+ } ;
7580
7681// Uncomment the below for testing timeouts:
7782// import { delay } from './shared/utils.js';
@@ -112,12 +117,18 @@ function setStringResponseHeaders(headers: ResponseResult['headers'], res: Fasti
112117 }
113118}
114119
120+ function escapeHtmlText ( value : string ) {
121+ return value . replace ( / [ & < > ] / g, ( char ) => HTML_ESCAPE_REPLACEMENTS [ char ] ?? char ) ;
122+ }
123+
115124const setResponse = async ( result : ResponseResult , res : FastifyReply ) => {
116125 const { status, data, headers, stream } = result ;
117126 if ( status !== 200 && status !== 410 ) {
118127 log . info ( { msg : 'Sending non-200, non-410 data back' , data } ) ;
119128 }
120- if ( ! stream && typeof data === 'string' ) {
129+ const stringResponse = ! stream && typeof data === 'string' ;
130+ const responseData = stringResponse && status >= 400 ? escapeHtmlText ( data ) : data ;
131+ if ( stringResponse ) {
121132 setStringResponseHeaders ( headers , res ) ;
122133 }
123134 setHeaders ( headers , res ) ;
@@ -126,7 +137,10 @@ const setResponse = async (result: ResponseResult, res: FastifyReply) => {
126137 if ( stream ) {
127138 await res . send ( stream ) ;
128139 } else {
129- res . send ( data ) ;
140+ // Non-success strings are escaped above and sent as nosniff text/plain; success
141+ // strings include renderer JSON/HTML payloads for the Ruby client and stay intact.
142+ // codeql[js/reflected-xss]
143+ res . send ( responseData ) ;
130144 }
131145} ;
132146
0 commit comments