Skip to content

Commit 03b74a7

Browse files
committed
Escape renderer error text responses
1 parent f2fb549 commit 03b74a7

2 files changed

Lines changed: 21 additions & 4 deletions

File tree

packages/react-on-rails-pro-node-renderer/src/worker.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ const INCREMENTAL_REQUEST_CLOSE_TIMEOUT_MS = 1_000;
7272
// retain VM source-map registrations for the same idle period.
7373
const STREAM_CONTEXT_RELEASE_TIMEOUT_MS = STREAM_CHUNK_TIMEOUT_MS;
7474
const INCREMENTAL_RESPONSE_FINISH_TIMEOUT_MS = STREAM_CONTEXT_RELEASE_TIMEOUT_MS;
75+
const HTML_ESCAPE_REPLACEMENTS: Record<string, string> = {
76+
'&': '&amp;',
77+
'<': '&lt;',
78+
'>': '&gt;',
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+
115124
const 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

packages/react-on-rails-pro-node-renderer/tests/worker.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ describe('worker', () => {
296296
gemVersion,
297297
protocolVersion,
298298
railsEnv,
299-
renderingRequest: 'ReactOnRails.dummy',
299+
renderingRequest: 'ReactOnRails.dummy("<script>alert(1)</script>")',
300300
bundle: createReadStream(getFixtureBundle()),
301301
});
302302

@@ -313,7 +313,10 @@ describe('worker', () => {
313313
expect.stringContaining('Caught top level error in handleRenderRequest'),
314314
undefined,
315315
);
316+
expectPlainTextNosniffResponse(res);
316317
expect(res.payload).toContain('Caught top level error in handleRenderRequest');
318+
expect(res.payload).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
319+
expect(res.payload).not.toContain('<script>');
317320
} finally {
318321
buildExecutionContextSpy.mockRestore();
319322
reportMessageSpy.mockRestore();
@@ -804,7 +807,7 @@ describe('worker', () => {
804807
// The endpoint requires at least one bundle_<hash> field
805808
expect(res.statusCode).toBe(400);
806809
expectPlainTextNosniffResponse(res);
807-
expect(res.payload).toContain('No bundle_<hash> fields provided');
810+
expect(res.payload).toContain('No bundle_&lt;hash&gt; fields provided');
808811
});
809812

810813
test('post /upload-assets with duplicate bundle hash silently skips overwrite and returns 200', async () => {

0 commit comments

Comments
 (0)