Files
rmyndharis--openwa/docs/23-plugin-sandboxing.md
wehub-resource-sync 4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:24:08 +08:00

84 lines
5.1 KiB
Markdown

# 23 — Plugin Sandboxing
OpenWA runs **untrusted plugins** (anything loaded from the plugins directory) in an isolated worker
thread, separate from first-party built-ins (engines, bundled extensions) which run in-process. This
page describes the security model honestly — what the sandbox guarantees and, just as important, what
it does not — and what changes for plugin authors.
## Trust tiers
| Tier | Examples | Runs | Capabilities |
|------|----------|------|--------------|
| **Built-in (trusted)** | engines (whatsapp-web.js, baileys), bundled extensions (auto-reply, translation) | in-process | direct, full speed |
| **Untrusted** | anything in the plugins directory | in a `worker_thread` | only via the host-validated bridge |
The loader routes by tier automatically: a plugin registered programmatically is built-in; one loaded
from disk is untrusted and sandboxed.
## What the sandbox guarantees
- **No host-object access.** The worker runs in its own V8 context. It receives no reference to the
loader, the engine, the database, `MessageService`, or any host singleton. Its only channel out is
a `MessagePort`.
- **Capability mediation.** A plugin can touch WhatsApp / the database **only** through
`ctx.messages.*` / `ctx.engine.*` / `ctx.storage.*`, which round-trip to the host. The host runs
each call through the same permission + session-scope checks an in-process plugin gets
(`assertPermission` / declared `sessions`), so a sandboxed plugin can never exceed its declared
manifest permissions. Verbs are allowlisted — a worker cannot invoke an arbitrary host method.
- **Hook safety.** Hook handlers run in the worker and are dispatched with a **time budget**
(`SANDBOX_HOOK_TIMEOUT_MS`, default 5s). A slow or wedged handler is skipped (`continue: true`) so
it can never stall the host's hook chain.
- **Resource & runaway containment.** Each worker has a heap cap (`maxOldGenerationSizeMb`, default
256). An OOM terminates the worker, not the host. A wedged **lifecycle** call (load/unload) times out
and tears the worker down, and a crash rejects its in-flight calls — the host survives either way. A
runaway **hook** handler (e.g. an infinite synchronous loop) is skipped on the hook timeout so it can't
stall the host's hook chain, but the worker keeps running it (pegging a core) until the plugin is
reloaded or hits the heap cap — it is contained to its own thread, not instantly force-killed.
## What the sandbox does NOT guarantee
> A `worker_thread` is a separate V8 context **in the same OS process, under the same user**. It is
> not an OS-level sandbox.
A worker still has access to Node built-ins — `require('fs')`, `process`, network sockets — and runs
as the same uid as OpenWA. The sandbox therefore does **not**, by itself, stop a malicious plugin
from reading files the OpenWA process can read or making outbound network connections. It protects
the *integrity* of the host (no host-object compromise, contained faults, mediated capabilities) — not
the *confidentiality* of the host filesystem against deliberate Node-builtin abuse.
For genuinely untrusted, third-party plugins, combine the sandbox with **OS-level containment**:
- **Run OpenWA in its container** (the shipped Docker image already runs read-only rootfs, non-root,
and `cap_drop: ALL`), which bounds what any plugin's `fs`/network access can reach.
- Until a marketplace exists, the standing guidance remains: **install only plugins you trust.**
A stronger isolation variant (child process with Node's permission model, or an `isolated-vm`) is a
possible future enhancement for maximum-hostility deployments; the transport is already abstracted
behind a channel interface so it can slot in without touching plugin code.
## What changes for plugin authors
Sandboxed plugins keep the same `IPlugin` shape (`onLoad`/`onEnable`/`onDisable`/`onUnload`,
`ctx.messages`, `ctx.engine`, `ctx.storage`, `ctx.registerHook`), with these rules:
1. **Capability calls are remote.** They were already `async`; they now genuinely cross a thread
boundary. Nothing to change in usage.
2. **Only serializable data crosses.** Hook payloads and capability args/results must be
structured-clone-safe — plain objects, arrays, primitives, `Date`, typed arrays. **No functions,
no class instances with methods, no live references.**
3. **No ambient host access.** `require('fs')`/`process` etc. still exist in the worker but must not
be relied on as a capability — anything the plugin legitimately needs should be a declared
capability, and OS containment may block direct access.
4. **Declare your permissions.** A capability call is denied unless the manifest declares it
(`messages:send`, `engine:read`). See [19 — Plugin Architecture](./19-plugin-architecture.md).
This is a **breaking change for third-party plugin authoring** and ships in a minor release. Built-in
plugins are unaffected (they remain in-process).
## Configuration
| Env / constant | Default | Purpose |
|----------------|---------|---------|
| `maxOldGenerationSizeMb` (worker) | 256 | per-plugin heap cap; OOM kills the worker, not the host |
| hook handler timeout | 5000 ms | budget before a sandboxed hook handler is skipped |