From f73c7f83d65fbbe5030d2d27fe438f8809f00b95 Mon Sep 17 00:00:00 2001 From: SinachPat Date: Thu, 7 May 2026 07:10:27 +0100 Subject: [PATCH] fix: validate renderUrl as absolute http/https before loading in artboard iframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmptyArtboardContent.save() accepted any string as renderUrl — a relative path or the Originmain app URL would cause the artboard iframe to resolve against the Originmain origin, loading the app inside itself. - Added URL validation (same as UrlOnboardingOverlay) with inline error UI - toCanvasArtboard now validates renderUrl at read time so stale bad data already in the DB is also guarded against - CanvasArtboard now carries artboard_type, isolation_component, isolation_file, isolation_props so phase-3 isolation artboards render correctly; previously these fields were silently dropped by the hook Co-Authored-By: Claude Sonnet 4.6 --- .../app/src/components/canvas/Artboard.tsx | 30 ++++++++++--- packages/app/src/hooks/useArtboards.ts | 44 ++++++++++++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/canvas/Artboard.tsx b/packages/app/src/components/canvas/Artboard.tsx index 40f0a90..856f7a0 100644 --- a/packages/app/src/components/canvas/Artboard.tsx +++ b/packages/app/src/components/canvas/Artboard.tsx @@ -577,16 +577,30 @@ function EmptyArtboardContent({ const [editing, setEditing] = useState(false); const [urlValue, setUrlValue] = useState(''); const [saving, setSaving] = useState(false); + const [urlError, setUrlError] = useState(''); const save = async () => { - const url = urlValue.trim(); - if (!url) return; + const raw = urlValue.trim(); + if (!raw) return; + + // Validate: must be an absolute http/https URL so the iframe doesn't + // accidentally resolve a relative path against the Originmain origin + // (which would load the Originmain app inside itself). + try { + const parsed = new URL(raw); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('bad protocol'); + } catch { + setUrlError('Enter a valid http:// or https:// URL (e.g. http://localhost:4170)'); + return; + } + setUrlError(''); + setSaving(true); await fetch(`/api/artboards/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - metadata_jsonb: { renderUrl: url, width, height, x: 0, y: 0 }, + metadata_jsonb: { renderUrl: raw, width, height, x: 0, y: 0 }, }), }).catch(console.error); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); @@ -620,15 +634,21 @@ function EmptyArtboardContent({ autoFocus type="url" value={urlValue} - onChange={(e) => setUrlValue(e.target.value)} + onChange={(e) => { setUrlValue(e.target.value); setUrlError(''); }} placeholder="http://localhost:4170" onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }} style={{ - padding: '7px 10px', borderRadius: 6, border: '1.5px solid #0066FF', + padding: '7px 10px', borderRadius: 6, + border: `1.5px solid ${urlError ? '#EF4444' : '#0066FF'}`, fontSize: 11, fontFamily: 'inherit', width: '100%', boxSizing: 'border-box' as const, color: '#0A0A0A', background: '#fff', }} /> + {urlError && ( +

+ {urlError} +

+ )}

Use the CLI proxy URL or a preview deployment URL with @originmain/live installed

diff --git a/packages/app/src/hooks/useArtboards.ts b/packages/app/src/hooks/useArtboards.ts index 9238b62..d3c9235 100644 --- a/packages/app/src/hooks/useArtboards.ts +++ b/packages/app/src/hooks/useArtboards.ts @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { Artboard, InsertArtboard } from '@originmain/origin-graph'; +import type { Artboard, InsertArtboard, ArtboardType } from '@originmain/origin-graph'; export interface CanvasArtboard { id: string; @@ -11,19 +11,53 @@ export interface CanvasArtboard { renderUrl?: string; /** Route path appended to renderUrl when rendering a specific screen, e.g. "/dashboard". */ route?: string; + /** Controls which renderer is used: route (default), isolation, or static. */ + artboard_type?: ArtboardType; + /** Isolation artboard: exported component display name. */ + isolation_component?: string | null; + /** Isolation artboard: workspace-relative source file path. */ + isolation_file?: string | null; + /** Isolation artboard: live prop overrides forwarded to the isolation frame. */ + isolation_props?: Record | null; } function toCanvasArtboard(ab: Artboard): CanvasArtboard | null { const meta = ab.metadata_jsonb; - const x = typeof meta['x'] === 'number' ? meta['x'] : null; - const y = typeof meta['y'] === 'number' ? meta['y'] : null; - const width = typeof meta['width'] === 'number' ? meta['width'] : null; + + // x/y: prefer promoted top-level columns (migration 012), fall back to metadata_jsonb. + const x = typeof ab.canvas_x === 'number' ? ab.canvas_x + : typeof meta['x'] === 'number' ? meta['x'] : null; + const y = typeof ab.canvas_y === 'number' ? ab.canvas_y + : typeof meta['y'] === 'number' ? meta['y'] : null; + // width/height come from metadata_jsonb (top-level DB columns default to 1440/900, + // not from the user's intent — metadata_jsonb carries the actual artboard dimensions). + const width = typeof meta['width'] === 'number' ? meta['width'] : null; const height = typeof meta['height'] === 'number' ? meta['height'] : null; + if (x === null || y === null || width === null || height === null) return null; + const base: CanvasArtboard = { id: ab.id, label: ab.name, x, y, width, height }; - if (typeof meta['renderUrl'] === 'string') base.renderUrl = meta['renderUrl']; + + // renderUrl must be an absolute http/https URL — skip relative strings to + // avoid loading the Originmain app inside itself when the iframe resolves + // against the Originmain origin. + if (typeof meta['renderUrl'] === 'string') { + const raw = meta['renderUrl'] as string; + try { + const parsed = new URL(raw); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') base.renderUrl = raw; + } catch { /* invalid URL stored in DB — ignore */ } + } + if (typeof meta['route'] === 'string' && meta['route']) base.route = meta['route']; + + // Phase 3 isolation artboard fields (top-level DB columns, migration 012). + if (ab.artboard_type) base.artboard_type = ab.artboard_type; + if (ab.isolation_component !== undefined) base.isolation_component = ab.isolation_component ?? null; + if (ab.isolation_file !== undefined) base.isolation_file = ab.isolation_file ?? null; + if (ab.isolation_props !== undefined) base.isolation_props = ab.isolation_props as Record ?? null; + return base; }