Skip to content

fix(security): enforce declared permissions on every context facade, encrypt secret settings at rest, harden marketplace installs - #42

Merged
tajanthind merged 1 commit into
mainfrom
fix/security-enforced-permissions-and-secret-at-rest
Sep 4, 2026
Merged

tajanthind merged 1 commit into
mainfrom
fix/security-enforced-permissions-and-secret-at-rest

Conversation

@tajanthind

Copy link
Copy Markdown
Owner

Security review — five fixes, one PR.

1. Manifest permissions gate EVERY context facade (was: only FSContext)

  • New PermissionChecker(plugin_id, permissions) shared by all facades.
  • LibraryContext now requires library:read (all read methods) / library:write (all write methods).
  • SettingsContext requires settings.
  • FSContext requires filesystem:downloads / filesystem:music.
  • PluginContext.http is None unless the manifest declares network — undeclared outbound HTTP is impossible via the context.
  • Fail closed: a facade constructed without an explicit checker permits nothing. Core call sites use core_context_checker().

2. Marketplace install hardening (plugins/registry.py)

  • sha256 in the repo index is now mandatory — an entry without it is refused (previously optional).
  • New _safe_extract(): every archive member is resolved against the plugin dir; any member escaping it (zip-slip) raises RegistryError before extraction.

3. Secret settings encrypted at rest (plugins/secret_store.py, models.py, api.py, context.py)

  • Manifest settings_schema "type": "secret" fields are Fernet-encrypted before storage; key lives in CONFIG_DIR/plugin_secret.key (0600), outside the DB.
  • PluginSetting.secret column + migration; boot backfill encrypts pre-existing plaintext secret rows (idempotent — Fernet tokens skipped).
  • Decrypted only on read via context.settings / settings API.

4. O(1) _loaded_for_instance (plugins/manager.py)

  • Reverse index id(instance) -> plugin_id maintained under the manager lock; replaces the linear _plugins.values() scan on every invoke_provider().

5. Cooperative lock (plugins/manager.py)

  • gevent.lock.RLock (threading RLock fallback outside gevent) replaces threading.Lock.

Supporting changes

  • Bundled + example plugin manifests now declare the permissions they actually use (byte-identical with the fnack-plugins catalog, already pushed as 4f0bc4b with release zips clobbered).
  • docs/plugins/AUTHORING.md documents the enforced permission contract, secret-at-rest, mandatory sha256 / zip-slip rejection, and the honest limitation that a plugin importing requests directly bypasses context.http.
  • New arch test tests/architecture/test_plugin_permissions_and_secrets.py (permission gating, secret encryption at rest, sha256/zip-slip rejection).
  • cryptography>=42.0 added to requirements.

Verification

  • Full architecture suite: 24/24 pass; smoke test passes.
  • Live container E2E: installed fnack.acoustid from the marketplace, set its api_key → DB row has secret=1 and a Fernet token (not plaintext); GET returns plaintext; survives container restart with no double-encryption.

…ts at rest, harden marketplace installs

Security review — five fixes:

1. Manifest permissions now gate EVERY context facade, not just FSContext.
   - PermissionChecker(plugin_id, permissions) shared by LibraryContext
     (library:read / library:write), SettingsContext ("settings"),
     FSContext (filesystem:downloads / filesystem:music), and
     PluginContext.http ("network" — undeclared means http is None).
   - Fail closed: a facade built without an explicit checker permits
     nothing; core code uses core_context_checker() at its two call sites.
2. Marketplace install hardening:
   - sha256 is now MANDATORY — an index entry without it is refused
     (fail closed; previously it was optional).
   - _safe_extract() resolves every archive member against the plugin
     directory and refuses any member that would escape it (zip-slip).
3. Manifest "type": "secret" settings are encrypted at rest (Fernet;
   key in CONFIG_DIR/plugin_secret.key, mode 0600, outside the DB):
   - plugins/secret_store.py (encrypt/decrypt/looks_encrypted).
   - PluginSetting.secret column + migration.
   - SettingsContext and the settings API encrypt on write, decrypt on
     read; boot backfill encrypts pre-existing plaintext secret rows
     (idempotent).
4. PluginManager._loaded_for_instance is now an O(1) reverse index
   id(instance) -> plugin_id instead of a linear scan per invoke_provider.
5. PluginManager lock switched from threading.Lock to cooperative
   gevent.lock.RLock (threading fallback outside gevent contexts).

Bundled + example plugin manifests declare the permissions they actually
use (kept byte-identical with the fnack-plugins catalog); AUTHORING.md
documents the enforced permission contract, secret-at-rest, mandatory
sha256 / zip-slip rejection, and the direct-import limitation.
@tajanthind
tajanthind merged commit b07916b into main Sep 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant