diff --git a/packages/composition/src/visual.ts b/packages/composition/src/visual.ts
index e4308dbb5..6a0f9468f 100644
--- a/packages/composition/src/visual.ts
+++ b/packages/composition/src/visual.ts
@@ -216,6 +216,12 @@ export function assertVisualStyleV1(
if (/!important|[\u0000-\u001f\u007f]/iu.test(value)) {
throw new Error(`${label} contains an unsafe style value.`);
}
+ // `<` terminates the raw-text " }] }],
+ }],
+ });
+ assert.throws(
+ () => assertVisualTrackIdentity(hostile, timeline),
+ /unsafe style value/,
+ );
+});
+
+test("VisualTrack style values cannot hide resource functions behind CSS escapes", () => {
+ const { timeline, visual } = fixture();
+ const first = visual.presents[0]!.elements[0]!;
+ const escaped = sealVisualTrack({...visual,
+ presents: [{
+ ...visual.presents[0]!,
+ elements: [{ ...first, style: [{ name: "background", value: "u\\72 l(https://invalid.example/)" }] }],
+ }],
+ });
+ assert.throws(
+ () => assertVisualTrackIdentity(escaped, timeline),
+ /unsafe style value/,
+ );
+});
+
+test("VisualTrack animation keyframes share the same unsafe-value checks", () => {
+ const { timeline, visual } = fixture();
+ const first = visual.presents[0]!.elements[0]!;
+ const hostile = sealVisualTrack({...visual,
+ presents: [{
+ ...visual.presents[0]!,
+ elements: [{ ...first, animation: { keyframes: [
+ { atFrame: 0, style: [{ name: "opacity", value: 1 }] },
+ { atFrame: 30, style: [{ name: "opacity", value: "0" }] },
+ ] } }],
+ }],
+ });
+ assert.throws(
+ () => assertVisualTrackIdentity(hostile, timeline),
+ /unsafe style value/,
+ );
+});
+
test("VisualTrack cannot silently extend the versioned public Visual IR", () => {
const { timeline, visual } = fixture();
const first = visual.presents[0]!.elements[0]!;