Files
Netcatty/docs/plugin-platform/threat-model.md
T
ryan-wong-coder 7a47187e37 feat(plugins): add isolated plugin host runtime (#2303)
* feat(plugins): add isolated plugin host runtime

* test(plugins): build runtime dependencies before tests

* fix(plugins): harden runtime lifecycle and recovery

* fix(plugins): harden runtime extension boundaries

* fix(plugins): enforce runtime integrity boundaries

* fix(plugins): await utility process exit

* fix(plugins): harden utility process containment

* fix(plugins): close runtime recovery gaps

* fix(plugins): harden runtime lifecycle boundaries
2026-07-19 08:22:17 +08:00

11 KiB

Plugin platform threat model

Status: phase 2 internal runtime

Plugins are untrusted code. A useful plugin may parse terminal output, display content, call remote services, or ship a native companion; none of those needs imply trust in the author's code, update server, dependencies, or account.

This threat model records the security properties that the nine-stage platform must preserve. Phase 1 enforces package-format properties and defines the wire types. Phase 2 implements process isolation and lifecycle containment behind a development gate; later phases add capability mediation and distribution trust.

Protected assets

  • passwords, private keys, API keys, OTP values, and secret-setting plaintext;
  • terminal input while echo is disabled or authentication is in progress;
  • host addresses, usernames, notes, command history, and terminal output;
  • local files and filesystem metadata outside a plugin's data directory;
  • Netcatty renderer and main-process authority, Electron IPC, and Node APIs;
  • other plugins' packages, storage, logs, settings, and runtime messages;
  • cloud synchronization keys and provider credentials;
  • the integrity and availability of terminal sessions and the Netcatty process.

Adversaries

The design assumes any of the following may be hostile:

  • a locally installed plugin package;
  • a plugin dependency compromised after publication;
  • a publisher account or distribution server;
  • a companion executable;
  • remote content rendered or parsed by a plugin;
  • a malformed or intentionally expensive RPC peer;
  • an old package crafted to exploit a newer installer;
  • an update that requests broader permissions than the installed version.

The operating system, Electron sandbox, Netcatty application package, and user account are trusted. A machine already controlled by malware is outside the platform's protection boundary.

Package attacks

Archive traversal and aliasing

ZIP entries can target an absolute path, contain .., use backslashes on Windows, differ only by case, or exploit reserved device names. Extractors may then write outside staging or overwrite a different entry.

The contract CLI accepts one normalized POSIX spelling for each path and rejects exact, Unicode compatibility, and case-folded duplicates. The phase 2 installer must run the same validation before extraction and must extract only under a newly created staging directory.

Every archive entry uses the ZIP UTF-8 flag. Validation compares the raw central-directory name with the local-header name and also requires matching flags, compression method, CRC, and sizes, preventing different ZIP readers from validating and extracting different interpretations of one package.

Manifest decoding is fatal UTF-8 on both source directories and archives. Malformed byte sequences cannot be normalized differently by separate package inspection and installation paths. The packer also binds the exact validated manifest bytes to the scanned package entry with byte length and SHA-256, then rechecks the entry while writing. A source manifest changed between semantic validation and archive creation is rejected instead of inheriting the decision made for older bytes. Every source hash read enforces the file budget incrementally, and the writer refuses the first byte beyond the scanned size. Concurrent file growth therefore fails before it can turn validation or packaging into unbounded disk I/O. Installation retains the validated archive and binds it to both the archived byte digest and a canonical logical-content digest. The runtime gate rescans the installed directory immediately before placement and rejects changed, missing, or injected files before plugin code starts. This is an integrity and recovery boundary for corruption or unintended local modification; it is not a claim that Netcatty can defend against an already-compromised same-user operating system account.

A symbolic link can make an apparently safe relative path resolve outside the package. An executable bit can also hide an undeclared native program among ordinary assets.

Packages cannot contain symbolic links. Executable files must appear in a platform-specific companionExecutables[].variants entry; every variant binds its package path, supported target platforms, and content SHA-256. A later signature covers both the manifest and deterministic archive.

Resource exhaustion

Small compressed inputs can expand into very large outputs, or contain huge file counts and path names. Both source packing and archive validation impose limits on archive bytes, expanded bytes, individual files, entry count, manifest bytes, and path bytes. The installer must enforce limits while streaming, before committing package metadata.

A byte limit alone does not bound parser work: a small manifest can contain thousands of nested arrays or a very large number of tiny JSON values. Manifest validation therefore applies explicit depth and node-count budgets before the recursive JSON Schema validator runs. Exceeding either budget is an ordinary package validation failure, not an uncaught stack overflow.

Runtime attacks and capability controls

Renderer escape

Normal plugins run in a sandboxed Chromium context without Node, contextIsolation bypasses, arbitrary Electron IPC, or direct access to the application React tree and xterm instance. Plugin documents use a dedicated protocol with a restrictive Content Security Policy. The bootstrap removes direct fetch, socket, WebRTC, transport and worker globals before importing plugin code. Its isolated session is also offline behind an unreachable proxy, with non-proxied WebRTC disabled, so a fresh iframe global cannot restore network authority. Network access can only be added later through a checked host broker. Privileged plugins run in separate utility processes rather than the Netcatty main process.

Confused deputy

A plugin may ask the host to act on another plugin, terminal, host, file, or network origin. Every request must carry runtime identity assigned by the host; the host must ignore plugin-supplied identity fields. Capability handlers check the sender, active operation, declared permission, user grant, and resource scope before using application authority.

Permission laundering

A plugin could call a broadly capable built-in command or another plugin to avoid its own permission check. Public commands therefore retain caller identity, and capability checks occur at the final privileged boundary rather than only in UI or command registration.

Secret exfiltration

Secret values are never ordinary settings or JSON-RPC results. The credential broker uses operation-bound, single-use leases. Terminal sensitive-input mode bypasses third-party hooks unconditionally. Logs, diagnostics, synchronization, and crash reports redact secret fields before persistence. The SDK secret store returns an opaque SecretRef, never stored plaintext. The reference is not treated as a bearer capability: every privileged use must revalidate the calling plugin, resource ownership, permission, and operation.

Denial of service

RPC requests have deadlines and cancellation IDs. Streams have explicit byte windows. The phase-2 supervisor enforces activation and shutdown deadlines, bounded pending work, bounded logs, and crash quarantine. Later permission and terminal phases add CPU/memory/rate quotas and interceptor circuit breakers. A failed plugin must not stop unrelated plugins or terminal sessions.

RPC control JSON is capped at 1 MiB. Stream frames use a separate 24 MiB JSON budget only to carry a 16 MiB JSON/base64 chunk; transferred buffers are still validated against the 16 MiB chunk limit. This keeps large data on the credit-controlled path and prevents a single string from bypassing structural depth and node limits.

Runtime decoders must apply exact schemas for reserved methods instead of accepting malformed reserved messages as generic RPC. Transferable stream data is brand-checked through the native ArrayBuffer internal slot; an object that only spoofs Symbol.toStringTag or byteLength is not a transferable buffer. JSON serialization reads validated own data properties directly and never executes inherited toJSON() hooks supplied through a hostile prototype. All RPC and stream JSON values use the same depth and node-count budgets, plus their surface-specific byte budgets, so a validly framed peer cannot consume an unbounded call stack, validation loop, or retained control-message allocation. The stdio decoder also consumes fragmented byte queues by advancing an index rather than repeatedly shifting arrays. Small fragments are copied into bounded slabs, preventing both quadratic work and per-byte object retention when a peer delivers a large frame in very small chunks. Copying also prevents a caller from mutating queued Node.js Buffer storage after push() returns.

Update substitution and rollback

The final distribution stage uses signed repository metadata, publisher signatures, staged health checks, atomic version switching, and rollback to the last healthy version. Permission, API, or trust-level increases require a new user decision; an existing grant is not silently widened.

Security invariants

The platform is not ready for public enablement unless all of these hold:

  1. Ordinary plugins have no ambient Node, Electron, filesystem, network, React, or xterm authority.
  2. A declaration is not a grant, and a grant is limited to its declared resource and lifetime.
  3. No renderer means interactive permission requests fail closed.
  4. Password and no-echo input never reaches plugin hooks.
  5. The package installed is the package validated and, later, signed.
  6. A plugin cannot address another plugin's storage or runtime by changing an identifier in its request.
  7. Plugin failure is contained and the terminal data path fails open only where disclosure is impossible.
  8. Secrets never enter manifests, package defaults, logs, diagnostics, or cloud synchronization sidecars.
  9. Unknown newer protocol versions fail closed at privileged boundaries.
  10. Disabling every plugin restores the unextended Netcatty behavior and does not impose more than the agreed terminal throughput budget.

Phase 1 baseline

The first phase did not load plugin code. It introduced the SDK interfaces, committed Schema bundle, deterministic package format and package validation so the runtime boundary could be reviewed separately. Phase 2 now consumes those artifacts without changing the public contract version.

Phase 2 runtime boundary

Phase 2 implements package installation, isolated browser and utility-process runtimes, bounded RPC/streams, lifecycle deadlines and crash quarantine. These paths remain disabled unless NETCATTY_PLUGIN_DEV=1 is set. The browser path has no ambient Node, Electron, filesystem or network authority. The Node path is explicitly an advanced runtime and is not considered safe for public enablement until later phases add permission enforcement and signed trust policy.

The phase does not synthesize grants or secret storage. Calls that would need those facilities fail closed, preserving the distinction between a manifest declaration, a future user grant, and actual host authority.