Skip to content

Commit 29dcc9b

Browse files
fix: __FRSH_STATE potentially being overwritten by user code
1 parent 3bc43a1 commit 29dcc9b

File tree

15 files changed

+118
-14
lines changed

15 files changed

+118
-14
lines changed

src/runtime/entrypoints/main.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,7 @@ class NoPartialsError extends Error {}
640640
*/
641641
export async function applyPartials(res: Response): Promise<void> {
642642
const contentType = res.headers.get("Content-Type");
643+
const uuid = res.headers.get("X-Fresh-UUID");
643644
if (contentType !== "text/html; charset=utf-8") {
644645
throw new Error(partialErrorMessage);
645646
}
@@ -652,7 +653,7 @@ export async function applyPartials(res: Response): Promise<void> {
652653
// Preload all islands because they need to be available synchronously
653654
// for rendering later
654655
const islands: IslandRegistry = {};
655-
const dataRaw = doc.getElementById("__FRSH_PARTIAL_DATA")!;
656+
const dataRaw = doc.getElementById(`__FRSH_PARTIAL_DATA_${uuid}`)!;
656657
let data: {
657658
islands: Record<string, { export: string; url: string }>;
658659
signals: string | null;
@@ -669,7 +670,7 @@ export async function applyPartials(res: Response): Promise<void> {
669670
);
670671
}
671672

672-
const stateDom = doc.getElementById("__FRSH_STATE")?.textContent;
673+
const stateDom = doc.getElementById(`__FRSH_STATE_${uuid}`)?.textContent;
673674
let state: SerializedState = [[], []];
674675

675676
// Load all dependencies

src/server/context.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,7 @@ const createRenderNotFound = (
669669
return async (req, ctx) => {
670670
const notFound = extractResult.notFound;
671671
if (!notFound.component) {
672-
return sendResponse(["Not found.", undefined], {
672+
return sendResponse(["Not found.", "", undefined], {
673673
status: STATUS_CODE.NotFound,
674674
isDev: dev,
675675
statusText: undefined,
@@ -771,19 +771,20 @@ function collectEntrypoints(
771771
}
772772

773773
function sendResponse(
774-
resp: [string, ContentSecurityPolicy | undefined],
774+
resp: [string, string, ContentSecurityPolicy | undefined],
775775
options: {
776776
status: number;
777777
statusText: string | undefined;
778778
headers?: HeadersInit;
779779
isDev: boolean;
780780
},
781781
) {
782+
const [body, uuid, csp] = resp;
782783
const headers: Record<string, string> = {
783784
"content-type": "text/html; charset=utf-8",
785+
"x-fresh-uuid": uuid,
784786
};
785787

786-
const [body, csp] = resp;
787788
if (csp) {
788789
if (options.isDev) {
789790
csp.directives.connectSrc = [

src/server/render.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export function checkAsyncComponent<T>(
122122
*/
123123
export async function render<Data>(
124124
opts: RenderOptions<Data>,
125-
): Promise<[string, ContentSecurityPolicy | undefined] | Response> {
125+
): Promise<[string, string, ContentSecurityPolicy | undefined] | Response> {
126126
const component = opts.route.component;
127127

128128
// Only inherit layouts up to the nearest root layout.
@@ -242,6 +242,7 @@ export async function render<Data>(
242242
// ensures that each render request is associated with the same
243243
// data.
244244
const renderState = new RenderState(
245+
crypto.randomUUID(),
245246
{
246247
url,
247248
route: opts.route.pattern,
@@ -393,5 +394,5 @@ export async function render<Data>(
393394
moduleScripts: result.moduleScripts,
394395
lang: ctx.lang,
395396
});
396-
return [html, csp];
397+
return [html, renderState.renderUuid, csp];
397398
}

src/server/rendering/fresh_tags.tsx

+8-4
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,20 @@ export function renderFreshTags(
7272
// The inline script that will hydrate the page.
7373
let script = "";
7474

75-
// Serialize the state into the <script id=__FRSH_STATE> tag and generate the
75+
// Serialize the state into the <script id="__FRSH_STATE-<uuid>"> tag and generate the
7676
// inline script to deserialize it. This script starts by deserializing the
7777
// state in the tag. This potentially requires importing @preact/signals.
7878
let hasSignals = false;
7979
let requiresDeserializer = false;
8080
if (state[0].length > 0 || state[1].length > 0) {
81+
// Careful: This must be unique per render to avoid injected content
82+
// via `dangerouslySetInnerHTML` being able to overwrite our state.
83+
const stateId = `__FRSH_STATE_${renderState.renderUuid}`;
84+
8185
const res = serialize(state);
8286
const escapedState = htmlEscapeJsonString(res.serialized);
8387
opts.bodyHtml +=
84-
`<script id="__FRSH_STATE" type="application/json" nonce="${renderState.getNonce()}">${escapedState}</script>`;
88+
`<script id="${stateId}" type="application/json" nonce="${renderState.getNonce()}">${escapedState}</script>`;
8589

8690
hasSignals = res.hasSignals;
8791
requiresDeserializer = res.requiresDeserializer;
@@ -94,7 +98,7 @@ export function renderFreshTags(
9498
const url = addImport("signals.js");
9599
script += `import { signal } from "${url}";`;
96100
}
97-
script += `const ST = document.getElementById("__FRSH_STATE").textContent;`;
101+
script += `const ST = document.getElementById("${stateId}").textContent;`;
98102
script += `const STATE = `;
99103
if (res.requiresDeserializer) {
100104
if (res.hasSignals) {
@@ -166,7 +170,7 @@ export function renderFreshTags(
166170
);
167171
const nonce = renderState.csp ? ` nonce="${renderState.getNonce()}` : "";
168172
opts.bodyHtml +=
169-
`<script id="__FRSH_PARTIAL_DATA" type="application/json"${nonce}">${escapedData}</script>`;
173+
`<script id="__FRSH_PARTIAL_DATA_${renderState.renderUuid}" type="application/json"${nonce}">${escapedData}</script>`;
170174
}
171175
if (script !== "") {
172176
opts.bodyHtml +=

src/server/rendering/state.ts

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface RenderStateRouteOptions {
1616
}
1717

1818
export class RenderState {
19+
readonly renderUuid: string;
1920
// deno-lint-ignore no-explicit-any
2021
componentStack: any[];
2122
renderingUserTemplate = false;
@@ -48,12 +49,14 @@ export class RenderState {
4849
basePath: string;
4950

5051
constructor(
52+
renderUuid: string,
5153
routeOptions: RenderStateRouteOptions,
5254
// deno-lint-ignore no-explicit-any
5355
componentStack: any[],
5456
csp?: ContentSecurityPolicy,
5557
error?: unknown,
5658
) {
59+
this.renderUuid = renderUuid;
5760
this.routeOptions = routeOptions;
5861
this.csp = csp;
5962
this.componentStack = componentStack;

tests/fixture/fresh.gen.ts

+4
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import * as $route_groups_bar_boof_index from "./routes/route-groups/(bar)/boof/
7474
import * as $route_groups_foo_layout from "./routes/route-groups/(foo)/_layout.tsx";
7575
import * as $route_groups_foo_index from "./routes/route-groups/(foo)/index.tsx";
7676
import * as $signal_shared from "./routes/signal_shared.tsx";
77+
import * as $spoof_state from "./routes/spoof_state.tsx";
7778
import * as $state_in_props_middleware from "./routes/state-in-props/_middleware.ts";
7879
import * as $state_in_props_index from "./routes/state-in-props/index.tsx";
7980
import * as $state_middleware_middleware from "./routes/state-middleware/_middleware.ts";
@@ -85,6 +86,7 @@ import * as $std from "./routes/std.tsx";
8586
import * as $umlaut_äöüß from "./routes/umlaut-äöüß.tsx";
8687
import * as $wildcard from "./routes/wildcard.tsx";
8788
import * as $Counter from "./islands/Counter.tsx";
89+
import * as $DangerousIsland from "./islands/DangerousIsland.tsx";
8890
import * as $Foo_Bar from "./islands/Foo.Bar.tsx";
8991
import * as $FormIsland from "./islands/FormIsland.tsx";
9092
import * as $Greeter from "./islands/Greeter.tsx";
@@ -194,6 +196,7 @@ const manifest = {
194196
"./routes/route-groups/(foo)/_layout.tsx": $route_groups_foo_layout,
195197
"./routes/route-groups/(foo)/index.tsx": $route_groups_foo_index,
196198
"./routes/signal_shared.tsx": $signal_shared,
199+
"./routes/spoof_state.tsx": $spoof_state,
197200
"./routes/state-in-props/_middleware.ts": $state_in_props_middleware,
198201
"./routes/state-in-props/index.tsx": $state_in_props_index,
199202
"./routes/state-middleware/_middleware.ts": $state_middleware_middleware,
@@ -208,6 +211,7 @@ const manifest = {
208211
},
209212
islands: {
210213
"./islands/Counter.tsx": $Counter,
214+
"./islands/DangerousIsland.tsx": $DangerousIsland,
211215
"./islands/Foo.Bar.tsx": $Foo_Bar,
212216
"./islands/FormIsland.tsx": $FormIsland,
213217
"./islands/Greeter.tsx": $Greeter,
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useEffect, useState } from "preact/hooks";
2+
3+
export default function RawIsland(props: { raw: string }) {
4+
const [css, set] = useState("");
5+
useEffect(() => {
6+
set("raw_ready");
7+
}, []);
8+
9+
return <div class={css} dangerouslySetInnerHTML={{ __html: props.raw }} />;
10+
}

tests/fixture/routes/spoof_state.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import DangerousIsland from "../islands/DangerousIsland.tsx";
2+
3+
export default function SerializePrototype() {
4+
return <DangerousIsland raw={`<h1 id="__FRSH_STATE">{.invalid.json}</h1>`} />;
5+
}

tests/fixture_partials/fresh.gen.ts

+6
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,12 @@ import * as $relative_link_index from "./routes/relative_link/index.tsx";
117117
import * as $scroll_restoration_index from "./routes/scroll_restoration/index.tsx";
118118
import * as $scroll_restoration_injected from "./routes/scroll_restoration/injected.tsx";
119119
import * as $scroll_restoration_update from "./routes/scroll_restoration/update.tsx";
120+
import * as $spoof_state_index from "./routes/spoof_state/index.tsx";
121+
import * as $spoof_state_partial from "./routes/spoof_state/partial.tsx";
120122
import * as $Counter from "./islands/Counter.tsx";
121123
import * as $CounterA from "./islands/CounterA.tsx";
122124
import * as $CounterB from "./islands/CounterB.tsx";
125+
import * as $DangerousIsland from "./islands/DangerousIsland.tsx";
123126
import * as $Fader from "./islands/Fader.tsx";
124127
import * as $InvalidSlot from "./islands/InvalidSlot.tsx";
125128
import * as $KeyExplorer from "./islands/KeyExplorer.tsx";
@@ -262,11 +265,14 @@ const manifest = {
262265
"./routes/scroll_restoration/index.tsx": $scroll_restoration_index,
263266
"./routes/scroll_restoration/injected.tsx": $scroll_restoration_injected,
264267
"./routes/scroll_restoration/update.tsx": $scroll_restoration_update,
268+
"./routes/spoof_state/index.tsx": $spoof_state_index,
269+
"./routes/spoof_state/partial.tsx": $spoof_state_partial,
265270
},
266271
islands: {
267272
"./islands/Counter.tsx": $Counter,
268273
"./islands/CounterA.tsx": $CounterA,
269274
"./islands/CounterB.tsx": $CounterB,
275+
"./islands/DangerousIsland.tsx": $DangerousIsland,
270276
"./islands/Fader.tsx": $Fader,
271277
"./islands/InvalidSlot.tsx": $InvalidSlot,
272278
"./islands/KeyExplorer.tsx": $KeyExplorer,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useEffect, useState } from "preact/hooks";
2+
3+
export default function DangerousIsland(props: { raw: string }) {
4+
const [css, set] = useState("");
5+
useEffect(() => {
6+
set("raw_ready");
7+
}, []);
8+
9+
return <div class={css} dangerouslySetInnerHTML={{ __html: props.raw }} />;
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Partial } from "$fresh/runtime.ts";
2+
3+
export default function SerializePrototype() {
4+
return (
5+
<div>
6+
<Partial name="content">
7+
<p>initial</p>
8+
</Partial>
9+
<a href="/spoof_state/partial">Update</a>
10+
</div>
11+
);
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Partial } from "$fresh/runtime.ts";
2+
import DangerousIsland from "../../islands/DangerousIsland.tsx";
3+
4+
export default function Res() {
5+
return (
6+
<Partial name="content">
7+
<DangerousIsland raw={`<h1 id="__FRSH_STATE">{.invalid.json}</h1>`} />
8+
<p class="done">partial</p>
9+
</Partial>
10+
);
11+
}

tests/main_test.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
withFakeServe,
2222
withPageName,
2323
} from "./test_utils.ts";
24+
import { composeMiddlewares } from "$fresh/src/server/compose.ts";
2425

2526
const ctx = await ServerContext.fromManifest(manifest, config);
2627
const handler = ctx.handler();
@@ -949,7 +950,7 @@ Deno.test("Adds nonce to inline scripts", async () => {
949950
await withFakeServe("./tests/fixture/main.ts", async (server) => {
950951
const doc = await server.getHtml(`/nonce_inline`);
951952

952-
const stateScript = doc.querySelector("#__FRSH_STATE")!;
953+
const stateScript = doc.querySelector("[id^=__FRSH_STATE]")!;
953954
const nonce = stateScript.getAttribute("nonce")!;
954955

955956
const el = doc.querySelector("#inline-script")!;
@@ -1220,3 +1221,18 @@ Deno.test("empty string fallback for optional params", async () => {
12201221
assertEquals(data, { path: "foo", version: "" });
12211222
});
12221223
});
1224+
1225+
// See https://github.com/denoland/fresh/issues/2254
1226+
Deno.test("should not be able to override __FRSH_STATE", async () => {
1227+
await withPageName("./tests/fixture/main.ts", async (page, address) => {
1228+
let didError = false;
1229+
page.on("pageerror", (ev) => {
1230+
didError = true;
1231+
console.log(ev);
1232+
});
1233+
await page.goto(`${address}/spoof_state`);
1234+
await page.waitForSelector(".raw_ready");
1235+
1236+
assert(!didError);
1237+
});
1238+
});

tests/partials_test.ts

+20
Original file line numberDiff line numberDiff line change
@@ -1569,3 +1569,23 @@ Deno.test("render partial without title", async () => {
15691569
},
15701570
);
15711571
});
1572+
1573+
// See https://github.com/denoland/fresh/issues/2254
1574+
Deno.test("should not be able to override __FRSH_STATE", async () => {
1575+
await withPageName(
1576+
"./tests/fixture_partials/main.ts",
1577+
async (page, address) => {
1578+
let didError = false;
1579+
page.on("pageerror", (ev) => {
1580+
didError = true;
1581+
console.log(ev);
1582+
});
1583+
await page.goto(`${address}/spoof_state`);
1584+
1585+
await page.click("a");
1586+
await page.waitForSelector(".raw_ready");
1587+
1588+
assert(!didError);
1589+
},
1590+
);
1591+
});

tests/render_test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ Deno.test("doesn't leak data across renderers", async () => {
1818
const resp = await handler(req);
1919
const doc = parseHtml(await resp.text());
2020

21-
assertSelector(doc, "#__FRSH_STATE");
22-
const text = doc.querySelector("#__FRSH_STATE")?.textContent!;
21+
assertSelector(doc, "[id^=__FRSH_STATE]");
22+
const text = doc.querySelector("[id^=__FRSH_STATE]")?.textContent!;
2323
const json = JSON.parse(text);
2424
assertEquals(json, { "v": [[{ "site": name }], []] });
2525
}

0 commit comments

Comments
 (0)