From 07ed67a793fa1e0615c2b8bec6379d58d46eadc7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 17:44:02 -0400 Subject: [PATCH] fix(extensions): keep runtime settings gated on owned storage (#5094) --- api/extensions.py | 23 ++++++++---- static/extension_settings.js | 47 ++++++++++++++++++------ static/panels.js | 2 +- tests/test_extension_hooks.py | 2 +- tests/test_extension_settings_runtime.py | 19 +++++++++- tests/test_extension_status_endpoint.py | 10 ++--- 6 files changed, 73 insertions(+), 30 deletions(-) diff --git a/api/extensions.py b/api/extensions.py index a135e17d..e822d1d4 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -7,6 +7,7 @@ It is disabled by default and never executes or fetches third-party URLs. import html import json +import math import logging import os import re @@ -559,26 +560,32 @@ def _normalize_enum_options(options: object) -> Optional[List[Dict[str, str]]]: normalized.append({"value": value, "label": label}) return normalized +_SETTINGS_DEFAULT_MISSING = object() + def _normalize_settings_default(field_type: str, raw_default: object, options: Optional[List[Dict[str, str]]] = None) -> Tuple[bool, object]: if field_type == "boolean": - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, False return (True, raw_default) if isinstance(raw_default, bool) else (False, None) if field_type == "string": - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, "" return (True, raw_default) if isinstance(raw_default, str) else (False, None) if field_type == "number": - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, 0 - return (True, raw_default) if isinstance(raw_default, (int, float)) and not isinstance(raw_default, bool) else (False, None) + return ( + (True, raw_default) + if isinstance(raw_default, (int, float)) and not isinstance(raw_default, bool) and math.isfinite(raw_default) + else (False, None) + ) if field_type == "integer": - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, 0 return (True, raw_default) if isinstance(raw_default, int) and not isinstance(raw_default, bool) else (False, None) if field_type == "enum" and options: values = [option["value"] for option in options] - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, values[0] return (True, raw_default) if isinstance(raw_default, str) and raw_default in values else (False, None) return False, None @@ -615,7 +622,7 @@ def _sanitize_settings_schema(entry: Dict[str, object]) -> List[Dict[str, object options = _normalize_enum_options(raw_field.get("options")) if field_type == "enum" else None if field_type == "enum" and options is None: continue - ok, default = _normalize_settings_default(field_type, raw_field.get("default"), options) + ok, default = _normalize_settings_default(field_type, raw_field.get("default", _SETTINGS_DEFAULT_MISSING), options) if not ok: continue if key in seen_keys: @@ -1545,7 +1552,7 @@ def inject_extension_tags(index_html: str) -> str: runtime_json = json.dumps(runtime_config, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c") runtime_tag = ( "" ).format(runtime_json) diff --git a/static/extension_settings.js b/static/extension_settings.js index 2315d274..406d9862 100644 --- a/static/extension_settings.js +++ b/static/extension_settings.js @@ -97,19 +97,28 @@ return fields; } - function configure(config){ - const list=Array.isArray(config&&config.extensions)?config.extensions:[]; - schemas.clear(); + function normalizeSchemas(rawRows){ + const list=Array.isArray(rawRows)?rawRows:[]; + const entries=[]; for(const entry of list){ const id=extensionId(entry&&entry.id); if(!id) continue; - schemas.set(id, { + const storageOwned=!!(entry&&entry.storage_owned); + entries.push({ id, name:text(entry&&entry.name,id), - storage_owned:!!(entry&&entry.storage_owned), - settings_schema:normalizeSchema(entry&&entry.settings_schema), + storage_owned:storageOwned, + settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[], }); } + return entries; + } + + function primeFromStatus(statusPayload){ + schemas.clear(); + for(const entry of normalizeSchemas(statusPayload&&statusPayload.extensions)){ + schemas.set(entry.id,entry); + } } function safeRead(key){ @@ -179,15 +188,20 @@ return overrides; } + function supportsSettings(meta){ + return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length); + } + function settingsForExtension(id){ const clean=extensionId(id); const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]}; - const schema=meta.settings_schema||[]; + const schema=supportsSettings(meta)?meta.settings_schema:[]; const key=settingsKey(clean); function current(){ return validate(schema,safeRead(key)).values; } function setAll(values){ + if(!supportsSettings(meta)) return {ok:false,values:current(),errors:{extension:'unsupported'}}; const checked=validate(schema,values); if(!checked.ok) return checked; const saved=safeWrite(key,overridesFromValues(schema,checked.values)); @@ -209,10 +223,12 @@ }, setAll, reset(){ + if(!supportsSettings(meta)) return current(); safeWrite(key,{}); return current(); }, clear(){ + if(!supportsSettings(meta)) return false; safeWrite(key,{}); return true; }, @@ -220,24 +236,31 @@ } function storageForExtension(id){ - const key=storageKey(id); + const clean=extensionId(id); + const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]}; + const allowed=!!meta.storage_owned; + const key=storageKey(clean); return { - getAll(){return safeRead(key);}, + getAll(){return allowed?safeRead(key):{};}, get(name,defaultValue){ + if(!allowed) return defaultValue; const data=safeRead(key); return Object.prototype.hasOwnProperty.call(data,name)?data[name]:defaultValue; }, set(name,value){ + if(!allowed) return false; const data=safeRead(key); data[name]=value; return safeWrite(key,data); }, remove(name){ + if(!allowed) return false; const data=safeRead(key); delete data[name]; return safeWrite(key,data); }, clear(){ + if(!allowed) return false; safeWrite(key,{}); return true; }, @@ -245,8 +268,8 @@ } const api={ - configure, - normalizeSchema, + normalizeSchemas, + primeFromStatus, namespaceForExtension, settingsForExtension, storageForExtension, @@ -260,5 +283,5 @@ window.hermesExt.storage=window.hermesExt.storage||{}; window.hermesExt.settings.forExtension=settingsForExtension; window.hermesExt.storage.forExtension=storageForExtension; - configure(window.__HERMES_EXTENSION_CONFIG__||{}); + primeFromStatus(window.__HERMES_EXTENSION_CONFIG__||{}); })(); diff --git a/static/panels.js b/static/panels.js index aa0ffed0..38035f88 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8170,7 +8170,7 @@ function _extensionEntryBadge(entry){ function _configureExtensionSettingsFromStatus(data){ if(!window.HermesExtensionSettings||!data||!Array.isArray(data.extensions)) return; - window.HermesExtensionSettings.configure({extensions:data.extensions}); + window.HermesExtensionSettings.primeFromStatus({extensions:data.extensions}); } function _extensionSettingsFieldHtml(field,value){ diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py index c6ef0031..a2371978 100644 --- a/tests/test_extension_hooks.py +++ b/tests/test_extension_hooks.py @@ -154,7 +154,7 @@ def test_extension_settings_runtime_config_injects_before_extension_scripts(tmp_ injected = inject_extension_tags("") assert "window.__HERMES_EXTENSION_CONFIG__" in injected - assert "window.HermesExtensionSettings.configure(window.__HERMES_EXTENSION_CONFIG__)" in injected + assert "window.HermesExtensionSettings.primeFromStatus(window.__HERMES_EXTENSION_CONFIG__)" in injected assert '"storage_owned":true' in injected assert '"settings_schema":[{"key":"flag","type":"boolean"' in injected assert injected.index("window.__HERMES_EXTENSION_CONFIG__") < injected.index("/extensions/settings-ok.js") diff --git a/tests/test_extension_settings_runtime.py b/tests/test_extension_settings_runtime.py index b59db337..92e54e11 100644 --- a/tests/test_extension_settings_runtime.py +++ b/tests/test_extension_settings_runtime.py @@ -42,7 +42,7 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): }}; eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8')); - window.HermesExtensionSettings.configure({{ + window.HermesExtensionSettings.primeFromStatus({{ extensions: [{{ id: 'demo.ext', name: 'Demo', @@ -75,6 +75,23 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); assert.strictEqual(storage.clear(), true); assert.strictEqual(store.has('hermes.ext.storage.demo.ext'), false); + + window.HermesExtensionSettings.primeFromStatus({{ + extensions: [{{ + id: 'denied.ext', + name: 'Denied', + storage_owned: false, + settings_schema: [{{key: 'flag', type: 'boolean', default: false}}], + }}] + }}); + + const deniedSettings = window.HermesExtensionSettings.settingsForExtension('denied.ext'); + assert.strictEqual(deniedSettings.setAll({{flag: true}}).ok, false); + assert.strictEqual(store.has('hermes.ext.settings.denied.ext'), false); + + const deniedStorage = window.HermesExtensionSettings.storageForExtension('denied.ext'); + assert.strictEqual(deniedStorage.set('note', 'blocked'), false); + assert.strictEqual(store.has('hermes.ext.storage.denied.ext'), false); """ ) _run_node(script) diff --git a/tests/test_extension_status_endpoint.py b/tests/test_extension_status_endpoint.py index 9d8243c3..0c0358d4 100644 --- a/tests/test_extension_status_endpoint.py +++ b/tests/test_extension_status_endpoint.py @@ -223,6 +223,8 @@ def test_extension_status_sanitizes_settings_schema_only_for_owned_storage(tmp_p {"key": "bad_enum", "type": "enum", "options": [{"label": "No value"}]}, {"key": "flag", "type": "boolean", "default": False}, {"key": "bad_default", "type": "integer", "default": 1.2}, + {"key": "null_flag", "type": "boolean", "default": None}, + {"key": "null_name", "type": "string", "default": None}, ], }, { @@ -243,13 +245,7 @@ def test_extension_status_sanitizes_settings_schema_only_for_owned_storage(tmp_p status = get_extension_status() by_id = {entry["id"]: entry for entry in status["extensions"]} assert by_id["settings-ok"]["storage_owned"] is True - assert [field["key"] for field in by_id["settings-ok"]["settings_schema"]] == [ - "flag", - "name", - "ratio", - "count", - "mode", - ] + assert [field["key"] for field in by_id["settings-ok"]["settings_schema"]] == ["flag", "name", "ratio", "count", "mode"] assert by_id["settings-ok"]["settings_schema"][0]["default"] is True assert by_id["settings-ok"]["settings_schema"][4]["options"] == [ {"value": "compact", "label": "Compact"},