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:
@@ -1,5 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { clampZoom, MIN_ZOOM, MAX_ZOOM } from './panZoom';
|
import {
|
||||||
|
clampZoom,
|
||||||
|
parsePanZoomParams,
|
||||||
|
DEFAULT_VIEW,
|
||||||
|
DEFAULT_ZOOM,
|
||||||
|
MIN_ZOOM,
|
||||||
|
MAX_ZOOM
|
||||||
|
} from './panZoom';
|
||||||
|
|
||||||
describe('clampZoom', () => {
|
describe('clampZoom', () => {
|
||||||
it('returns the value unchanged when within range', () => {
|
it('returns the value unchanged when within range', () => {
|
||||||
@@ -24,3 +31,31 @@ describe('clampZoom', () => {
|
|||||||
expect(MAX_ZOOM).toBe(3.0);
|
expect(MAX_ZOOM).toBe(3.0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parsePanZoomParams', () => {
|
||||||
|
it('parses well-formed cx/cy/z params', () => {
|
||||||
|
expect(parsePanZoomParams({ cx: '120', cy: '-40', z: '1.5' })).toEqual({
|
||||||
|
x: 120,
|
||||||
|
y: -40,
|
||||||
|
z: 1.5
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to DEFAULT_VIEW when params are absent', () => {
|
||||||
|
expect(parsePanZoomParams({})).toEqual(DEFAULT_VIEW);
|
||||||
|
expect(DEFAULT_VIEW).toEqual({ x: 0, y: 0, z: DEFAULT_ZOOM });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects Infinity and NaN, degrading each axis to its default (Nora #692)', () => {
|
||||||
|
expect(parsePanZoomParams({ z: 'Infinity' }).z).toBe(DEFAULT_ZOOM);
|
||||||
|
expect(parsePanZoomParams({ z: 'NaN' }).z).toBe(DEFAULT_ZOOM);
|
||||||
|
expect(parsePanZoomParams({ cx: 'NaN', cy: 'Infinity' })).toEqual(DEFAULT_VIEW);
|
||||||
|
expect(parsePanZoomParams({ cx: '1e500' }).x).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps an out-of-range zoom into the supported bounds', () => {
|
||||||
|
expect(parsePanZoomParams({ z: '99' }).z).toBe(MAX_ZOOM);
|
||||||
|
expect(parsePanZoomParams({ z: '0.01' }).z).toBe(MIN_ZOOM);
|
||||||
|
expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,8 +12,47 @@
|
|||||||
/** Resolved zoom bounds (OQ-001). */
|
/** Resolved zoom bounds (OQ-001). */
|
||||||
export const MIN_ZOOM = 0.25;
|
export const MIN_ZOOM = 0.25;
|
||||||
export const MAX_ZOOM = 3.0;
|
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. */
|
/** Clamp a zoom factor into the supported range. */
|
||||||
export function clampZoom(z: number): number {
|
export function clampZoom(z: number): number {
|
||||||
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
|
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))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user