Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revert "Remove flight chunk" #1278

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/moody-planes-perform.md
@@ -0,0 +1,5 @@
---
'@shopify/hydrogen': patch
---

Reverts [#1272](https://github.com/Shopify/hydrogen/pull/1272) and properly escapes terminating script sequences
68 changes: 63 additions & 5 deletions packages/hydrogen/src/entry-server.tsx
Expand Up @@ -216,7 +216,7 @@ async function render(
) {
const state = {pathname: url.pathname, search: url.search};

const {AppSSR} = buildAppSSR(
const {AppSSR, rscReadable} = buildAppSSR(
{
App,
log,
Expand All @@ -233,9 +233,10 @@ async function render(
return template;
}

let html = await renderToBufferedString(AppSSR, {log, nonce}).catch(
onErrorShell
);
let [html, flight] = await Promise.all([
renderToBufferedString(AppSSR, {log, nonce}).catch(onErrorShell),
bufferReadableStream(rscReadable.getReader()).catch(() => null),
]);

const {headers, status, statusText} = getResponseOptions(componentResponse);

Expand All @@ -261,6 +262,18 @@ async function render(

html = applyHtmlHead(html, request.ctx.head, template);

if (flight) {
html = html.replace(
'</body>',
() =>
`${flightContainer({
init: true,
nonce,
chunk: flight as string,
})}</body>`
);
}

postRequestTasks('ssr', status, request, componentResponse);

return new Response(html, {
Expand Down Expand Up @@ -307,7 +320,13 @@ async function stream(
const rscToScriptTagReadable = new ReadableStream({
start(controller) {
log.trace('rsc start chunks');
bufferReadableStream(rscReadable.getReader()).then(() => {
let init = true;
const encoder = new TextEncoder();
bufferReadableStream(rscReadable.getReader(), (chunk) => {
const scriptTag = flightContainer({init, chunk, nonce});
controller.enqueue(encoder.encode(scriptTag));
init = false;
}).then(() => {
log.trace('rsc finish chunks');
return controller.close();
});
Expand Down Expand Up @@ -839,6 +858,27 @@ async function createNodeWriter() {
return new PassThrough() as InstanceType<typeof PassThroughType>;
}

function flightContainer({
init,
chunk,
nonce,
}: {
chunk?: string;
init?: boolean;
nonce?: string;
}) {
let script = `<script${nonce ? ` nonce="${nonce}"` : ''}>`;
if (init) {
script += 'var __flight=[];';
}

if (chunk) {
script += `__flight.push(${JSON.stringify(escapeScriptContent(chunk))})`;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So just to clarify, JSON.stringify()
eliminates the need for using backticks for the string that is passed to push() i.e. for

`__flight.push(\`${normalizedChunk}\`)`

Correct? This way the case where this would be abused by passing a payload like ${alert(1)} would be no longer be a concern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct!

}

return script + '</script>';
}

function postRequestTasks(
type: RenderType,
status: number,
Expand All @@ -850,3 +890,21 @@ function postRequestTasks(
logQueryTimings(type, request);
request.savePreloadQueries();
}

/**
* This escaping function is borrowed from React core. It prevents flight syntax from
* prematurely ending the script tag. Untrusted script content should be made safe
* before using this api by the developer, but this ensures that the script cannot
* be early terminated or never terminated state.
* @see https://github.com/facebook/react/blob/4c03bb6ed01a448185d9a1554229208a9480560d/packages/react-dom/src/server/ReactDOMServerFormatConfig.js#L96
*/
function escapeScriptContent(scriptText: string) {
return ('' + scriptText).replace(scriptRegex, scriptReplacer);
}
const scriptRegex = /(<\/|<)(s)(cript)/gi;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is a concern here, but since this is a regex with a global flag, it's stateful and therefore calling RegExp.prototype.test() or RegExp.prototype.exec() on it for two different inputs would be problematic. Do you know if String.prototype.replace() has the same issue or not? I was not able to find anything. 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that only RegExp methods are stateful, so something like String.match() or String.replace() should be fine 👍 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec

const scriptReplacer = (
match: string,
prefix: string,
s: string,
suffix: string
) => `${prefix}${s === 's' ? '\\u0073' : '\\u0053'}${suffix}`;
Comment on lines +901 to +910
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. It just replaces s with a unicode version

30 changes: 30 additions & 0 deletions packages/hydrogen/src/framework/Hydration/rsc.ts
Expand Up @@ -10,8 +10,38 @@ import {
} from '@shopify/hydrogen/vendor/react-server-dom-vite';
import {RSC_PATHNAME} from '../../constants';

declare global {
// eslint-disable-next-line no-var
var __flight: Array<string>;
}

let rscReader: ReadableStream | null;

if (globalThis.__flight && __flight.length > 0) {
const contentLoaded = new Promise((resolve) =>
document.addEventListener('DOMContentLoaded', resolve)
);

try {
rscReader = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const write = (chunk: string) => {
controller.enqueue(encoder.encode(chunk));
return 0;
};

__flight.forEach(write);
__flight.push = write;

contentLoaded.then(() => controller.close());
},
});
} catch (_) {
// Old browser, will try a new hydration request later
}
}

function createResponseCache() {
return new Map<string, any>();
}
Expand Down
@@ -0,0 +1,3 @@
export default function Passthrough({children}) {
return children;
}
@@ -0,0 +1,11 @@
import Passthrough from '../components/Passthrough.client';

export default function Escaping() {
return (
<Passthrough
prop="</script><script>document.body = ''</script>"
// eslint-disable-next-line react/no-children-prop
children="</script><script>alert('hi')</script>"
/>
);
}
43 changes: 22 additions & 21 deletions packages/playground/server-components/tests/e2e-test-cases.ts
Expand Up @@ -37,10 +37,8 @@ export default async function testCases({
expect(await page.textContent('body')).toContain('About');
expect(await page.textContent('.count')).toBe('Count is 0');

// await page.click('.increase');
// // TODO: Fix test flakiness
// await new Promise((res) => setTimeout(res, 1000));
// expect(await page.textContent('.count')).toBe('Count is 1');
await page.click('.increase');
expect(await page.textContent('.count')).toBe('Count is 1');
});

it('renders `<ShopifyProvider>` dynamically in RSC and on the client', async () => {
Expand Down Expand Up @@ -113,6 +111,8 @@ export default async function testCases({
expect(streamedChunks.length).toBeGreaterThan(1); // Streamed more than 1 chunk

const body = streamedChunks.join('');
expect(body).toContain('var __flight=[];');
expect(body).toContain(`__flight.push("S1:\\"react.suspense\\"`);
expect(body).toContain('<div c="5">');
expect(body).toContain('>footer!<');
});
Expand All @@ -128,11 +128,11 @@ export default async function testCases({
streamedChunks.push(chunk.toString());
}

// Worker test is returning 1 chunk, while node test are returning 2 chunk
// The second chunk is undefined
// expect(streamedChunks.length).toEqual(1); // Did not stream because it's a bot
expect(streamedChunks.length).toEqual(1); // Did not stream because it's a bot

const body = streamedChunks.join('');
expect(body).toContain('var __flight=[];');
expect(body).toContain(`__flight.push("S1:\\"react.suspense\\"`);
expect(body).toContain('<div c="5">');
expect(body).toContain('>footer!<');
});
Expand Down Expand Up @@ -199,6 +199,13 @@ export default async function testCases({
expect(body).toEqual('User-agent: *\nDisallow: /admin\n');
});

it('properly escapes props in the SSR flight script chunks', async () => {
await page.goto(getServerUrl() + '/escaping');
expect(await page.textContent('body')).toContain(
"</script><script>alert('hi')</script>"
);
});

describe('HMR', () => {
if (isBuild) return;

Expand Down Expand Up @@ -392,20 +399,14 @@ export default async function testCases({
expect(await page.textContent('*')).toContain('fname=sometext');
});

// it('can concatenate requests', async () => {
// await page.goto(getServerUrl() + '/html-form');
// expect(await page.textContent('#counter')).toEqual('0');
// await page.click('#increase');

// // TODO: Fix test flakiness
// await new Promise((res) => setTimeout(res, 1000));
// expect(await page.textContent('#counter')).toEqual('1');
// await page.click('#increase');

// // TODO: Fix test flakiness
// await new Promise((res) => setTimeout(res, 1000));
// expect(await page.textContent('#counter')).toEqual('2');
// });
it('can concatenate requests', async () => {
await page.goto(getServerUrl() + '/html-form');
expect(await page.textContent('#counter')).toEqual('0');
await page.click('#increase');
expect(await page.textContent('#counter')).toEqual('1');
await page.click('#increase');
expect(await page.textContent('#counter')).toEqual('2');
});
});

describe('Custom Routing', () => {
Expand Down