feat(stammbaum): parse + sanitise URL pan/zoom params (#692)

Degrade Infinity/NaN/overflow per axis and clamp zoom into bounds so a crafted
?cx/?cy/?z shared link cannot blank the SVG (Nora's review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 16:25:11 +02:00
parent 5458ca9bae
commit a7d0e96613
2 changed files with 75 additions and 1 deletions

View File

@@ -12,8 +12,47 @@
/** Resolved zoom bounds (OQ-001). */
export const MIN_ZOOM = 0.25;
export const MAX_ZOOM = 3.0;
export const DEFAULT_ZOOM = 1;
/**
* The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre
* (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the
* whole tree (fit-to-screen) because the base viewBox already encloses the
* layout bounding box at z=1.
*/
export type PanZoomState = { x: number; y: number; z: number };
/** Fit-to-screen / initial view (US-PAN-004). */
export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM };
/** Clamp a zoom factor into the supported range. */
export function clampZoom(z: number): number {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
}
/** Parse a raw value to a finite number, or return `fallback` for NaN/Infinity/absent. */
function finiteOr(raw: string | null | undefined, fallback: number): number {
if (raw == null) return fallback;
const n = Number(raw);
return Number.isFinite(n) ? n : fallback;
}
/**
* Parse URL-supplied pan/zoom params into a safe {@link PanZoomState} (OQ-003).
*
* Every axis is sanitised independently: `Infinity`, `NaN`, overflow (`1e500`),
* and absent values degrade to the default for that axis, and the zoom is
* clamped into [MIN_ZOOM, MAX_ZOOM]. This guards against a crafted shared link
* (`?z=Infinity`, `?cx=NaN`) rendering the SVG blank — see Nora's review (#692).
*/
export function parsePanZoomParams(raw: {
cx?: string | null;
cy?: string | null;
z?: string | null;
}): PanZoomState {
return {
x: finiteOr(raw.cx, DEFAULT_VIEW.x),
y: finiteOr(raw.cy, DEFAULT_VIEW.y),
z: clampZoom(finiteOr(raw.z, DEFAULT_ZOOM))
};
}