|
1 | 1 | import { describe, expect, it, vi } from "vitest"; |
2 | 2 | import { parseHTML } from "linkedom"; |
3 | 3 | import { |
| 4 | + buildVariablesByCompScript, |
4 | 5 | scopeCssToComposition, |
5 | 6 | wrapInlineScriptWithErrorBoundary, |
6 | 7 | wrapScopedCompositionScript, |
@@ -699,13 +700,18 @@ window.__afterTimeline = window.__timelines.scene; |
699 | 700 | }); |
700 | 701 |
|
701 | 702 | it("wraps unscoped composition script source as a string literal", () => { |
| 703 | + const source = 'window.payload = "</script><script>window.pwned = true;</script>";'; |
702 | 704 | const wrapped = wrapInlineScriptWithErrorBoundary( |
703 | | - 'window.payload = "</script><script>window.pwned = true;</script>";', |
| 705 | + source, |
704 | 706 | "[HyperFrames] composition script error:", |
705 | 707 | ); |
706 | 708 |
|
707 | 709 | expect(wrapped).toContain("Function("); |
708 | | - expect(wrapped).toContain('\\"</script><script>window.pwned = true;</script>\\"'); |
| 710 | + // The literal carries the source verbatim, with `<` escaped so it cannot end the |
| 711 | + // raw-text `<script>` this is emitted into. |
| 712 | + expect(wrapped).not.toContain("</script"); |
| 713 | + const literal = /Function\((".*")\)/.exec(wrapped)?.[1]; |
| 714 | + expect(JSON.parse(literal ?? "")).toBe(source); |
709 | 715 | }); |
710 | 716 |
|
711 | 717 | it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => { |
@@ -886,3 +892,132 @@ window.__timelines['intro'] = tl; |
886 | 892 | expect(gsapTargets).toEqual([["HELLO"]]); |
887 | 893 | }); |
888 | 894 | }); |
| 895 | + |
| 896 | +/** |
| 897 | + * The emitted statement is placed inside a `<script>` element, and `<script>` is a |
| 898 | + * RAW TEXT element: HTML serialization does not escape its content and the tokenizer |
| 899 | + * closes it at the first `</script`. `JSON.stringify` escapes `"` and `\` but not `/`, |
| 900 | + * so an unescaped variable value could close the element and have the remainder parsed |
| 901 | + * as markup — turning composition data into executable script. |
| 902 | + */ |
| 903 | +/** |
| 904 | + * Every payload leads with a benign `<` before its `</script`, so escaping only the |
| 905 | + * first `<` is not enough to pass: that pins the `/g` flag on the escape rather than |
| 906 | + * merely "an escape ran". A lone `<` in a value is the common case (`a < b`, `<em>`), |
| 907 | + * so a payload whose breakout is not the first `<` is the realistic one. |
| 908 | + */ |
| 909 | +const SCRIPT_BREAKOUT = "x<y</script><script>window.__pwned=1//"; |
| 910 | + |
| 911 | +/** Serialize into a document the way the compilers do, then re-parse it. */ |
| 912 | +function scriptsAfterRoundTrip(body: string): string[] { |
| 913 | + const { document } = parseHTML("<!doctype html><html><head></head><body></body></html>"); |
| 914 | + const el = document.createElement("script"); |
| 915 | + el.textContent = body; |
| 916 | + document.body.appendChild(el); |
| 917 | + const { document: reparsed } = parseHTML(document.toString()); |
| 918 | + return [...reparsed.querySelectorAll("script")].map((s) => s.textContent ?? ""); |
| 919 | +} |
| 920 | + |
| 921 | +describe("buildVariablesByCompScript — <script> breakout", () => { |
| 922 | + it("does not let a variable VALUE close the script element", () => { |
| 923 | + const body = buildVariablesByCompScript({ |
| 924 | + "comp-a": { greeting: SCRIPT_BREAKOUT }, |
| 925 | + }); |
| 926 | + expect(body).not.toBeNull(); |
| 927 | + expect(body).not.toContain("</script"); |
| 928 | + expect(scriptsAfterRoundTrip(body ?? "")).toHaveLength(1); |
| 929 | + }); |
| 930 | + |
| 931 | + it("does not let a variable KEY close the script element", () => { |
| 932 | + const body = buildVariablesByCompScript({ |
| 933 | + "comp-a": { [SCRIPT_BREAKOUT]: "x" }, |
| 934 | + }); |
| 935 | + expect(body).not.toContain("</script"); |
| 936 | + expect(scriptsAfterRoundTrip(body ?? "")).toHaveLength(1); |
| 937 | + }); |
| 938 | + |
| 939 | + it("does not let a COMP ID close the script element", () => { |
| 940 | + const body = buildVariablesByCompScript({ |
| 941 | + [SCRIPT_BREAKOUT]: { a: "x" }, |
| 942 | + }); |
| 943 | + expect(body).not.toContain("</script"); |
| 944 | + expect(scriptsAfterRoundTrip(body ?? "")).toHaveLength(1); |
| 945 | + }); |
| 946 | + |
| 947 | + it("keeps the value byte-identical once executed — the escape is transparent", () => { |
| 948 | + // Run the statement the way the browser does rather than string-slicing it. |
| 949 | + const variables = { "comp-a": { greeting: "a </script> b <em>c</em>" } }; |
| 950 | + const body = buildVariablesByCompScript(variables) ?? ""; |
| 951 | + const fakeWindow: Record<string, unknown> = {}; |
| 952 | + new Function("window", body)(fakeWindow); |
| 953 | + expect(fakeWindow.__hfVariablesByComp).toEqual(variables); |
| 954 | + }); |
| 955 | + |
| 956 | + it("returns null when there are no per-instance values", () => { |
| 957 | + expect(buildVariablesByCompScript({})).toBeNull(); |
| 958 | + }); |
| 959 | +}); |
| 960 | + |
| 961 | +/** |
| 962 | + * The variables table is not the only attacker-reachable literal emitted into a |
| 963 | + * `<script>`: the wrapper the sub-composition scripts run inside embeds the |
| 964 | + * composition id four times over (directly, as the timeline id, and inside two |
| 965 | + * derived selector patterns), plus the authored root id, the scope-selector |
| 966 | + * override and the error label. All of them are emitted into the same raw-text |
| 967 | + * element, so each has to survive a serialize/reparse round trip. |
| 968 | + */ |
| 969 | +describe("wrapScopedCompositionScript — <script> breakout via the wrapper literals", () => { |
| 970 | + const LABEL = "[HyperFrames] composition script error:"; |
| 971 | + |
| 972 | + it("does not let a COMP ID close the script element", () => { |
| 973 | + const body = wrapScopedCompositionScript("console.log(1);", SCRIPT_BREAKOUT); |
| 974 | + expect(body).not.toContain("</script"); |
| 975 | + expect(scriptsAfterRoundTrip(body)).toHaveLength(1); |
| 976 | + }); |
| 977 | + |
| 978 | + it("keeps the comp id byte-identical — the escape is transparent", () => { |
| 979 | + const body = wrapScopedCompositionScript("console.log(1);", SCRIPT_BREAKOUT); |
| 980 | + const literal = /var __hfCompId = (.*);/.exec(body)?.[1]; |
| 981 | + expect(literal).toBeDefined(); |
| 982 | + expect(JSON.parse(literal ?? "")).toBe(SCRIPT_BREAKOUT); |
| 983 | + }); |
| 984 | + |
| 985 | + it("does not let the AUTHORED ROOT ID close the script element", () => { |
| 986 | + const body = wrapScopedCompositionScript( |
| 987 | + "console.log(1);", |
| 988 | + "comp-a", |
| 989 | + LABEL, |
| 990 | + undefined, |
| 991 | + "comp-a", |
| 992 | + SCRIPT_BREAKOUT, |
| 993 | + ); |
| 994 | + expect(body).not.toContain("</script"); |
| 995 | + expect(scriptsAfterRoundTrip(body)).toHaveLength(1); |
| 996 | + }); |
| 997 | + |
| 998 | + it("does not let the SCOPE SELECTOR override close the script element", () => { |
| 999 | + const body = wrapScopedCompositionScript("console.log(1);", "comp-a", LABEL, SCRIPT_BREAKOUT); |
| 1000 | + expect(body).not.toContain("</script"); |
| 1001 | + expect(scriptsAfterRoundTrip(body)).toHaveLength(1); |
| 1002 | + }); |
| 1003 | + |
| 1004 | + it("does not let the ERROR LABEL close the script element", () => { |
| 1005 | + const body = wrapScopedCompositionScript("console.log(1);", "comp-a", SCRIPT_BREAKOUT); |
| 1006 | + expect(body).not.toContain("</script"); |
| 1007 | + expect(scriptsAfterRoundTrip(body)).toHaveLength(1); |
| 1008 | + }); |
| 1009 | +}); |
| 1010 | + |
| 1011 | +describe("wrapInlineScriptWithErrorBoundary — <script> breakout", () => { |
| 1012 | + it("does not let the wrapped SOURCE close the script element", () => { |
| 1013 | + const body = wrapInlineScriptWithErrorBoundary(`var a = "${SCRIPT_BREAKOUT}";`, "[err]"); |
| 1014 | + expect(body).not.toContain("</script"); |
| 1015 | + expect(scriptsAfterRoundTrip(body)).toHaveLength(1); |
| 1016 | + }); |
| 1017 | + |
| 1018 | + it("does not let the ERROR LABEL close the script element", () => { |
| 1019 | + const body = wrapInlineScriptWithErrorBoundary("var a = 1;", SCRIPT_BREAKOUT); |
| 1020 | + expect(body).not.toContain("</script"); |
| 1021 | + expect(scriptsAfterRoundTrip(body)).toHaveLength(1); |
| 1022 | + }); |
| 1023 | +}); |
0 commit comments