Compare commits

...
Author SHA1 Message Date
SAPHID ad7be10cdf Validate extension bridge API versions before loading panels 2026-10-25 06:05:49 +00:00
5 changed files with 142 additions and 1 deletions

No files matched your search

+8
View File
@@ -34,11 +34,19 @@ The manifest ID must match the folder name:
"id": "your-id",
"name": "Your feature",
"version": "1.0.0",
"apiVersion": 1,
"description": "What the panel does.",
"entry": "index.html"
}
```
`apiVersion` selects the Frame Control bridge contract, independently of your
extension's own `version`. This app supports integer `1`; a missing field means
`1` for existing extensions. Unsupported versions appear as errors on Refresh,
and their assets are not served. Declare it in new extensions so a future API
change can fail clearly instead of running incompatible code. The runtime
`capabilities` reply below reports the bridge version too.
Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS,
JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder.
Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel
@@ -2,6 +2,7 @@
"id": "headset-glance",
"name": "Headset Glance",
"version": "1.0.0",
"apiVersion": 1,
"description": "Shows a small read-only summary of the connected Steam Frame.",
"entry": "index.html"
}
+107
View File
@@ -0,0 +1,107 @@
"""Extension compatibility and filesystem boundaries, without running plugin code."""
import sandbox # noqa: F401
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_extensions
class ExtensionFiles(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name) / 'extensions'
self.root.mkdir()
self.patch = patch.object(frame_extensions, 'root', return_value=self.root)
self.patch.start()
self.addCleanup(self.patch.stop)
def extension(self, name='test-panel', **fields):
folder = self.root / name
folder.mkdir(exist_ok=True)
manifest = {'id': name, 'name': 'Test panel', 'version': '1.0.0',
'description': 'A read-only panel', 'entry': 'index.html', **fields}
(folder / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
(folder / 'index.html').write_text('<script src="main.js"></script>', encoding='utf-8')
(folder / 'main.js').write_text('/* extension code */', encoding='utf-8')
return folder
def test_existing_and_explicit_v1_manifests_are_compatible(self):
self.extension('legacy')
self.extension('explicit', apiVersion=1)
result = frame_extensions.list_extensions()
self.assertEqual(result['errors'], [])
self.assertEqual([(x['id'], x['apiVersion']) for x in result['extensions']],
[('explicit', 1), ('legacy', 1)])
self.assertEqual(frame_extensions.asset('explicit', 'main.js'),
(b'/* extension code */', 'text/javascript; charset=utf-8'))
def test_unsupported_or_malformed_api_cannot_be_loaded_directly(self):
for version in (2, 0, -1, '1', None, True, 1.0, [], {}):
with self.subTest(apiVersion=version):
self.extension(apiVersion=version)
result = frame_extensions.list_extensions()
self.assertEqual(result['extensions'], [])
self.assertIn('unsupported apiVersion', result['errors'][0]['error'])
with self.assertRaisesRegex(ValueError, 'unsupported apiVersion'):
frame_extensions.asset('test-panel', 'index.html')
def test_one_incompatible_extension_does_not_hide_compatible_panels(self):
self.extension('future', apiVersion=2)
self.extension('working', apiVersion=1)
result = frame_extensions.list_extensions()
self.assertEqual([x['id'] for x in result['extensions']], ['working'])
self.assertEqual(result['errors'][0]['folder'], 'future')
def test_sample_is_discoverable_and_its_assets_are_served(self):
source = Path(__file__).resolve().parents[1] / 'examples/extensions/headset-glance'
shutil.copytree(source, self.root / source.name)
result = frame_extensions.list_extensions()
self.assertEqual(result['errors'], [])
self.assertEqual(result['extensions'][0]['id'], source.name)
for asset in ('index.html', 'main.js', 'style.css'):
self.assertEqual(frame_extensions.asset(source.name, asset)[0],
(source / asset).read_bytes())
def test_private_nested_and_traversal_paths_are_rejected(self):
self.extension()
for name in ('manifest.json', '../outside.js', 'sub/main.js', '.hidden.js'):
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
frame_extensions.asset('test-panel', name)
with self.assertRaises(FileNotFoundError):
frame_extensions.asset('../test-panel', 'main.js')
def test_oversized_assets_are_not_read(self):
folder = self.extension()
with (folder / 'large.js').open('wb') as output:
output.truncate(5 * 1024 * 1024 + 1)
with self.assertRaises(FileNotFoundError):
frame_extensions.asset('test-panel', 'large.js')
def test_symlinks_cannot_expose_files_outside_a_panel(self):
folder = self.extension()
outside = Path(self.tmp.name) / 'outside.js'
outside.write_text('private file', encoding='utf-8')
try:
(folder / 'escape.js').symlink_to(outside)
(self.root / 'alias').symlink_to(folder, target_is_directory=True)
except OSError:
self.skipTest('symlinks unavailable for this user')
with self.assertRaises(FileNotFoundError):
frame_extensions.asset('test-panel', 'escape.js')
with self.assertRaises(ValueError):
frame_extensions.asset('alias', 'main.js')
(folder / 'manifest.json').unlink()
(folder / 'manifest.json').symlink_to(outside)
with self.assertRaises(ValueError):
frame_extensions.asset('test-panel', 'main.js')
if __name__ == '__main__':
unittest.main()
+18
View File
@@ -126,6 +126,24 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(code, 400)
self.assertIn('experimental', body['error'])
def test_incompatible_extension_is_reported_and_assets_are_unavailable(self):
folder = Path(os.environ['FRAME_CONTROL_DATA_DIR']) / 'extensions' / 'future-panel'
folder.mkdir(parents=True, exist_ok=True)
(folder / 'manifest.json').write_text(json.dumps({'id': 'future-panel', 'name': 'Future',
'version': '1.0', 'description': 'Requires a newer API', 'apiVersion': 2}))
(folder / 'index.html').write_text('<p>Incompatible panel</p>')
try:
self.assertEqual(self.request('GET', '/api/extensions')[0], 403)
code, _, body = self.request('GET', '/api/extensions', headers={'X-Frame-UI': '1'})
self.assertEqual(code, 200)
data = json.loads(body)
self.assertNotIn('future-panel', [x['id'] for x in data['extensions']])
self.assertTrue(any(x['folder'] == 'future-panel' and 'apiVersion' in x['error']
for x in data['errors']))
self.assertEqual(self.request('GET', '/extension/future-panel/index.html')[0], 404)
finally:
shutil.rmtree(folder)
def test_headset_page_and_install_acknowledgement(self):
code, _, body = self.request('GET', '/headset')
self.assertEqual(code, 200)
+8 -1
View File
@@ -7,6 +7,7 @@ import frame_host
ID = re.compile(r"[a-z][a-z0-9-]{0,39}\Z")
API_VERSION = 1
ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8", ".json": "application/json",
@@ -27,6 +28,11 @@ def _manifest(folder):
data = json.loads(manifest.read_text(encoding="utf-8"))
if not isinstance(data, dict) or data.get("id") != folder.name:
raise ValueError("manifest id must match its folder")
# Older manifests predate this field and use bridge v1. Check again when
# serving assets, so an incompatible panel cannot bypass discovery.
api_version = data.get("apiVersion", 1)
if type(api_version) is not int or api_version != API_VERSION:
raise ValueError(f"unsupported apiVersion; this app supports {API_VERSION}")
for key, limit in (("name", 80), ("version", 40), ("description", 240)):
value = data.get(key)
if not isinstance(value, str) or not value.strip() or len(value) > limit:
@@ -34,7 +40,8 @@ def _manifest(folder):
entry = data.get("entry", "index.html")
if entry != "index.html" or (folder / entry).is_symlink() or not (folder / entry).is_file():
raise ValueError("entry must be a regular index.html")
return {key: data[key] for key in ("id", "name", "version", "description")}
return {**{key: data[key] for key in ("id", "name", "version", "description")},
"apiVersion": api_version}
def list_extensions():