Skip to content

Plan 003: Harden .lab project import — schema validation, custom-shader consent gate, SVG raster cap #86

Description

@git-chad

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-92parseLabProjectFile 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-150applyLabProjectFile 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:68void compileCustomShaderModule({ ... }) is invoked from the pass with the layer's sourceCode.

  • src/renderer/custom-shader-runtime.ts:96-154sanitizeCustomShaderSource 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-50parseSvgRasterResolution 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

  • parseLabProjectFile uses zod (grep -n "zod" src/lib/editor/project-file.ts → match)
  • bun test exits 0 including ≥ 4 new validation tests
  • Importing a .lab with non-starter custom-shader sourceCode triggers a confirmation before any shader code executes (manual check recorded in PR notes)
  • parseSvgRasterResolution caps at 8192 (grep -n "8192" src/renderer/pipeline-manager.ts → match)
  • bun run typecheck:tsc and bun run lint exit 0
  • No files outside the in-scope list modified (git status)
  • plans/README.md status row updated

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).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions