Skip to content

Commit fa029db

Browse files
committed
fix(studio): reject unsafe keyframe values
1 parent ad5229a commit fa029db

11 files changed

Lines changed: 418 additions & 100 deletions

File tree

packages/core/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@
5454
"import": "./src/studio-api/helpers/draftMarkers.ts",
5555
"types": "./src/studio-api/helpers/draftMarkers.ts"
5656
},
57+
"./studio-api/finite-mutation": {
58+
"import": "./src/studio-api/helpers/finiteMutation.ts",
59+
"types": "./src/studio-api/helpers/finiteMutation.ts"
60+
},
5761
"./text": {
5862
"import": "./src/text/index.ts",
5963
"types": "./src/text/index.ts"
@@ -133,6 +137,10 @@
133137
"import": "./dist/studio-api/helpers/draftMarkers.js",
134138
"types": "./dist/studio-api/helpers/draftMarkers.d.ts"
135139
},
140+
"./studio-api/finite-mutation": {
141+
"import": "./dist/studio-api/helpers/finiteMutation.js",
142+
"types": "./dist/studio-api/helpers/finiteMutation.d.ts"
143+
},
136144
"./text": {
137145
"import": "./dist/text/index.js",
138146
"types": "./dist/text/index.d.ts"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from "vitest";
2+
import { findUnsafeDomPatchValues, findUnsafeMutationValues } from "./finiteMutation";
3+
4+
describe("finiteMutation", () => {
5+
it("reports non-finite numbers before mutation serialization", () => {
6+
expect(
7+
findUnsafeMutationValues({
8+
type: "set-arc-path",
9+
segments: [{ curviness: Number.NaN, cp1: { x: Infinity, y: 0 } }],
10+
}).map((field) => field.path),
11+
).toEqual(["body.segments[0].curviness", "body.segments[0].cp1.x"]);
12+
});
13+
14+
it("treats null as unsafe because JSON serializes NaN and Infinity to null", () => {
15+
expect(
16+
findUnsafeMutationValues({
17+
type: "update-property",
18+
property: "x",
19+
value: null,
20+
}),
21+
).toEqual([{ path: "body.value", reason: "null" }]);
22+
});
23+
24+
it("allows explicit DOM patch value removals while rejecting unsafe patch metadata", () => {
25+
expect(
26+
findUnsafeDomPatchValues({
27+
target: { id: "title", selectorIndex: null },
28+
operations: [{ type: "inline-style", property: "opacity", value: null }],
29+
}),
30+
).toEqual([{ path: "body.target.selectorIndex", reason: "null" }]);
31+
});
32+
33+
it("rejects non-finite DOM patch values before JSON serialization can turn them into null", () => {
34+
expect(
35+
findUnsafeDomPatchValues({
36+
target: { id: "title" },
37+
operations: [{ type: "inline-style", property: "left", value: Number.NaN }],
38+
}),
39+
).toEqual([{ path: "body.operations[0].value", reason: "non-finite-number" }]);
40+
});
41+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export interface UnsafeMutationValue {
2+
path: string;
3+
reason: "non-finite-number" | "null";
4+
}
5+
6+
interface FindUnsafeMutationValuesOptions {
7+
allowNullPath?: (path: string) => boolean;
8+
}
9+
10+
export function findUnsafeMutationValues(
11+
value: unknown,
12+
path = "body",
13+
options: FindUnsafeMutationValuesOptions = {},
14+
): UnsafeMutationValue[] {
15+
if (value === null) {
16+
return options.allowNullPath?.(path) ? [] : [{ path, reason: "null" }];
17+
}
18+
if (typeof value === "number") {
19+
return Number.isFinite(value) ? [] : [{ path, reason: "non-finite-number" }];
20+
}
21+
if (!value || typeof value !== "object") return [];
22+
if (Array.isArray(value)) {
23+
return value.flatMap((item, index) =>
24+
findUnsafeMutationValues(item, `${path}[${index}]`, options),
25+
);
26+
}
27+
return Object.entries(value).flatMap(([key, item]) =>
28+
findUnsafeMutationValues(item, `${path}.${key}`, options),
29+
);
30+
}
31+
32+
const DOM_PATCH_NULL_VALUE_PATH = /^body\.operations\[\d+\]\.value$/;
33+
34+
export function findUnsafeDomPatchValues(value: unknown): UnsafeMutationValue[] {
35+
return findUnsafeMutationValues(value, "body", {
36+
allowNullPath: (path) => DOM_PATCH_NULL_VALUE_PATH.test(path),
37+
});
38+
}

packages/core/src/studio-api/routes/files.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, describe, expect, it } from "vitest";
22
import { Hono } from "hono";
3-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { registerFileRoutes } from "./files";
@@ -171,6 +171,86 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas
171171
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
172172
});
173173

