Skip to content

Commit 3bcab3d

Browse files
fix(studio): reject unsafe keyframe values (#1389)
1 parent ab7f69c commit 3bcab3d

10 files changed

Lines changed: 372 additions & 43 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/helpers/safePath.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,21 @@ function createProjectDir(): string {
1919
}
2020

2121
describe("walkDir", () => {
22-
it("hides internal HyperFrames backup files from project listings", () => {
22+
it("hides internal HyperFrames files from project listings", () => {
2323
const projectDir = createProjectDir();
2424
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
2525
mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
26+
mkdirSync(join(projectDir, ".cache", "examples"), { recursive: true });
2627
mkdirSync(join(projectDir, "compositions"), { recursive: true });
2728
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup");
2829
writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), "preset");
30+
writeFileSync(join(projectDir, ".cache", "examples", "preset.html"), "preset");
2931
writeFileSync(join(projectDir, "compositions", "scene.html"), "scene");
3032

31-
expect(walkDir(projectDir)).toEqual([
32-
".hyperframes/examples/preset.html",
33-
"compositions/scene.html",
34-
]);
33+
const files = walkDir(projectDir);
34+
expect(files).toContain(".cache/examples/preset.html");
35+
expect(files).toContain("compositions/scene.html");
36+
expect(files).not.toContain(".hyperframes/backup/snapshot.html");
37+
expect(files).not.toContain(".hyperframes/examples/preset.html");
3538
});
3639
});

packages/core/src/studio-api/helpers/safePath.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@ export function isSafePath(base: string, resolved: string): boolean {
77
return resolved.startsWith(norm) || resolved === resolve(base);
88
}
99

10-
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
11-
12-
function shouldIgnoreDir(rel: string): boolean {
13-
return rel === ".hyperframes/backup";
14-
}
10+
const IGNORE_DIRS = new Set([".thumbnails", ".hyperframes", "node_modules", ".git"]);
1511

1612
/**
1713
* True when any directory segment of a relative path is a dot-directory or
@@ -30,7 +26,7 @@ export function walkDir(dir: string, prefix = ""): string[] {
3026
const files: string[] = [];
3127
for (const entry of readdirSync(dir, { withFileTypes: true })) {
3228
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
33-
if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
29+
if (IGNORE_DIRS.has(entry.name)) continue;
3430
if (entry.isDirectory()) {
3531
files.push(...walkDir(join(dir, entry.name), rel));
3632
} else {

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,86 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas
239239
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
240240
});
241241

242+
it("rejects serialized non-finite mutation values before writing source", async () => {
243+
const projectDir = createProjectDir();
244+
writeHtml(projectDir, "comp.html", FROMTO_COMP);
245+
const app = new Hono();
246+
registerFileRoutes(app, createAdapter(projectDir));
247+
248+
const anim = await getFirstAnimation(app, "comp.html");
249+
const before = readFileSync(join(projectDir, "comp.html"), "utf-8");
250+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
251+
method: "POST",
252+
headers: { "Content-Type": "application/json" },
253+
body: JSON.stringify({
254+
type: "update-property",
255+
animationId: anim.id,
256+
property: "x",
257+
value: Number.NaN,
258+
}),
259+
});
260+
const payload = (await res.json()) as { error?: string; fields?: string[] };
261+
262+
expect(res.status).toBe(400);
263+
expect(payload.error).toContain("unsafe values");
264+
expect(payload.fields).toContain("body.value");
265+
expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(before);
266+
});
267+
268+
it("rejects unsafe DOM patch metadata before writing source", async () => {
269+
const projectDir = createProjectDir();
270+
writeFileSync(join(projectDir, "index.html"), '<div id="title">Before</div>');
271+
const app = new Hono();
272+
registerFileRoutes(app, createAdapter(projectDir));
273+
274+
const response = await app.request(
275+
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
276+
{
277+
method: "POST",
278+
headers: { "Content-Type": "application/json" },
279+
body: JSON.stringify({
280+
target: { id: "title", selectorIndex: Number.NaN },
281+
operations: [{ type: "text-content", property: "textContent", value: "After" }],
282+
}),
283+
},
284+
);
285+
const payload = (await response.json()) as { error?: string; fields?: string[] };
286+
287+
expect(response.status).toBe(400);
288+
expect(payload.error).toContain("unsafe values");
289+
expect(payload.fields).toContain("body.target.selectorIndex");
290+
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(
291+
'<div id="title">Before</div>',
292+
);
293+
});
294+
295+
it("allows DOM patch null values used for explicit style removals", async () => {
296+
const projectDir = createProjectDir();
297+
writeFileSync(
298+
join(projectDir, "index.html"),
299+
'<div id="title" style="opacity: 1">Before</div>',
300+
);
301+
const app = new Hono();
302+
registerFileRoutes(app, createAdapter(projectDir));
303+
304+
const response = await app.request(
305+
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
306+
{
307+
method: "POST",
308+
headers: { "Content-Type": "application/json" },
309+
body: JSON.stringify({
310+
target: { id: "title" },
311+
operations: [{ type: "inline-style", property: "opacity", value: null }],
312+
}),
313+
},
314+
);
315+
const payload = (await response.json()) as { changed?: boolean; content?: string };
316+
317+
expect(response.status).toBe(200);
318+
expect(payload.changed).toBe(true);
319+
expect(payload.content).not.toContain("opacity");
320+
});
321+
242322
it("update-from-property returns 400 for a non-fromTo animation", async () => {
243323
const projectDir = createProjectDir();
244324
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
@@ -18,6 +18,11 @@ import { generateWaveformCache } from "../helpers/waveform.js";
1818
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
1919
import { isSafePath } from "../helpers/safePath.js";
2020
import { backupPathForResponse, snapshotBeforeWrite } from "../helpers/backupJournal.js";
21+
import {
22+
findUnsafeDomPatchValues,
23+
findUnsafeMutationValues,
24+
type UnsafeMutationValue,
25+
} from "../helpers/finiteMutation.js";
2126
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
2227
import {
2328
removeElementFromHtml,
@@ -116,6 +121,20 @@ function writeIfChanged(
116121
});
117122
}
118123

124+
function rejectUnsafeMutationValues(
125+
c: RouteContext,
126+
unsafeFields: UnsafeMutationValue[],
127+
): Response {
128+
return c.json(
129+
{
130+
error: "mutation contains unsafe values",
131+
fields: unsafeFields.map((field) => field.path),
132+
unsafeValues: unsafeFields,
133+
},
134+
400,
135+
);
136+
}
137+
119138
/**
120139
* Parse the request body and validate that `target` is present.
121140
* Returns `{ error }` if missing, or `{ target, body }` for the full parsed body.
@@ -951,6 +970,10 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
951970
if (!Array.isArray(parsed.body.operations) || parsed.body.operations.length === 0) {
952971
return c.json({ error: "target and operations required" }, 400);
953972
}
973+
const unsafeFields = findUnsafeDomPatchValues(parsed.body);
974+
if (unsafeFields.length > 0) {
975+
return rejectUnsafeMutationValues(c, unsafeFields);
976+
}
954977

955978
let originalContent: string;
956979
try {
@@ -1125,6 +1148,10 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
11251148
if (!body || !body.type) {
11261149
return c.json({ error: "mutation type required" }, 400);
11271150
}
1151+
const unsafeFields = findUnsafeMutationValues(body);
1152+
if (unsafeFields.length > 0) {
1153+
return rejectUnsafeMutationValues(c, unsafeFields);
1154+
}
11281155

11291156
let html = readFileSync(res.absPath, "utf-8");
11301157
let block = extractGsapScriptBlock(html);

packages/studio/src/hooks/gsapScriptCommitHelpers.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
12
import type { DomEditSelection } from "../components/editor/domEditingTypes";
23

34
export const PROPERTY_DEFAULTS: Record<string, number> = {
@@ -31,3 +32,97 @@ export function ensureElementAddressable(selection: DomEditSelection): {
3132
el.setAttribute("id", id);
3233
return { selector: `#${id}`, autoId: id };
3334
}
35+
36+
export class GsapMutationHttpError extends Error {
37+
constructor(
38+
readonly statusCode: number,
39+
readonly responseBody: unknown,
40+
) {
41+
super(formatGsapMutationHttpErrorMessage(statusCode, responseBody));
42+
this.name = "GsapMutationHttpError";
43+
}
44+
}
45+
46+
function isRecord(value: unknown): value is Record<string, unknown> {
47+
return typeof value === "object" && value !== null;
48+
}
49+
50+
export async function readJsonResponseBody(res: Response): Promise<unknown> {
51+
const contentType = res.headers.get("content-type") ?? "";
52+
if (!contentType.includes("application/json")) {
53+
return await res.text().catch(() => null);
54+
}
55+
return await res.json().catch(() => null);
56+
}
57+
58+
function formatGsapMutationHttpErrorMessage(statusCode: number, body: unknown): string {
59+
if (isRecord(body) && typeof body.error === "string") {
60+
return body.error;
61+
}
62+
return `GSAP mutation failed with status ${statusCode}`;
63+
}
64+
65+
export function formatGsapMutationRejectionToast(error: GsapMutationHttpError): string {
66+
const body = error.responseBody;
67+
if (isRecord(body)) {
68+
const fields = Array.isArray(body.fields)
69+
? body.fields.filter((field): field is string => typeof field === "string")
70+
: [];
71+
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
72+
return `Couldn't save animation: ${formatGsapMutationHttpErrorMessage(
73+
error.statusCode,
74+
body,
75+
)}${suffix}`;
76+
}
77+
return `Couldn't save animation: ${error.message}`;
78+
}
79+
80+
interface AssignAutoIdParams {
81+
projectId: string;
82+
targetPath: string;
83+
selection: DomEditSelection;
84+
autoId: string;
85+
showToast?: (message: string, tone?: "error" | "info") => void;
86+
}
87+
88+
export async function assignGsapTargetAutoIdIfNeeded({
89+
projectId,
90+
targetPath,
91+
selection,
92+
autoId,
93+
showToast,
94+
}: AssignAutoIdParams): Promise<boolean> {
95+
const patchBody = {
96+
target: {
97+
id: selection.id,
98+
hfId: selection.hfId,
99+
selector: selection.selector,
100+
selectorIndex: selection.selectorIndex,
101+
},
102+
operations: [{ type: "html-attribute", property: "id", value: autoId }],
103+
};
104+
const unsafePatchFields = findUnsafeDomPatchValues(patchBody);
105+
if (unsafePatchFields.length > 0) {
106+
showToast?.("Couldn't assign element id because the patch contains invalid values", "error");
107+
return false;
108+
}
109+
const res = await fetch(
110+
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
111+
{
112+
method: "POST",
113+
headers: { "Content-Type": "application/json" },
114+
body: JSON.stringify(patchBody),
115+
},
116+
);
117+
if (!res.ok) {
118+
showToast?.(
119+
formatGsapMutationRejectionToast(
120+
new GsapMutationHttpError(res.status, await readJsonResponseBody(res)),
121+
),
122+
"error",
123+
);
124+
return false;
125+
}
126+
const data = (await res.json()) as { changed?: boolean };
127+
return data.changed === true;
128+
}

0 commit comments

Comments
 (0)