diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..74994075a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,10 @@ # God's Eye View Current State +Share links and structured views accept only own keys in their style registries. +Inherited names such as `constructor` and `__proto__` fall back to the existing +normal/null defaults without interrupting other restored fields. Programmatic +visual-style calls ignore unknown or non-string values before mutating state. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index e862aba66..13dedd709 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -778,3 +778,20 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/); assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/); }); + + +test('share restore rejects inherited styles while continuing later fields', () => { + for (const style of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown']) { + const restored = makeManager(`#lat=10&lon=20&style=${style}&cr=1`).parseInitialHash(); + assert.equal(restored.style, 'normal'); + assert.equal(restored.celestialRing, true); + } +}); + +test('programmatic invalid styles are ignored before changing visual state', () => { + const target = { activeStyle: 'normal' }; + for (const value of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown', {}, null, () => {}]) { + assert.doesNotThrow(() => VisualSettings.prototype.setStyle.call(target, value)); + assert.equal(target.activeStyle, 'normal'); + } +}); diff --git a/src/sharelink.js b/src/sharelink.js index 6072fb1bd..7e0e78b35 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -175,7 +175,9 @@ export class ShareLinkManager { parseOr(params.get('dd'), 50), 50, ); - const style = URL_TO_STYLE[params.get('style')] || 'normal'; + const style = Object.hasOwn(URL_TO_STYLE, params.get('style')) + ? URL_TO_STYLE[params.get('style')] + : 'normal'; const decodedLayerState = decodeLayerStateParams(params); const state = { lat, diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 2aab58f10..b80e4452f 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1539,6 +1539,11 @@ export class VisualSettings { preserveCyberRestore = false, } = {}, ) { + if ( + typeof styleName !== 'string' || + (styleName !== 'normal' && !Object.hasOwn(STYLES, styleName)) + ) + return; const { setDetectionStyle } = this.services; if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual'); if (this.hud.getVariant() === 'cyber') { diff --git a/src/view/index.js b/src/view/index.js index 8ab56d5a4..fc5385aaa 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -370,7 +370,9 @@ export function viewFromParams(params) { pitch_deg: number('pitch'), }, layers: state?.enabledLayerIds ?? [], - style: STYLES_BY_URL_NAME[params.get('style')] ?? null, + style: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style')) + ? STYLES_BY_URL_NAME[params.get('style')] + : null, map: VIEW_MAPS.includes(map) ? map : null, follow, annotations: annotationsFromParams(params), diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 818feb1ba..34299c498 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -126,3 +126,20 @@ test('annotations travel in the link, keeping only what the app draws', async () // Too long for a link: the view keeps them, the link leaves them out. assert.equal(viewToParams(many).has('an'), false); }); + +test('inherited and unknown style keys do not corrupt a restored view', () => { + for (const style of [ + 'constructor', + '__proto__', + 'toString', + 'hasOwnProperty', + 'unknown', + ]) { + const view = viewFromParams( + new URLSearchParams({ lat: '10', lon: '20', style, map: 'osm' }), + ); + assert.equal(view.style, null); + assert.equal(view.map, 'osm'); + assert.equal(view.camera.lat, 10); + } +});