174+
it("rejects serialized non-finite mutation values before writing source", async () => {
175+
const projectDir = createProjectDir();
176+
writeHtml(projectDir, "comp.html", FROMTO_COMP);
177+
const app = new Hono();
178+
registerFileRoutes(app, createAdapter(projectDir));
179+
180+
const anim = await getFirstAnimation(app, "comp.html");
181+
const before = readFileSync(join(projectDir, "comp.html"), "utf-8");
182+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
183+
method: "POST",
184+
headers: { "Content-Type": "application/json" },
185+
body: JSON.stringify({
186+
type: "update-property",
187+
animationId: anim.id,
188+
property: "x",
189+
value: Number.NaN,
190+
}),
191+
});
192+
const payload = (await res.json()) as { error?: string; fields?: string[] };
193+
194+
expect(res.status).toBe(400);
195+
expect(payload.error).toContain("unsafe values");
196+
expect(payload.fields).toContain("body.value");
197+
expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(before);
198+
});
199+
200+
it("rejects unsafe DOM patch metadata before writing source", async () => {
201+
const projectDir = createProjectDir();
202+
writeFileSync(join(projectDir, "index.html"), '<div id="title">Before</div>');
203+
const app = new Hono();
204+
registerFileRoutes(app, createAdapter(projectDir));
205+
206+
const response = await app.request(
207+
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
208+
{
209+
method: "POST",
210+
headers: { "Content-Type": "application/json" },
211+
body: JSON.stringify({
212+
target: { id: "title", selectorIndex: Number.NaN },
213+
operations: [{ type: "text-content", property: "textContent", value: "After" }],
214+
}),
215+
},
216+
);
217+
const payload = (await response.json()) as { error?: string; fields?: string[] };
218+
219+
expect(response.status).toBe(400);
220+
expect(payload.error).toContain("unsafe values");
221+
expect(payload.fields).toContain("body.target.selectorIndex");
222+
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(
223+
'<div id="title">Before</div>',
224+
);
225+
});
226+
227+
it("allows DOM patch null values used for explicit style removals", async () => {
228+
const projectDir = createProjectDir();
229+
writeFileSync(
230+
join(projectDir, "index.html"),
231+
'<div id="title" style="opacity: 1">Before</div>',
232+
);
233+
const app = new Hono();
234+
registerFileRoutes(app, createAdapter(projectDir));
235+
236+
const response = await app.request(
237+
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
238+
{
239+
method: "POST",
240+
headers: { "Content-Type": "application/json" },
241+
body: JSON.stringify({
242+
target: { id: "title" },
243+
operations: [{ type: "inline-style", property: "opacity", value: null }],
244+
}),
245+
},
246+
);
247+
const payload = (await response.json()) as { changed?: boolean; content?: string };
248+
249+
expect(response.status).toBe(200);
250+
expect(payload.changed).toBe(true);
251+
expect(payload.content).not.toContain("opacity");
252+
});
253+
174254
it("update-from-property returns 400 for a non-fromTo animation", async () => {
175255
const projectDir = createProjectDir();
176256
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>

packages/core/src/studio-api/routes/files.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import { isAudioFile } from "../helpers/mime.js";
1717
import { generateWaveformCache } from "../helpers/waveform.js";
1818
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
1919
import { isSafePath } from "../helpers/safePath.js";
20+
import {
21+
findUnsafeDomPatchValues,
22+
findUnsafeMutationValues,
23+
type UnsafeMutationValue,
24+
} from "../helpers/finiteMutation.js";
2025
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
2126
import {
2227
removeElementFromHtml,
@@ -105,6 +110,20 @@ function writeIfChanged(
105110
return c.json({ ok: true, changed: true, content: next });
106111
}
107112

113+
function rejectUnsafeMutationValues(
114+
c: RouteContext,
115+
unsafeFields: UnsafeMutationValue[],
116+
): Response {
117+
return c.json(
118+
{
119+
error: "mutation contains unsafe values",
120+
fields: unsafeFields.map((field) => field.path),
121+
unsafeValues: unsafeFields,
122+
},
123+
400,
124+
);
125+
}
126+
108127
/**
109128
* Parse the request body and validate that `target` is present.
110129
* Returns `{ error }` if missing, or `{ target, body }` for the full parsed body.
@@ -918,6 +937,10 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
918937
if (!Array.isArray(parsed.body.operations) || parsed.body.operations.length === 0) {
919938
return c.json({ error: "target and operations required" }, 400);
920939
}
940+
const unsafeFields = findUnsafeDomPatchValues(parsed.body);
941+
if (unsafeFields.length > 0) {
942+
return rejectUnsafeMutationValues(c, unsafeFields);
943+
}
921944

922945
let originalContent: string;
923946
try {
@@ -1077,6 +1100,10 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
10771100
if (!body || !body.type) {
10781101
return c.json({ error: "mutation type required" }, 400);
10791102
}
1103+
const unsafeFields = findUnsafeMutationValues(body);
1104+
if (unsafeFields.length > 0) {
1105+
return rejectUnsafeMutationValues(c, unsafeFields);
1106+
}
10801107

10811108
let html = readFileSync(res.absPath, "utf-8");
10821109
let block = extractGsapScriptBlock(html);
-20.4 KB
Binary file not shown.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
2+
3+
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
4+
5+
// fallow-ignore-next-line complexity
6+
export function isElementGsapTargeted(
7+
iframe: HTMLIFrameElement | null,
8+
element: HTMLElement,
9+
): boolean {
10+
// When the GSAP drag intercept is disabled for debugging, treat every
11+
// element as un-targeted so commits take the plain CSS persist path.
12+
if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
13+
if (!iframe?.contentWindow) return false;
14+
let timelines: Record<string, TimelineLike> | undefined;
15+
try {
16+
timelines = (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
17+
.__timelines;
18+
} catch {
19+
return false;
20+
}
21+
if (!timelines) return false;
22+
const id = element.id;
23+
for (const tl of Object.values(timelines)) {
24+
if (!tl?.getChildren) continue;
25+
try {
26+
for (const child of tl.getChildren(true)) {
27+
if (!child.targets) continue;
28+
for (const t of child.targets()) {
29+
if (t === element || (id && t.id === id)) return true;
30+
}
31+
}
32+
} catch {
33+
continue;
34+
}
35+
}
36+
return false;
37+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
2+
3+
export const PROPERTY_DEFAULTS: Record<string, number> = {
4+
opacity: 1,
5+
x: 0,
6+
y: 0,
7+
scale: 1,
8+
scaleX: 1,
9+
scaleY: 1,
10+
rotation: 0,
11+
width: 100,
12+
height: 100,
13+
};
14+
15+
export function ensureElementAddressable(selection: DomEditSelection): {
16+
selector: string;
17+
autoId?: string;
18+
} {
19+
if (selection.id) return { selector: `#${selection.id}` };
20+
if (selection.selector) return { selector: selection.selector };
21+
22+
const el = selection.element;
23+
const doc = el.ownerDocument;
24+
const tag = el.tagName.toLowerCase();
25+
let id = tag;
26+
let n = 1;
27+
while (doc.getElementById(id)) {
28+
n += 1;
29+
id = `${tag}-${n}`;
30+
}
31+
el.setAttribute("id", id);
32+
return { selector: `#${id}`, autoId: id };
33+
}
34+
35+
export class GsapMutationHttpError extends Error {
36+
constructor(
37+
readonly statusCode: number,
38+
readonly responseBody: unknown,
39+
) {
40+
super(formatGsapMutationHttpErrorMessage(statusCode, responseBody));
41+
this.name = "GsapMutationHttpError";
42+
}
43+
}
44+
45+
function isRecord(value: unknown): value is Record<string, unknown> {
46+
return typeof value === "object" && value !== null;
47+
}
48+
49+
export async function readJsonResponseBody(res: Response): Promise<unknown> {
50+
const contentType = res.headers.get("content-type") ?? "";
51+
if (!contentType.includes("application/json")) {
52+
return await res.text().catch(() => null);
53+
}
54+
return await res.json().catch(() => null);
55+
}
56+
57+
function formatGsapMutationHttpErrorMessage(statusCode: number, body: unknown): string {
58+
if (isRecord(body) && typeof body.error === "string") {
59+
return body.error;
60+
}
61+
return `GSAP mutation failed with status ${statusCode}`;
62+
}
63+
64+
export function formatGsapMutationRejectionToast(error: GsapMutationHttpError): string {
65+
const body = error.responseBody;
66+
if (isRecord(body)) {
67+
const fields = Array.isArray(body.fields)
68+
? body.fields.filter((field): field is string => typeof field === "string")
69+
: [];
70+
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
71+
return `Couldn't save animation: ${formatGsapMutationHttpErrorMessage(
72+
error.statusCode,
73+
body,
74+
)}${suffix}`;
75+
}
76+
return `Couldn't save animation: ${error.message}`;
77+
}

0 commit comments

Comments
 (0)