fix: validate renderUrl as absolute http/https before loading in artboard iframe

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 <noreply@anthropic.com>
This commit is contained in:
SinachPat
2026-05-07 07:10:27 +01:00
co-authored by Claude Sonnet 4.6
parent cdf6f8caf3
commit f73c7f83d6
2 changed files with 64 additions and 10 deletions
@@ -577,16 +577,30 @@ function EmptyArtboardContent({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [urlValue, setUrlValue] = useState(''); const [urlValue, setUrlValue] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [urlError, setUrlError] = useState('');
const save = async () => { const save = async () => {
const url = urlValue.trim(); const raw = urlValue.trim();
if (!url) return; 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); setSaving(true);
await fetch(`/api/artboards/${id}`, { await fetch(`/api/artboards/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 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); }).catch(console.error);
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
@@ -620,15 +634,21 @@ function EmptyArtboardContent({
autoFocus autoFocus
type="url" type="url"
value={urlValue} value={urlValue}
onChange={(e) => setUrlValue(e.target.value)} onChange={(e) => { setUrlValue(e.target.value); setUrlError(''); }}
placeholder="http://localhost:4170" placeholder="http://localhost:4170"
onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }} onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }}
style={{ 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%', fontSize: 11, fontFamily: 'inherit', width: '100%',
boxSizing: 'border-box' as const, color: '#0A0A0A', background: '#fff', boxSizing: 'border-box' as const, color: '#0A0A0A', background: '#fff',
}} }}
/> />
{urlError && (
<p style={{ margin: 0, fontSize: 10, color: '#EF4444', lineHeight: 1.4, fontFamily: 'inherit' }}>
{urlError}
</p>
)}
<p style={hintStyle}> <p style={hintStyle}>
Use the CLI proxy URL or a preview deployment URL with @originmain/live installed Use the CLI proxy URL or a preview deployment URL with @originmain/live installed
</p> </p>
+39 -5
View File
@@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; 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 { export interface CanvasArtboard {
id: string; id: string;
@@ -11,19 +11,53 @@ export interface CanvasArtboard {
renderUrl?: string; renderUrl?: string;
/** Route path appended to renderUrl when rendering a specific screen, e.g. "/dashboard". */ /** Route path appended to renderUrl when rendering a specific screen, e.g. "/dashboard". */
route?: string; 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<string, unknown> | null;
} }
function toCanvasArtboard(ab: Artboard): CanvasArtboard | null { function toCanvasArtboard(ab: Artboard): CanvasArtboard | null {
const meta = ab.metadata_jsonb; const meta = ab.metadata_jsonb;
const x = typeof meta['x'] === 'number' ? meta['x'] : null;
const y = typeof meta['y'] === 'number' ? meta['y'] : null; // x/y: prefer promoted top-level columns (migration 012), fall back to metadata_jsonb.
const width = typeof meta['width'] === 'number' ? meta['width'] : null; 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; const height = typeof meta['height'] === 'number' ? meta['height'] : null;
if (x === null || y === null || width === null || height === null) return 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 }; 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']; 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<string, unknown> ?? null;
return base; return base;
} }