Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/react-on-rails-pro/src/streamingUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { PassThrough, Readable } from 'stream';

import createReactOutput from 'react-on-rails/createReactOutput';
import { isPromise, isServerRenderHash } from 'react-on-rails/isServerRenderResult';
import buildConsoleReplay from 'react-on-rails/buildConsoleReplay';
import { consoleReplay } from 'react-on-rails/buildConsoleReplay';
import { createResultObject, convertToError, validateComponent } from 'react-on-rails/serverRenderUtils';
import {
RenderParams,
Expand Down Expand Up @@ -112,7 +112,13 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen
const transformStream = new PassThrough({
transform(chunk: Buffer, _, callback) {
const htmlChunk = chunk.toString();
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
// Get unwrapped console replay JavaScript (not wrapped in <script> tags)
// We use consoleReplay() instead of buildConsoleReplay() because streaming
// contexts handle script tag wrapping separately (e.g., with CSP nonces).
// This returns pure JavaScript without wrapping, which is then embedded
// into the result object JSON payload.
const consoleReplayScript = consoleReplay(previouslyReplayedConsoleMessages, consoleHistory);

previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));
this.push(`${jsonChunk}\n`);
Expand Down
6 changes: 3 additions & 3 deletions packages/react-on-rails/src/buildConsoleReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ declare global {
* @internal Exported for tests and for Ruby helper to wrap with nonce
*/
export function consoleReplay(
numberOfMessagesToSkip = 0,
customConsoleHistory: (typeof console)['history'] | undefined = undefined,
numberOfMessagesToSkip: number = 0,
): string {
// console.history is a global polyfill used in server rendering.
const consoleHistory = customConsoleHistory ?? console.history;
Expand Down Expand Up @@ -55,11 +55,11 @@ export function consoleReplay(
}

export default function buildConsoleReplay(
numberOfMessagesToSkip = 0,
customConsoleHistory: (typeof console)['history'] | undefined = undefined,
numberOfMessagesToSkip: number = 0,
nonce?: string,
): string {
const consoleReplayJS = consoleReplay(customConsoleHistory, numberOfMessagesToSkip);
const consoleReplayJS = consoleReplay(numberOfMessagesToSkip, customConsoleHistory);
if (consoleReplayJS.length === 0) {
return '';
}
Expand Down
8 changes: 4 additions & 4 deletions packages/react-on-rails/src/serverRenderReactComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isValidElement, type ReactElement } from 'react';
// ComponentRegistry is accessed via globalThis.ReactOnRails.getComponent for cross-bundle compatibility
import createReactOutput from './createReactOutput.ts';
import { isPromise, isServerRenderHash } from './isServerRenderResult.ts';
import { consoleReplay } from './buildConsoleReplay.ts';
import buildConsoleReplay from './buildConsoleReplay.ts';
import handleError from './handleError.ts';
import { renderToString } from './ReactDOMServer.cts';
import { createResultObject, convertToError, validateComponent } from './serverRenderUtils.ts';
Expand Down Expand Up @@ -109,11 +109,11 @@ async function createPromiseResult(
const consoleHistory = console.history;
try {
const html = await renderState.result;
const consoleReplayScript = consoleReplay(consoleHistory);
const consoleReplayScript = buildConsoleReplay(0, consoleHistory);
return createResultObject(html, consoleReplayScript, renderState);
} catch (e: unknown) {
const errorRenderState = handleRenderingError(e, { componentName, throwJsErrors });
const consoleReplayScript = consoleReplay(consoleHistory);
const consoleReplayScript = buildConsoleReplay(0, consoleHistory);
return createResultObject(errorRenderState.result, consoleReplayScript, errorRenderState);
}
}
Expand All @@ -128,7 +128,7 @@ function createFinalResult(
return createPromiseResult({ ...renderState, result }, componentName, throwJsErrors);
}

const consoleReplayScript = consoleReplay();
const consoleReplayScript = buildConsoleReplay();
return JSON.stringify(createResultObject(result, consoleReplayScript, renderState));
}

Expand Down
54 changes: 51 additions & 3 deletions packages/react-on-rails/tests/buildConsoleReplay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ console.warn.apply(console, ["other message","{\\"c\\":3,\\"d\\":4}"]);

it('buildConsoleReplay adds nonce attribute when provided', () => {
console.history = [{ arguments: ['test message'], level: 'log' }];
const actual = buildConsoleReplay(undefined, 0, 'abc123');
const actual = buildConsoleReplay(0, undefined, 'abc123');

expect(actual).toContain('nonce="abc123"');
expect(actual).toContain('<script id="consoleReplayLog" nonce="abc123">');
Expand All @@ -87,7 +87,7 @@ console.warn.apply(console, ["other message","{\\"c\\":3,\\"d\\":4}"]);

it('buildConsoleReplay returns empty string when no console messages', () => {
console.history = [];
const actual = buildConsoleReplay(undefined, 0, 'abc123');
const actual = buildConsoleReplay(0, undefined, 'abc123');

expect(actual).toEqual('');
});
Expand All @@ -112,7 +112,7 @@ console.warn.apply(console, ["other message","{\\"c\\":3,\\"d\\":4}"]);
console.history = [{ arguments: ['test'], level: 'log' }];
// Attempt attribute injection attack
const maliciousNonce = 'abc123" onload="alert(1)';
const actual = buildConsoleReplay(undefined, 0, maliciousNonce);
const actual = buildConsoleReplay(0, undefined, maliciousNonce);

// Should strip dangerous characters (quotes, parens, spaces)
// = is kept as it's valid in base64, but the quotes are stripped making it harmless
Expand All @@ -123,4 +123,52 @@ console.warn.apply(console, ["other message","{\\"c\\":3,\\"d\\":4}"]);
// Verify the dangerous parts (quotes and parens) are removed
expect(actual).not.toMatch(/nonce="[^"]*"[^>]*onload=/);
});

it('consoleReplay skips specified number of messages', () => {
console.history = [
{ arguments: ['skip 1'], level: 'log' },
{ arguments: ['skip 2'], level: 'log' },
{ arguments: ['keep 1'], level: 'log' },
{ arguments: ['keep 2'], level: 'warn' },
];
const actual = consoleReplay(2); // Skip first 2 messages

// Should not contain skipped messages
expect(actual).not.toContain('skip 1');
expect(actual).not.toContain('skip 2');

// Should contain kept messages
expect(actual).toContain('console.log.apply(console, ["keep 1"]);');
expect(actual).toContain('console.warn.apply(console, ["keep 2"]);');
});

it('consoleReplay uses custom console history when provided', () => {
console.history = [{ arguments: ['ignored'], level: 'log' }];
const customHistory = [
{ arguments: ['custom message 1'], level: 'warn' },
{ arguments: ['custom message 2'], level: 'error' },
];
const actual = consoleReplay(0, customHistory);

// Should not contain global console.history
expect(actual).not.toContain('ignored');

// Should contain custom history
expect(actual).toContain('console.warn.apply(console, ["custom message 1"]);');
expect(actual).toContain('console.error.apply(console, ["custom message 2"]);');
});

it('consoleReplay combines numberOfMessagesToSkip with custom history', () => {
const customHistory = [
{ arguments: ['skip this'], level: 'log' },
{ arguments: ['keep this'], level: 'warn' },
];
const actual = consoleReplay(1, customHistory);

// Should skip first message
expect(actual).not.toContain('skip this');

// Should keep second message
expect(actual).toContain('console.warn.apply(console, ["keep this"]);');
});
});
Loading