Plan 003: Harden .lab project import — schema validation, custom-shader consent gate, SVG raster cap
Executor instructions: Follow this plan step by step. Run every
verification command and confirm the expected result before moving to the
next step. If anything in the "STOP conditions" section occurs, stop and
report — do not improvise. When done, update the status row for this plan
in plans/README.md — unless a reviewer dispatched you and told you they
maintain the index.
Drift check (run first): git diff --stat 84c1d09..HEAD -- src/lib/editor/project-file.ts src/renderer/custom-shader-pass.ts src/renderer/custom-shader-runtime.ts src/renderer/pipeline-manager.ts src/store/layer-store.ts
If any in-scope file changed since this plan was written, compare the
"Current state" excerpts against the live code before proceeding; on a
mismatch, treat it as a STOP condition.
Status
- Priority: P1
- Effort: M
- Risk: MED (touches the project load path; behavior must stay identical for trusted files)
- Depends on: plans/001-test-baseline-pure-logic.md
- Category: security
- Planned at: commit
84c1d09, 2026-06-10
Why this matters
Opening a .lab project file from someone else currently executes that person's JavaScript in your browser session. The chain: (1) custom-shader layers store their source in a sourceCode layer parameter, which is serialized into .lab files; (2) parseLabProjectFile validates only the top-level shape and structuredClones everything else straight into the stores; (3) the custom-shader pass compiles sourceCode with the TypeScript transpiler and runs it via new Function(...); (4) the sanitizer strips only imports/exports/JSX — it does not block fetch, document, localStorage, globalThis, or anything else in page scope. .lab files are designed to be shared, so this is a working arbitrary-code-execution vector against anyone who opens an untrusted project. A secondary DoS exists: svgRasterResolution from a project file is parsed with no upper bound and sized into a canvas.
Custom shaders running arbitrary TSL code is the feature — this plan does not sandbox the authoring flow. It (a) validates imported files with zod (already a dependency), and (b) requires one explicit user confirmation before custom-shader code from an imported file is executed, and (c) caps the SVG raster resolution.
Current state
-
src/lib/editor/project-file.ts:48-92 — parseLabProjectFile does manual shape checks then return structuredClone(candidate as LabProjectFile). Layers and timeline tracks are not validated beyond Array.isArray. (Full excerpt in plan 001; same source.)
-
src/lib/editor/project-file.ts:94-150 — applyLabProjectFile pushes parsed layers into useLayerStore.getState().replaceState(...) and tracks into useTimelineStore.getState().replaceState(...).
-
src/lib/editor/config/layer-registry.ts:124-160 — the custom-shader layer defines params including:
{
animatable: false,
defaultValue: CUSTOM_SHADER_STARTER,
key: "sourceCode",
label: "Source Code",
type: "text",
visibleWhen: CUSTOM_SHADER_INTERNAL_VISIBILITY,
},
-
src/renderer/custom-shader-pass.ts:68 — void compileCustomShaderModule({ ... }) is invoked from the pass with the layer's sourceCode.
-
src/renderer/custom-shader-runtime.ts:96-154 — sanitizeCustomShaderSource filters only import/export/JSX statements. Lines 264-271:
const evaluator = new Function(
"exports",
"module",
...scopeNames,
`${outputText}\nreturn module.exports;`
)
The scope injects TSL + shader utils (PRELUDE), but new Function bodies still resolve fetch, document, etc. from global scope.
-
src/renderer/pipeline-manager.ts:35-50 — parseSvgRasterResolution clamps only the lower bound:
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2048
}
return Math.round(parsed)
-
zod v4 is already a root dependency (package.json: "zod": "^4.3.6") but is not yet imported anywhere in src/lib/editor/ (verify with grep -rn "from \"zod\"" src/).
-
Find the import call site (file-open UI) with grep -rn "parseLabProjectFile" src/ — expected: one UI call site plus the definition. Read it before step 3.
Repo conventions: double quotes, no semicolons, sorted object keys (Biome), strict TS with exactOptionalPropertyTypes. UI confirmations elsewhere use dialog components from src/components/ui/ — match the closest existing confirm-style dialog if one exists; otherwise a minimal window.confirm is acceptable for this plan (note it in the PR as a follow-up for design).
Commands you will need
| Purpose |
Command |
Expected on success |
| Install |
bun install --frozen-lockfile |
exit 0 |
| Build runtime pkg |
bun run build:runtime |
exit 0 |
| Tests |
bun test |
all pass |
| Typecheck |
bun run typecheck:tsc |
exit 0 |
| Lint |
bun run lint |
exit 0 |
| Dev server (manual check) |
bun dev |
editor at localhost:3000 |
Scope
In scope:
src/lib/editor/project-file.ts (zod schema + parse rewrite)
src/lib/editor/__tests__/project-file.test.ts (extend)
- The single UI call site of
parseLabProjectFile / applyLabProjectFile (consent gate)
src/renderer/pipeline-manager.ts (resolution cap only)
plans/README.md (status row)
Out of scope:
src/renderer/custom-shader-runtime.ts — do NOT attempt to sandbox new Function or restrict its scope; that's a product-level decision (worker isolation) deferred deliberately.
packages/shader-lab-react/** — the published runtime has the same new Function pattern; consumers embed configs they author themselves. Mirroring this hardening is a separate decision; do not touch.
- The custom-shader authoring/editing flow (typing code in the editor must keep working with zero new friction).
Git workflow
- Branch:
advisor/003-harden-lab-import
- Commit per step; style:
fix: validate .lab imports with zod, feat: require confirmation before running imported custom shader code.
- App-only changes: no changeset required.
- Do NOT push or open a PR unless the operator instructed it.
Steps
Step 1: Add a zod schema for LabProjectFile
In src/lib/editor/project-file.ts, define a zod schema that encodes at minimum: format literal "shader-lab"; version 1 or 2; assets array of { fileName: string, id: string, kind: string }; layers array of objects validated to have id: string, type/kind per EditorLayer (read src/types/editor.ts and mirror the required fields; use .passthrough()/.loose() for parameter bags rather than enumerating every layer param); timeline with duration: number, loop: boolean, tracks array; composition with positive finite width/height numbers. Rewrite parseLabProjectFile to use schema.safeParse, mapping failures to the existing user-facing error messages where the old checks had specific wording (keep the exact strings for: invalid JSON, wrong format, unsupported version, missing layer stack, missing timeline data/tracks, missing composition dimensions — plan 001's tests assert them).
Verify: bun test project-file → all existing tests still pass.
Step 2: Extend the tests
Add cases to src/lib/editor/__tests__/project-file.test.ts:
- Layer entry that is a string (not object) → rejected.
composition: { width: Infinity, height: 1080 } → rejected.
timeline.duration: "6" (string) → rejected.
- Unknown extra keys on a layer's params → accepted (passthrough), preserved in output.
Verify: bun test project-file → all pass, including the 4 new cases.
Step 3: Consent gate for imported custom-shader code
At the UI call site that imports a .lab file (found via the grep in Current state): after a successful parse and before applyLabProjectFile, check whether any parsed layer is a custom-shader layer whose sourceCode param differs from the built-in starters (CUSTOM_SHADER_STARTER / CUSTOM_EFFECT_STARTER from src/lib/editor/custom-shader/shared.ts). If so, show a confirmation: "This project contains custom shader code that will run in your browser. Only continue if you trust the source. Run custom shaders?" — Cancel must either abort the whole import (simplest, acceptable) or strip the custom-shader layers; pick aborting. Identify custom-shader layers by the same type discriminator the registry uses ("custom-shader" appears in src/types/editor.ts:32).
Verify: bun run typecheck:tsc → exit 0. Manual: bun dev, export a project containing a custom-shader layer, re-import it → confirmation appears; Cancel aborts; Confirm loads it and the shader runs.
Step 4: Cap SVG raster resolution
In src/renderer/pipeline-manager.ts parseSvgRasterResolution, clamp the upper bound to 8192: return Math.min(8192, Math.round(parsed)).
Verify: bun run typecheck:tsc → exit 0; bun run lint → exit 0.
Step 5: Full gate
Verify: bun test && bun run typecheck:tsc && bun run lint → all exit 0.
Test plan
- Step 2's four new validation cases in
src/lib/editor/__tests__/project-file.test.ts, modeled on the tests from plan 001.
- The consent gate is UI-level; verified manually in step 3 (documented in the PR description). Do not write DOM tests for it in this plan.
Done criteria
STOP conditions
Stop and report back (do not improvise) if:
parseLabProjectFile has more than one production call site, or the call site structure makes a pre-apply gate impossible without refactoring beyond the listed files.
- The
EditorLayer type union is too complex to mirror in zod without enumerating per-layer params AND .passthrough() on params is insufficient to keep imports working — report the type complexity rather than weakening validation to z.any() everywhere.
- zod v4 API differs from what you expect (it changed notably from v3 — check the installed version's docs in
node_modules/zod/README.md first).
- Plan 001 is not DONE (its tests are the regression net for the parse rewrite).
Maintenance notes
- The real long-term fix for custom-shader execution is isolation (Web Worker / restricted scope) or pre-transpiled, signed exports — tracked as a direction item, not this plan.
- When new layer types are added, the zod layer schema's discriminator may need updating — reviewers of new-layer PRs should check.
- The consent gate keys off the starter-source constants; if starters change, previously-fine files may re-prompt (harmless).
- Deferred: mirroring the SVG cap and any validation into
packages/shader-lab-react (consumers pass their own configs there).
Plan 003: Harden
.labproject import — schema validation, custom-shader consent gate, SVG raster capStatus
84c1d09, 2026-06-10Why this matters
Opening a
.labproject file from someone else currently executes that person's JavaScript in your browser session. The chain: (1) custom-shader layers store their source in asourceCodelayer parameter, which is serialized into.labfiles; (2)parseLabProjectFilevalidates only the top-level shape andstructuredClones everything else straight into the stores; (3) the custom-shader pass compilessourceCodewith the TypeScript transpiler and runs it vianew Function(...); (4) the sanitizer strips only imports/exports/JSX — it does not blockfetch,document,localStorage,globalThis, or anything else in page scope..labfiles are designed to be shared, so this is a working arbitrary-code-execution vector against anyone who opens an untrusted project. A secondary DoS exists:svgRasterResolutionfrom a project file is parsed with no upper bound and sized into a canvas.Custom shaders running arbitrary TSL code is the feature — this plan does not sandbox the authoring flow. It (a) validates imported files with zod (already a dependency), and (b) requires one explicit user confirmation before custom-shader code from an imported file is executed, and (c) caps the SVG raster resolution.
Current state
src/lib/editor/project-file.ts:48-92—parseLabProjectFiledoes manual shape checks thenreturn structuredClone(candidate as LabProjectFile). Layers and timeline tracks are not validated beyondArray.isArray. (Full excerpt in plan 001; same source.)src/lib/editor/project-file.ts:94-150—applyLabProjectFilepushes parsed layers intouseLayerStore.getState().replaceState(...)and tracks intouseTimelineStore.getState().replaceState(...).src/lib/editor/config/layer-registry.ts:124-160— the custom-shader layer defines params including:src/renderer/custom-shader-pass.ts:68—void compileCustomShaderModule({ ... })is invoked from the pass with the layer'ssourceCode.src/renderer/custom-shader-runtime.ts:96-154—sanitizeCustomShaderSourcefilters only import/export/JSX statements. Lines 264-271:The scope injects TSL + shader utils (
PRELUDE), butnew Functionbodies still resolvefetch,document, etc. from global scope.src/renderer/pipeline-manager.ts:35-50—parseSvgRasterResolutionclamps only the lower bound:zodv4 is already a root dependency (package.json:"zod": "^4.3.6") but is not yet imported anywhere insrc/lib/editor/(verify withgrep -rn "from \"zod\"" src/).Find the import call site (file-open UI) with
grep -rn "parseLabProjectFile" src/— expected: one UI call site plus the definition. Read it before step 3.Repo conventions: double quotes, no semicolons, sorted object keys (Biome), strict TS with
exactOptionalPropertyTypes. UI confirmations elsewhere use dialog components fromsrc/components/ui/— match the closest existing confirm-style dialog if one exists; otherwise a minimalwindow.confirmis acceptable for this plan (note it in the PR as a follow-up for design).Commands you will need
bun install --frozen-lockfilebun run build:runtimebun testbun run typecheck:tscbun run lintbun devScope
In scope:
src/lib/editor/project-file.ts(zod schema + parse rewrite)src/lib/editor/__tests__/project-file.test.ts(extend)parseLabProjectFile/applyLabProjectFile(consent gate)src/renderer/pipeline-manager.ts(resolution cap only)plans/README.md(status row)Out of scope:
src/renderer/custom-shader-runtime.ts— do NOT attempt to sandboxnew Functionor restrict its scope; that's a product-level decision (worker isolation) deferred deliberately.packages/shader-lab-react/**— the published runtime has the samenew Functionpattern; consumers embed configs they author themselves. Mirroring this hardening is a separate decision; do not touch.Git workflow
advisor/003-harden-lab-importfix: validate .lab imports with zod,feat: require confirmation before running imported custom shader code.Steps
Step 1: Add a zod schema for
LabProjectFileIn
src/lib/editor/project-file.ts, define a zod schema that encodes at minimum:formatliteral"shader-lab";version1 or 2;assetsarray of{ fileName: string, id: string, kind: string };layersarray of objects validated to haveid: string,type/kindperEditorLayer(readsrc/types/editor.tsand mirror the required fields; use.passthrough()/.loose()for parameter bags rather than enumerating every layer param);timelinewithduration: number,loop: boolean,tracksarray;compositionwith positive finitewidth/heightnumbers. RewriteparseLabProjectFileto useschema.safeParse, mapping failures to the existing user-facing error messages where the old checks had specific wording (keep the exact strings for: invalid JSON, wrong format, unsupported version, missing layer stack, missing timeline data/tracks, missing composition dimensions — plan 001's tests assert them).Verify:
bun test project-file→ all existing tests still pass.Step 2: Extend the tests
Add cases to
src/lib/editor/__tests__/project-file.test.ts:composition: { width: Infinity, height: 1080 }→ rejected.timeline.duration: "6"(string) → rejected.Verify:
bun test project-file→ all pass, including the 4 new cases.Step 3: Consent gate for imported custom-shader code
At the UI call site that imports a
.labfile (found via the grep in Current state): after a successful parse and beforeapplyLabProjectFile, check whether any parsed layer is a custom-shader layer whosesourceCodeparam differs from the built-in starters (CUSTOM_SHADER_STARTER/CUSTOM_EFFECT_STARTERfromsrc/lib/editor/custom-shader/shared.ts). If so, show a confirmation: "This project contains custom shader code that will run in your browser. Only continue if you trust the source. Run custom shaders?" — Cancel must either abort the whole import (simplest, acceptable) or strip the custom-shader layers; pick aborting. Identify custom-shader layers by the same type discriminator the registry uses ("custom-shader"appears insrc/types/editor.ts:32).Verify:
bun run typecheck:tsc→ exit 0. Manual:bun dev, export a project containing a custom-shader layer, re-import it → confirmation appears; Cancel aborts; Confirm loads it and the shader runs.Step 4: Cap SVG raster resolution
In
src/renderer/pipeline-manager.tsparseSvgRasterResolution, clamp the upper bound to 8192:return Math.min(8192, Math.round(parsed)).Verify:
bun run typecheck:tsc→ exit 0;bun run lint→ exit 0.Step 5: Full gate
Verify:
bun test && bun run typecheck:tsc && bun run lint→ all exit 0.Test plan
src/lib/editor/__tests__/project-file.test.ts, modeled on the tests from plan 001.Done criteria
parseLabProjectFileuses zod (grep -n "zod" src/lib/editor/project-file.ts→ match)bun testexits 0 including ≥ 4 new validation tests.labwith non-starter custom-shadersourceCodetriggers a confirmation before any shader code executes (manual check recorded in PR notes)parseSvgRasterResolutioncaps at 8192 (grep -n "8192" src/renderer/pipeline-manager.ts→ match)bun run typecheck:tscandbun run lintexit 0git status)plans/README.mdstatus row updatedSTOP conditions
Stop and report back (do not improvise) if:
parseLabProjectFilehas more than one production call site, or the call site structure makes a pre-applygate impossible without refactoring beyond the listed files.EditorLayertype union is too complex to mirror in zod without enumerating per-layer params AND.passthrough()on params is insufficient to keep imports working — report the type complexity rather than weakening validation toz.any()everywhere.node_modules/zod/README.mdfirst).Maintenance notes
packages/shader-lab-react(consumers pass their own configs there).