Skip to content

Commit 9d13f6f

Browse files
authored
fix: sanitize <script> in __FRSH_STATE state (#739)
This commit escapes `<` `>` and related characters in the serialized state that is encoded into the `<script id=__FRSH_STATE>` tag. This prevents XSS attack occuring through injection of a `</script>` string in island props.
1 parent b755095 commit 9d13f6f

File tree

7 files changed

+142
-37
lines changed

7 files changed

+142
-37
lines changed

src/server/htmlescape.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// This utility is based on https://github.com/zertosh/htmlescape
2+
// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE
3+
4+
const ESCAPE_LOOKUP: { [match: string]: string } = {
5+
">": "\\u003e",
6+
"<": "\\u003c",
7+
"\u2028": "\\u2028",
8+
"\u2029": "\\u2029",
9+
};
10+
11+
const ESCAPE_REGEX = /[><\u2028\u2029]/g;
12+
13+
export function htmlEscapeJsonString(str: string): string {
14+
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
15+
}

src/server/htmlescape_test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { htmlEscapeJsonString } from "./htmlescape.ts";
2+
import { assertEquals } from "../../tests/deps.ts";
3+
4+
Deno.test("with angle brackets should escape", () => {
5+
const evilObj = { evil: "<script></script>" };
6+
assertEquals(
7+
htmlEscapeJsonString(JSON.stringify(evilObj)),
8+
'{"evil":"\\u003cscript\\u003e\\u003c/script\\u003e"}',
9+
);
10+
});
11+
12+
Deno.test("with angle brackets should parse back", () => {
13+
const evilObj = { evil: "<script></script>" };
14+
assertEquals(
15+
JSON.parse(htmlEscapeJsonString(JSON.stringify(evilObj))),
16+
evilObj,
17+
);
18+
});

src/server/render.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { CSP_CONTEXT, nonce, NONE, UNSAFE_INLINE } from "../runtime/csp.ts";
1717
import { ContentSecurityPolicy } from "../runtime/csp.ts";
1818
import { bundleAssetUrl } from "./constants.ts";
1919
import { assetHashingHook } from "../runtime/utils.ts";
20+
import { htmlEscapeJsonString } from "./htmlescape.ts";
2021

2122
export interface RenderOptions<Data> {
2223
route: Route<Data> | UnknownPage | ErrorPage;
@@ -276,7 +277,7 @@ export async function render<Data>(
276277
if (state[0].length > 0 || state[1].length > 0) {
277278
// Append state to the body
278279
bodyHtml += `<script id="__FRSH_STATE" type="application/json">${
279-
JSON.stringify(state)
280+
htmlEscapeJsonString(JSON.stringify(state))
280281
}</script>`;
281282

282283
// Append the inline script to the body

tests/fixture/fresh.gen.ts

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,25 @@ import * as $5 from "./routes/api/get_only.ts";
1212
import * as $6 from "./routes/assetsCaching/index.tsx";
1313
import * as $7 from "./routes/books/[id].tsx";
1414
import * as $8 from "./routes/connInfo.ts";
15-
import * as $9 from "./routes/failure.ts";
16-
import * as $10 from "./routes/index.tsx";
17-
import * as $11 from "./routes/intercept.tsx";
18-
import * as $12 from "./routes/intercept_args.tsx";
19-
import * as $13 from "./routes/islands/index.tsx";
20-
import * as $14 from "./routes/layeredMdw/_middleware.ts";
21-
import * as $15 from "./routes/layeredMdw/layer2-no-mw/without_mw.ts";
22-
import * as $16 from "./routes/layeredMdw/layer2/_middleware.ts";
23-
import * as $17 from "./routes/layeredMdw/layer2/abc.ts";
24-
import * as $18 from "./routes/layeredMdw/layer2/index.ts";
25-
import * as $19 from "./routes/layeredMdw/layer2/layer3/[id].ts";
26-
import * as $20 from "./routes/layeredMdw/layer2/layer3/_middleware.ts";
27-
import * as $21 from "./routes/middleware_root.ts";
28-
import * as $22 from "./routes/not_found.ts";
29-
import * as $23 from "./routes/params.tsx";
30-
import * as $24 from "./routes/props/[id].tsx";
31-
import * as $25 from "./routes/static.tsx";
32-
import * as $26 from "./routes/wildcard.tsx";
15+
import * as $9 from "./routes/evil.tsx";
16+
import * as $10 from "./routes/failure.ts";
17+
import * as $11 from "./routes/index.tsx";
18+
import * as $12 from "./routes/intercept.tsx";
19+
import * as $13 from "./routes/intercept_args.tsx";
20+
import * as $14 from "./routes/islands/index.tsx";
21+
import * as $15 from "./routes/layeredMdw/_middleware.ts";
22+
import * as $16 from "./routes/layeredMdw/layer2-no-mw/without_mw.ts";
23+
import * as $17 from "./routes/layeredMdw/layer2/_middleware.ts";
24+
import * as $18 from "./routes/layeredMdw/layer2/abc.ts";
25+
import * as $19 from "./routes/layeredMdw/layer2/index.ts";
26+
import * as $20 from "./routes/layeredMdw/layer2/layer3/[id].ts";
27+
import * as $21 from "./routes/layeredMdw/layer2/layer3/_middleware.ts";
28+
import * as $22 from "./routes/middleware_root.ts";
29+
import * as $23 from "./routes/not_found.ts";
30+
import * as $24 from "./routes/params.tsx";
31+
import * as $25 from "./routes/props/[id].tsx";
32+
import * as $26 from "./routes/static.tsx";
33+
import * as $27 from "./routes/wildcard.tsx";
3334
import * as $$0 from "./islands/Counter.tsx";
3435
import * as $$1 from "./islands/Test.tsx";
3536
import * as $$2 from "./islands/kebab-case-counter-test.tsx";
@@ -45,24 +46,25 @@ const manifest = {
4546
"./routes/assetsCaching/index.tsx": $6,
4647
"./routes/books/[id].tsx": $7,
4748
"./routes/connInfo.ts": $8,
48-
"./routes/failure.ts": $9,
49-
"./routes/index.tsx": $10,
50-
"./routes/intercept.tsx": $11,
51-
"./routes/intercept_args.tsx": $12,
52-
"./routes/islands/index.tsx": $13,
53-
"./routes/layeredMdw/_middleware.ts": $14,
54-
"./routes/layeredMdw/layer2-no-mw/without_mw.ts": $15,
55-
"./routes/layeredMdw/layer2/_middleware.ts": $16,
56-
"./routes/layeredMdw/layer2/abc.ts": $17,
57-
"./routes/layeredMdw/layer2/index.ts": $18,
58-
"./routes/layeredMdw/layer2/layer3/[id].ts": $19,
59-
"./routes/layeredMdw/layer2/layer3/_middleware.ts": $20,
60-
"./routes/middleware_root.ts": $21,
61-
"./routes/not_found.ts": $22,
62-
"./routes/params.tsx": $23,
63-
"./routes/props/[id].tsx": $24,
64-
"./routes/static.tsx": $25,
65-
"./routes/wildcard.tsx": $26,
49+
"./routes/evil.tsx": $9,
50+
"./routes/failure.ts": $10,
51+
"./routes/index.tsx": $11,
52+
"./routes/intercept.tsx": $12,
53+
"./routes/intercept_args.tsx": $13,
54+
"./routes/islands/index.tsx": $14,
55+
"./routes/layeredMdw/_middleware.ts": $15,
56+
"./routes/layeredMdw/layer2-no-mw/without_mw.ts": $16,
57+
"./routes/layeredMdw/layer2/_middleware.ts": $17,
58+
"./routes/layeredMdw/layer2/abc.ts": $18,
59+
"./routes/layeredMdw/layer2/index.ts": $19,
60+
"./routes/layeredMdw/layer2/layer3/[id].ts": $20,
61+
"./routes/layeredMdw/layer2/layer3/_middleware.ts": $21,
62+
"./routes/middleware_root.ts": $22,
63+
"./routes/not_found.ts": $23,
64+
"./routes/params.tsx": $24,
65+
"./routes/props/[id].tsx": $25,
66+
"./routes/static.tsx": $26,
67+
"./routes/wildcard.tsx": $27,
6668
},
6769
islands: {
6870
"./islands/Counter.tsx": $$0,

tests/fixture/routes/evil.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Test from "../islands/Test.tsx";
2+
3+
export default function EvilPage() {
4+
return (
5+
<div>
6+
<Test message={`</script><script>alert('test')</script>`} />
7+
</div>
8+
);
9+
}

tests/fixture/routes/islands/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default function Home() {
99
<Counter id="counter2" start={10} />
1010
<KebabCaseFileNameTest id="kebab-case-file-counter" start={5} />
1111
<Test message="" />
12+
<Test message={`</script><script>alert('test')</script>`} />
1213
</div>
1314
);
1415
}

tests/islands_test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,62 @@ Deno.test({
8282
sanitizeOps: false,
8383
sanitizeResources: false,
8484
});
85+
86+
Deno.test({
87+
name: "island tests with </script>",
88+
async fn(t) {
89+
// Preparation
90+
const serverProcess = Deno.run({
91+
cmd: ["deno", "run", "-A", "./tests/fixture/main.ts"],
92+
stdout: "piped",
93+
stderr: "inherit",
94+
});
95+
96+
const decoder = new TextDecoderStream();
97+
const lines = serverProcess.stdout.readable
98+
.pipeThrough(decoder)
99+
.pipeThrough(new TextLineStream());
100+
101+
let started = false;
102+
for await (const line of lines) {
103+
if (line.includes("Listening on http://")) {
104+
started = true;
105+
break;
106+
}
107+
}
108+
if (!started) {
109+
throw new Error("Server didn't start up");
110+
}
111+
112+
await delay(100);
113+
114+
const browser = await puppeteer.launch({ args: ["--no-sandbox"] });
115+
const page = await browser.newPage();
116+
page.on("dialog", () => {
117+
assert(false, "There is XSS");
118+
});
119+
120+
await page.goto("http://localhost:8000/evil", {
121+
waitUntil: "networkidle2",
122+
});
123+
124+
await t.step("prevent XSS on Island", async () => {
125+
const bodyElem = await page.waitForSelector(`body`);
126+
const value = await bodyElem?.evaluate((el) => el.getInnerHTML());
127+
128+
assertStringIncludes(
129+
value,
130+
`{"message":"\\u003c/script\\u003e\\u003cscript\\u003ealert('test')\\u003c/script\\u003e"}`,
131+
`XSS is not escaped`,
132+
);
133+
});
134+
135+
await browser.close();
136+
137+
await lines.cancel();
138+
serverProcess.kill("SIGTERM");
139+
serverProcess.close();
140+
},
141+
sanitizeOps: false,
142+
sanitizeResources: false,
143+
});

0 commit comments

Comments
 (0)