mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
fix(security): refuse unsafe plugin ids, keep secrets private, validate request bodies (#643)
* fix(security): refuse unsafe plugin ids, keep secrets private, validate bodies - install_from_url and the registry install's manifest rename refuse a plugin id that is not a single safe name (no ../ out of plugins_dir). - Uninstall and config reset refuse core config sections and ids with path parts; uninstall of a plugin whose directory is gone still works. - separate_secrets checks a field's own x-secret marker before recursing, so object/array secrets no longer land in config.json. - Backup restore creates missing secrets/wifi/ytm files with mode 640; export skips non-object manifests and no longer collides on same-second exports. - SYSTEM_FONTS includes every bundled font from BUNDLED_FONTS. - Raw config/secrets saves and validate_request_json require a JSON object. - A blank max_dynamic_duration_seconds keeps the stored value; other values are validated to 30-1800 instead of raising a 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(security): validate the id before install_plugin moves anything; claim backup names atomically - install_plugin set aside plugins_dir / plugin_id before any id check, so "../x" moved a directory outside the plugins dir (the rollback moved it back, but only if the install path got that far) - two exports finishing in the same second could both see a free name and the later os.replace destroyed the first archive; the name is now claimed with O_EXCL before the archive is swapped in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -391,5 +391,55 @@ class TestReconcileEndpointPayload(unittest.TestCase):
|
||||
self._reconciler_instance.reconcile_state.assert_called_once_with(force=True)
|
||||
|
||||
|
||||
class TestNonPluginIdsAreRefused(unittest.TestCase):
|
||||
"""Uninstall and config reset key config.json by the request's plugin_id.
|
||||
|
||||
``{"plugin_id": "display"}`` deleted the core display section and
|
||||
answered success; a reset overwrote it with a plugin schema's defaults.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client, self.mod, _cleanup = _make_client()
|
||||
self.addCleanup(_cleanup)
|
||||
self.api_v3 = self.mod.api_v3
|
||||
self.api_v3.plugin_manager.plugin_manifests = {'thing': {'id': 'thing'}}
|
||||
|
||||
def _post(self, url, body):
|
||||
return self.client.post(url, data=json.dumps(body),
|
||||
content_type='application/json')
|
||||
|
||||
def test_uninstall_refuses_core_sections_and_traversal(self):
|
||||
for bad in ('display', 'schedule', 'plugin_system', 'github', '../x', 'a/b', 7):
|
||||
with self.subTest(plugin_id=bad):
|
||||
response = self._post('/api/v3/plugins/uninstall', {'plugin_id': bad})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.api_v3.config_manager.cleanup_plugin_config.assert_not_called()
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||
|
||||
def test_uninstall_still_cleans_a_plugin_whose_directory_is_gone(self):
|
||||
# Not among the discovered manifests, but a plugin-shaped id: the
|
||||
# config cleanup must still run.
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = True
|
||||
response = self._post('/api/v3/plugins/uninstall', {'plugin_id': 'gone-plugin'})
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.get_json())
|
||||
self.api_v3.config_manager.cleanup_plugin_config.assert_called_once_with(
|
||||
'gone-plugin', remove_secrets=True)
|
||||
|
||||
def test_secrets_only_core_key_is_allowed_when_a_plugin_has_that_id(self):
|
||||
self.api_v3.plugin_manager.plugin_manifests = {'youtube': {'id': 'youtube'}}
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = True
|
||||
response = self._post('/api/v3/plugins/uninstall', {'plugin_id': 'youtube'})
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.get_json())
|
||||
|
||||
def test_reset_refuses_core_sections(self):
|
||||
response = self._post('/api/v3/plugins/config/reset', {'plugin_id': 'display'})
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.api_v3.schema_manager.generate_default_config.assert_not_called()
|
||||
self.api_v3.config_manager.save_raw_file_content.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user