v0.11.0
Added
- All five SDKs gain poll sending, batch profile pictures, and status media downloads, and their
webhook filter types now match the server's polymorphic contract. NewsendPoll,
profilePictures(batch lookup returning{id: url|null}), and statusmedia(binary bytes +
content type) methods in the JavaScript, Python, Go, PHP, and Java SDKs — the binary path is a
newbyte[]end-to-end transport in Java. The webhook filtervalueis now
string | string[] | booleanwithcaseSensitivesupport in the typed SDKs (Go already
matched),mentionsis accepted on send-text everywhere, and Java/Go non-JSON 2xx responses
behave like the other three SDKs (raw text / verbatim bytes instead of leaking parser errors).
(#947)
Fixed
-
S3 storage no longer silently stays on the local fallback after a boot-time miss, the Baileys engine no
longer reports READY while its socket is in reconnect backoff, and the dashboard no longer serves cached
state across logout. Four correctness fixes: if S3 was unreachable at boot,StorageServicefell back
to local and never re-probed, so after S3 recovered writes kept landing in./data/mediawhile reads hit
S3NoSuchKey— a silent split-brain; it now re-probes on a 60s interval (S3_REPROBE_INTERVAL_MS),
WARNs while degraded, self-clears the timer on recovery, and the recovery is strictly one-way (false→true)
so a transient flake cannot drop a healthy deployment to local; while S3 is active, enumeration and the
storage totals also cover the local fallback directory and deletes go to both backends, so bytes written
during an outage stay visible to listings and reclaimable instead of being stranded. When the Baileys
socket entered reconnect backoff after a transient close, the adapter kept reportingREADYfor up to the
60s backoff cap while the socket was dead, soprobeLiveness()returned true andensureReady()let
sends through against a dead socket — it now setsINITIALIZINGimmediately on the transient close
(matching the whatsapp-web.js engine) and restoresREADYon the nextopen. The dashboard's React Query
cache survived logout (a re-login showed the previous user's sessions/messages), the startup re-validation
did not checkres.ok(a 401/revoked key kept the cached role), the WebSocket client dropped
subscribed/errorframes (a scoped-keyFORBIDDEN_SESSIONwas invisible), andmarkChatReadfired
per call without a debounce; all four are fixed (queryClient.clear()on logout, state-machine
validation, frame routing, a 750ms trailing coalescer). Separately, thetecnativa/docker-socket-proxy
compose entry setDELETE: 1which is dead config on the pinned v0.4.2 image (its method gate admits on
POSTalone), so the SECURITY.md "least-privilege" claim oversold the boundary — the dead directive is
dropped, the README/SECURITY now document the real threat model (a compromised API container withPOST=1
is host-root-equivalent), and the managed-profile teardown switched fromremove({ v: true })(which
discarded anonymous volumes the disable/re-enable flow assumes) to a stop-only path that reports
per-profile errors honestly instead of claiming success on a 403. (#945, #944, #938, #934) -
The data export/import path and the backup/restore scripts no longer silently lose data, crash
the process, or write databases the app never opens. Five integrity gaps in the export/import
endpoints (GET /api/infra/export-data,POST /api/infra/import-data, storage archive import)
are closed: each optional-table read was wrapped in a blindtry/catchthat debug-logged any
failure as "empty table", so a lock/IO/timeout produced an HTTP 200 "complete" backup that the
import then treated as authoritative —DELETEing the rows the export never captured (now
rethrows everything except a genuine missing-table error, with the skipped tables listed in a new
skippedTablesresponse field); the Postgresbody_tstsvector leaked into message exports via
SELECT *(now stripped); thestatus_updatestable was missing from the backup flow entirely
despite the documented "all Data DB tables" contract (now exported/imported); a corrupt gzip or
mid-read I/O failure onimportFromStreamraised an unhandlederrorevent that killed the
server, since neither the gunzip nor the input stream had an error listener (both now fail the
import promise and the controller maps it to 400); and the in-memorylid -> phonemirror was
left stale after a committed restore (now reloaded post-commit). The pre-flight now refuses a
full-replace restore that would orphan a running engine with 409 Conflict listing the affected
sessions — passstopOrphans: trueto stop them inside the request (preferred), orforce: true
to proceed and leave them running until restart (response carriesrestartRequired/
orphanedEngines/stoppedOrphanEngines/failedOrphanEngines). Separately,
scripts/backup.shandscripts/restore.shresolved database files fromOPENWA_DATA_DIR,
which the app never reads — it opensMAIN_DATABASE_NAME/DATABASE_NAMEwith fixed./data
defaults — so a custom DB env path made backup exit 0 archiving nothing and restore write
databases the app never opened (next boot: fresh-empty, new API keys, new master key). Both
scripts now resolve DB paths exactly like the app, fail hard (non-zero + clear message) on a
missing source or an incomplete archive, and the production image shipssqlite3so in-container
backups take online-consistent snapshots viasqlite3 .backup(aCONSISTENCY-WARNINGmarker +
restore--strictgate cover the CLI-absent fallback). A newShell scriptsCI job runs the
backup/restore smoke suite and shellcheck on every change. Breaking (behavior):backup.sh
now exits non-zero where it previously reported success over an empty/partial archive — scheduled
jobs pointed at wrong paths will start alerting — and import/export responses gain additive fields
only. (#927, #926) -
Webhook delivery records now match what actually happened on the wire, and the Redis-backed
rate limiter no longer stalls or double-counts during a Redis outage. On the webhook path, a
lastTriggeredAtbookkeeping update that threw after a 2xx receiver response used to flip the
outcome to failed — BullMQ/direct retry then re-POSTed an already-delivered event and filed a
dead-letter row for a successful delivery (the update is now isolated in its own try/catch and
the success outcome stands); parked and in-flight direct deliveries used to vanish on shutdown
with no record (the dispatch limiter now supportsclose(),onModuleDestroydead-letters
parked deliveries, drains in-flight up toWEBHOOK_SHUTDOWN_DRAIN_MS, and logs anything still
running as abandoned); a BullMQ job that stalled twice failed without callingprocess()and so
bypassed the DLQ/metric/hook channels (a new@OnWorkerEvent('failed')handler records the same
dead-letter row for the stall sentinel); and thewebhook:beforehook could rewrite
event/sessionId/timestamp, making the signed body diverge from theX-OpenWA-*headers
(all identity fields are now re-asserted, and the serialized body is capped at
WEBHOOK_MAX_PAYLOAD_BYTES, default 1 MiB). On the throttler path, the ioredis client behind the
fail-open Redis storage was built with defaultenableOfflineQueue: trueand no
onModuleDestroy/error listener, so a Redis outage queued every command (~8s per increment × 3
throttlers = ~24s+ per request) before the fail-open path engaged, resent unfulfilledINCR
evals after reconnect (double-counting, sinceINCRis not idempotent), and could hang
app.close()on a half-open socket. The client is now built fail-fast
(enableOfflineQueue: false,autoResendUnfulfilledCommands: false,commandTimeout: 2000ms,
maxRetriesPerRequest: null), owns its lifecycle via a structured error listener and a bounded
quit()/disconnect()drain, and a startup WARN fires whenWEBHOOK_SHUTDOWN_DRAIN_MS(default
5s) is shorter thanWEBHOOK_TIMEOUT(default 10s) — the cross that silently truncated in-flight
deliveries on shutdown. Breaking (behavior): legitimately huge webhook payloads above 1 MiB
are now recorded as undelivered rather than sent; a Redis answering slower than 2s degrades to
fail-open (allow) instead of eventually answering. (#933, #932) -
A session that exhausts its reconnect budget no longer leaks a concurrency slot or wedge its
restart. When a session with a finiteconfig.maxReconnectAttemptsreached the terminal FAILED
branch ofscheduleReconnect, it wrote FAILED but left the dead engine in the in-process map —
unlike the sister terminal path inonError, which evicts for the explicit reason that a leftover
engine holds a concurrency slot and makes the nextstart()reject the session as "already started".
The reconnect-exhaustion path now evicts the dead engine the same wayonErrordoes (guarded on
the engine being present, since theexecuteReconnect-catch caller already evicted its half-built
engine). Only affects sessions with an explicit finitemaxReconnectAttempts(the default is
unlimited). -
cancelBatchno longer overwrites a terminally FAILED batch to CANCELLED. The cancel guard
checked only COMPLETED and CANCELLED, so a post-completion cancel on astopOnError-failed batch
(or any batch that resolved to FAILED) relabelled it CANCELLED, rewroteprogress.cancelled/
pending, and masked the real delivery failures and themessage:failedevents that had already
fired. FAILED is now in the terminal-status guard, so each terminal status stays exclusive. -
The ingress queue now actually queues when
QUEUE_ENABLED=true, persisted ingress events are reconciled
after silent-loss windows, and disabling one instance no longer tears down an enabled sibling sharing its
session scope. The IntegrationModule never imported the QueueModule, so the@Optional()ingress queue
injection was alwaysundefinedand every delivery dispatched inline despite the operator-facing queued
contract (fast-ack, retries, ordering, DLQ) — the module now imports it conditionally (mirroring the
webhook module) and the boot fail-fast tripwire crashes startup loudly if the wiring ever regresses.
ingress_eventswas a dedup log but never a durability handle: a crash between persist and dispatch, a
failed fire-and-forget response route, or a swallowed inline failure lost the event while the provider's
honest retry deduped to a no-op. Rows now carrydispatchState/dispatchAttempts/lastDispatchAt
(legacy rows excluded), the enqueue path records outcomes, and a 60s reconciler (INGRESS_RECONCILE_*)
replays stuck deliveries with their original delivery id, retiring them terminally (plus a DLQ row) after
five attempts instead of looping forever; a replay first re-applies the eligibility check the live path
makes at the door, so an instance disabled or deleted after the persist is never handed the event.
Separately, disabling, deleting, or re-scoping an instance unconditionally stripped its session from the
plugin'sactiveSessionsand wiped the per-session config even when another ENABLED instance shared that
scope; the teardown now checks for an enabled sibling first, and concrete activation preserves'*'while
a wildcard sibling is still enabled. (#921, #924, #922) -
Search-provider plugins now receive the finalized state of every outbound message, and two
runnable cURL examples in the API collection no longer 404. Themessage:persistedhook fired
only for the initial PENDING row: the finalized SENT/FAILED save emitted nothing, and the
echo-merge that drops the redundant pending row left a ghost document no provider could correlate
(the pending row has nowaMessageId). The hook now re-fires on every persisted transition as an
upsert keyed by the row id — SENT with the engine id, FAILED on send failure, the surviving row
after an echo-won merge — and a newmessage:deletedevent fires for the dropped row, with
shallow-snapshot payloads so fire-and-forget delivery sees the state at emission time. Separately,
the history and reactions cURLs indocs/07-api-collection.mdmissed the/messagessegment and
returned 404 when copied verbatim. (#919, #910) -
Restarting a session no longer SIGKILLs the live browser of a prefix-named sibling. The
orphan-Chromium sweep matched the--openwa-session=<id>marker as a plain substring of theps
command line, so restarting sessionsaleskilled the running browser of siblingsales2. The
marker is now matched token-exactly (whitespace/string-boundary delimited, session id
regex-escaped). (#923) -
Bootstrap and shutdown now fail loudly instead of coasting, and request metrics see the
traffic guards reject. A failed HTTP bind (e.g.EADDRINUSE) after full Nest init used to
leave a port-less zombie driving WhatsApp sessions withexitCodeset but no exit; the process
now runs a bounded best-effort teardown and exits 1. A teardown that rejects during SIGTERM
handling similarly ended inexit 0— it now exits 1 (a hung teardown is still bounded by the
second-signal force-exit). The RED metrics also stopped at the interceptor, so 401/403/429/404
rejections never reachedhttp_requests_total; a boundary middleware now records them (unmatched
routes folded into a(unmatched)label) with a claim flag preventing double-counts on handled
requests. Separately, astart()that completed after its session row was deleted re-created the
just-purged auth dir and emitted QR/status events for the dead session; the retirement guard now
re-purges both engines' auth dirs (gated by a by-name re-check so delete+recreate keeps its fresh
dir) on the start and reconnect paths alike. (#949, #961, #952) -
Infra config writes are section-scoped, mode-aware, and strictly coerced, and the bootstrap config
guards cover the paths the app actually uses. Saving config used to clobber sections absent from the
payload and carry stale secrets across mode flips (built-inminioadmin/openwacredentials surviving
into an external-blank config, which the production secret guard then rejected at the next boot — a
crash-loop with the dashboard dead); writes now merge per key, drop the old mode's secrets on a
builtin→external flip (S3_ENDPOINTclearable), keep a re-keyed built-in Postgres password instead of
re-stamping the bundled default on every save (it is reset only when switching in from an external
database), and re-run the production default-secret assertion at save time (400 before anything hits
disk). That assertion judges the configuration the next boot would actually see: a value supplied by the
host or orchestrator (composeenvironment:) takes precedence over the saved file just as it does at
boot, with a blank forward counting as unset, while values the loader itself merged in from.envand
data/.env.generateddo not — so a save is judged on the config being written rather than on the one it
replaces. Controller-local DTOs moved todto/with strict coercion, so a form-encoded'false'can no
longer persist as'true'for the boolean flags. The SQLite main/data path-collision guard now resolves
paths exactly like the runtime (and also fires for CLI migration invocations),REDIS_ENABLEDis strictly
validated (a typo fails boot instead of silently downgrading the throttler and cache to in-memory), the
numeric env checks accept plain decimal digits only — an exponent or hex spelling (1e6,0x100) now
fails boot instead of validating as one number while the app, which reads these back withparseInt,
configures another —WEBHOOK_MAX_PAYLOAD_BYTESis validated positive-only (a0cap would reject every
webhook dispatch), and a boot sweep deletesstorage-export-*archives orphaned by a restart after
STORAGE_EXPORT_SWEEP_MAX_AGE_MS(default 24h). Numeric knobs whose0is a documented opt-out now read
a blank or whitespace-only value — exactly what a compose${KEY:-}forward renders — as unset and fall
back to their default instead of parsing it as0: a blankINGRESS_INSTANCE_LIMITused to mean a limit
of zero, which 429'd every inbound ingress webhook, and a blankBULK_MAX_CONCURRENT_BATCHESsilently
meant unlimited. Breaking (behavior): partial config payloads now preserve omitted fields instead of
resetting them (the dashboard's full payload is byte-identical), and non-canonicalREDIS_ENABLEDvalues
— along with numeric variables spelled in exponent or hex notation — now fail boot. (#946, #960) -
Bulk batches re-validate rendered payloads and cancel terminally, and outbound rows stuck PENDING after
a crash are reaped. Media presence/size was only checked at batch creation, so{{variables}}and the
message:sendinghook could grow a payload past the cap afterwards — every item is now re-validated
post-gate (violations fail the item, honoringstopOnError). A cancel landing before the first item could
be fully overwritten by the cadence/final writes and send the whole batch anyway; all status transitions
are now DB-conditional on the current status, so CANCELLED stays terminal (only exact duplicate entries
are deduped first-wins at creation). Rows left PENDING when the process dies between the pre-send save and
the final save previously stayed PENDING forever (in the DB and in plugin search indexes); a periodic
reaper (MESSAGE_REAPER_INTERVAL_MS/_GRACE_MS/_BATCH_SIZE, defaults 10min/1h/50) marks them FAILED
with areapedAtmarker and re-emitsmessage:persistedso hook-driven providers reconcile. The reap
write is itself conditional on the row still being PENDING, so a send that resolves between the sweep's
scan and its write keeps its own SENT outcome and engine message id. (#955, #958) -
API-key usage accounting no longer loses deltas, and the queue dashboard leaves an audit trail. A
failedpendingUsagesave dropped the accumulated delta and 500'd the request (the delta now merges back
and the request stands); nothing flushed on shutdown, so the last window's usage vanished (a bounded
onModuleDestroyflush now writes it). Bull Board rejected requests with no audit record and
queue-mutating UI actions left none either — 401/403s now write the standard failed-auth row (429s are
left to the limiter), and authenticated non-GET requests write a newQUEUE_BOARD_MUTATEDaction with
actor/method/path. The bootstrapdata/.api-keyfile and boot banner pointed at keys after
rotation/revoke; the file is now removed when its key no longer validates (checked by hash at boot, and on
the revoke/delete paths) — unless a key row still carries the file's prefix, which means the hash miss
came from a changedAPI_KEY_PEPPERrather than a dead key: the file then survives and a WARN names the
repair (restore the original pepper, or rotate the key). (#963) -
Group participant batches, template renders, and status media are bounded and reconciled, and the
Postgres FTS probe reads the active schema. Participant arrays accept at most 256 entries (the engine
works them serially) and a partially-failing settings patch now names the failed field instead of silently
half-applying. Rendered templates are capped atTEMPLATE_RENDER_MAX_CHARS(default 64 KiB) instead of
growing unbounded. Status media used to write the file before its row (a crash between them left a
permanent orphan) and purge deleted the row even when the file delete failed; ingest is now row-first with
write_failedrecorded whenever the media ends up unattached — a failed file write or a failed post-write
row update — so thestatus.receivedwebhook always names the reason for a missing attachment, purge
keeps the row for the next sweep when the file delete fails (and reports nothing purged when that is every
expired row), and a periodic sweep reclaimsstatuses/files no row references after a grace period,
walking that prefix in the store directly so a store larger than a single listing leaves no orphan
stranded. The search FTS probe queriedinformation_schemaunscoped, so aPOSTGRES_SCHEMAdeployment
could read a namesake table in another schema and pick the wrong availability posture in degraded states;
it now resolves the table through the sessionsearch_pathviato_regclass('messages')and probe errors
fail closed without caching. Breaking (behavior): batches over 256 participants and renders over the
cap are now 400s. (#964, #966) -
Contract and documentation drift corrections.
POST /api/sessionsreturned the raw entity
(leakingconfig/proxyUrl) instead of the documented DTO — it now maps through
SessionResponseDtolike the sibling routes. The OpenAPI exporter pinned no env, so the
snapshot could drift with the operator's configuration —SEARCH_ENABLED/REDIS_ENABLEDare now
pinned for deterministic output (two exports under different envs are byte-identical), the
calls/profile/searchtags and the metrics Bearer scheme are declared, andGET /api/metrics
is in the spec. Docs corrections: the README's audit-trail claim now matches the explicit
audit-coverage registry, the rate-limit header documentation shows the actual per-throttler
suffixed headers (and CORS exposes them), the testing doc reflects the current suite inventory
and CI jobs, and the phantomDATABASE_SQLITE_PATH/DATABASE_URL/openwa.dbreferences are
replaced with the realDATABASE_NAME/MAIN_DATABASE_NAMEvariables and./data/*.sqlite
paths. (#953)
Security
-
The HTTP body parser, the WebSocket gateway, and the plugin install path are now bounded against
memory-exhaustion and supply-chain attacks, and the dashboard's plugin frame and CSV export no longer leak
or inject. Five hardening fixes: the bootstrap only enforced a per-requestBODY_SIZE_LIMIT, so N
concurrent near-limit bodies pinned N×limit bytes before any guard ran (the throttler sits behind the body
parser) — a new aggregate in-flight body budget (INFLIGHT_BODY_BUDGET_BYTES, default 4× the per-request
cap) reservesContent-Lengthat admission, rejecting over-budget requests with 503 +Retry-After+
Connection: close(exactly-once release across finish/close/error/abort). A chunk-encoded body declares
no length, so it takes a small opening reservation that is then reconciled against the bytes actually read
off the socket — a tiny chunked upload never costs a whole request slot, and one that grows past the
aggregate is aborted mid-stream. Reservations cannot be squatted on either: a sender that ships headers
and then goes silent is dropped after 15s without new body bytes rather than holding its declared size
until Node's request timeout, while any progress resets that clock and a fully-received request is never
reaped no matter how long its handler runs. The request stream is never tapped, so consumers that attach
late — the multipart parser, running after the async guards — still see every chunk. The WebSocket gateway
had no rate limit on handshakes (every connect did a DB lookup), no per-key socket cap, and no per-frame
throttle — an unauthenticated connection flood, a valid-key frame flood, or socket exhaustion were all
open; it now enforces three independent limits (per-IP handshake window, per-key socket cap, per-token
frame bucket). The handshake window is charged before authentication, so a flood never reaches the key
lookup, and refunded once the handshake proves authentic — it therefore bounds failed handshakes,
while authenticated volume stays bounded by the socket cap and clients sharing one NAT or reverse-proxy
address cannot shut each other out of the dashboard. All three are memory-bounded by LRU maps with
re-insertion-on-touch so an active abuser cannot drift to the LRU head, and a newRATE_LIMIT_EXCEEDED
audit action is sampled at 1/min/kind+subject so the audit writes cannot themselves become the flood.
Plugin install acceptedhttp://URLs with no integrity check (MITM-substitutable executable code); the
DTO now requireshttpsand an optional sha256 pin carried via URL fragment (#sha256=…, never sent to
the server so a catalog download link can carry it), fail-closed on a malformed marker or a digest
mismatch — the fragment is the only honored marker, since asha256/checksumquery parameter belongs to
the download host and seizing it would mis-verify an unrelated URL. The dashboard plugin config UI
rendered in anallow-scriptssandbox that inherits the dashboard CSP (which allows anyhttps:
img-src/media-src) — a meta-CSP (img-src 'self' data:,media-src 'self' data:,connect-src 'none') is now injected as the frame's first<head>element, closing the egress channelsandboxalone
cannot block. The audit-log CSV export quoted only,/\n/", so an attacker-influenced string starting
with=/+/@/-became a formula when an operator opened the export in a spreadsheet — cells are now
apostrophe-prefixed before structural quoting. Breaking (behavior): legitimate concurrent sends that
together exceed the aggregate body budget now get a transient 503 (raiseINFLIGHT_BODY_BUDGET_BYTES),
and a client that sends request headers and then transmits nothing for 15s has its connection dropped; a
frame/handshake/socket that exceeds its limit is closed with aRATE_LIMIT_EXCEEDEDaudit row; a plugin
install overhttp://is now rejected (usehttps://); a config-UI plugin that hot-links remote media
will render broken until its CSP-compliant equivalent is used. (#936, #937, #942, #939) -
Release workflows now pin every GitHub Action to a commit SHA and stop re-pointing
:lateston
every main push, and the production Docker build context no longer leaks.git/, agent
workspaces, stray databases, ordashboard/node_modulesinto image layers. Two supply-chain
fixes: all 61uses:across the workflow files were floating major tags (@v7,@v4), so a
compromised action update silently landed in jobs that handle publish secrets
(docker/login-action,setup-javaGPG,softprops/action-gh-release,build-push-action) —
every ref is now pinned to its annotated-tag SHA (verified againstgit ls-remote) with a
# vX.Y.Zcomment that Dependabot reads for monthly bumps.ci.ymlre-pointed the registry
:latesttag on every main push, but the CI image is build-gated only (never runs the
release boot-smoke → promote discipline), so a broken-but-buildable image could become:latest;
:latestnow moves only viarelease.ymlafter boot-smoke passes, a globalconcurrency:
group serializes overlapping tag pushes, and thepromotestep guards the mutable channels
(digest identity check before any re-point, prerelease minor-tag skip, per-tag post-promotion
digest verify). Separately, the.dockerignorerejected only root-anchorednode_modules/,
dist/,coverage/, three.env*variants, anddata/— leaking.git/,dashboard/node_modules,
dashboard/dist,.env.production/ custom.envfiles,*.sqlite/*.db,*.tsbuildinfo, and
agent workspaces (.claude/,.agent/) into the build context and every image layer; the
dockerignore is now complete, acheck-dockerignore.mjsgate (real Docker-matching semantics,
29 reject + 16 keep paths) runs as a hard CI gate in bothci.ymlandrelease.yml, the
postinstalllifecycle script is extracted toscripts/postinstall.jswith failure propagation
(a brokendashboard/package-lock.jsonor half-applied wwebjs patch now failsnpm install
instead of silently exit 0), and the Dockerfile copies the hook beforenpm ciso the install
can find it. Breaking (behavior):docker pull openwa:latestafter a main merge no longer
moves the tag — only a tagged release does;npm installnow fails where it previously reported
success over a broken dashboard/patch. (#940, #943) -
The create-instance and regenerate-secret responses no longer echo plaintext values for secret-flagged
config fields, and the whatsapp-web.js engine no longer reports phantom success for operations it never
performed. Two related honesty fixes:(POST /integration/plugins/:pluginId/instances, …/regenerate-secret)rendered the raw instance row whenreveal=true, bypassingmaskedView— so any
config field flaggedsecret: trueat any nesting depth (a nestedcredentials.apiToken, an array-row
webhooks[].signingKey) was returned in plaintext alongside the one-time ingress secret/verifyToken
reveal. The view builder now always starts frommaskedView(fail-closed when the schema is unavailable)
and unmasks only the two documented "revealed once" fields. Separately, several whatsapp-web.js adapter
methods reported success for work that never happened:subscribeToChannelfabricated{id:"undefined"}
from a boolean return,add/remove/promote/demoteParticipantsdiscarded the per-participant outcome (so a
403 not-admin / 404 not-registered / 409 already-member was a plain 2xx), the catalog reads
(getCatalog/getProducts/getProduct) were phantom stubs returningnull/[], and a dead Chromium page
was folded into a 404/400 not-found. These now answer honestly: 501 for the unwired catalog/subscribe
paths, 403 on a total participant refusal or a discardedfalseboolean
(subject/description/unsubscribe), 503 withEngineTransportErrorfor a transport death, and an additive
resultsfield on the four participant endpoints carrying the per-participant outcome. Where
whatsapp-web.js delivered a private group invite instead of adding the member, that entry is a success
carrying the 403 status and an invite-sent message, so an all-invite batch resolves rather than reading as
a total refusal; the remove/promote/demote entries, which the library only confirms as a whole batch, say
so in their message instead of implying an individually confirmed outcome.getProfilePicturereceived
the same transport-death treatment for consistency. Breaking (behavior): callers that previously read
their own config secret back from the create response now receive***, and any client that programmed
against a phantom 2xx for the engine operations above will see the new 4xx/5xx where the operation
genuinely cannot succeed. Response envelopes are otherwise additive only. (#929, #925) -
Plugin ingress routes with
signature.scheme: 'none'now require an explicit opt-in. Anone-scheme
route is an unauthenticated@Public()endpoint that, once an integration instance is provisioned
against it, lets anyone who can reach the host POST a forged payload that triggers outbound WhatsApp
sends on the bound session. The loader previously only logged a warning for such routes, so the
surface lit up silently as soon as an operator installed a plugin declaring one.validateIngressManifest
now rejects anynone-scheme route unlessALLOW_UNSIGNED_INGRESS=trueis set; signed schemes
(hmac-sha256,standard-webhooks,shared-secret) are unaffected, and both official ingress
plugins (chatwoot-adapter,supabase-otp-hook) declare signed schemes. When the opt-in is set, the
existing boot warning still fires so the operator is reminded to front the route with a network ACL. -
Secret comparisons on the
/api/metricsBearer and ingressshared-secretauth surfaces no
longer leak the expected token's byte-length. BothsafeEqualStr(ingressshared-secretverify)
andMetricsService.safeEqual(/api/metricsBearer) used the commontimingSafeEqualidiom of
early-returning on a buffer length mismatch — but that early return is itself a timing channel: a
caller who can time the response learns whether their candidate matches the expected secret's
length. Both now delegate to a sharedconstantTimeEqualhelper that hashes both inputs with a
per-process random key (fixed-length SHA-256 digests) andtimingSafeEquals those, so the value
comparison stays constant-time and neither input's length affects control flow. Thehmac-sha256
andstandard-webhooksschemes compare fixed-length digests and were not affected. -
npm dependency vulnerabilities pinned via
overrides(root + dashboard).
brace-expansionbumped to^5.0.8(CVE-2026-14257, ReDoS; was present at
both 2.1.2 and 5.0.7 in the root tree, and 5.0.6 in the dashboard tree) and
js-yamlto^5.2.2(GHSA-pm4m-ph32-ghv5, exponential parsing DoS) in the
rootpackage.json. Both fixes are pulled in through transitive dependencies;
the overrides consolidate the tree onto the fixed versions with no source
changes and shrinkpackage-lock.jsonby ~240 lines from dedup. -
Release images are now scanned for OS-package CVEs before they get public tags. A new
image-scanjob in the release workflow runstrivy imageagainst the freshly-built staging
image on bothlinux/amd64andlinux/arm64, and blocks thepromotestep (which applies the
X.Y.Z/X.Y/latesttags) on any fixable HIGH/CRITICAL. This covers the Debian packages baked
intonode:22-slim— and the arm64-onlychromiumpackages — that the source-treenpm audit
gate cannot see, plus the dependency tree bundled inside the image's own npm CLI: the production
image now installs npm 12 over the 10.9.8 the base image ships, clearing a criticalnode-tar
advisory along withsigstoreandpicomatchones. A single accepted finding is carried in
.trivyignorewith its justification and the condition for removing it; everything else still
blocks promotion. Runs at release time only, so it never touches fork-PR token permissions. -
Session-scoped API keys can no longer escape their fence through key management or integration instance
management, and pluginconversation.sendnow verifies the mapping belongs to the envelope's session.
The guard enforcesallowedSessionsonly against the:sessionIdroute param, so surfaces without one
slipped through: every/api/auth/api-keysroute (a scoped ADMIN could mint an unrestricted ADMIN key,
clear another key's scope, or enumerate all credentials — a total, persistent escape) and the integration
instance routes (whosesessionScopetravels in the request body and persisted rows the guard never
sees). A new@RequireUnscopedKey()marker rejects any allowlisted key on the key-lifecycle controller
(403, audited via the existing failed-auth trail), and instance create/patch/redrive now intersect the
requested and persisted scopes with the caller's allowlist — out-of-scope instances answer 404
(indistinguishable from missing, so they cannot be probed), and an all-sessions scope is uncreatable by
scoped keys. Separately,conversation.sendresolved a provider-conversation mapping without comparing
itssessionIdto the envelope session, so a stale mapping after a scope move could send on the wrong
WhatsApp session; the lookup now fails closed on any mismatch (an explicitchatIdin the envelope is
unaffected) — except when the mapping's own session no longer exists, which means the operator deleted and
re-paired it: the row is stale rather than cross-session, so it is rebound to the envelope's (already
activation-gated) session and the send proceeds, and a mapping upsert that collides with such a dead
session's row supersedes it instead of failing forever. Without that repair a deleted session's rows
brickedconversation.sendfor that conversation permanently. A mapping owned by another live session is
still a genuine violation and still fails closed. Breaking (behavior): session-scoped ADMIN keys now
get 403 on all key-management routes and can only manage instances bound inside their own sessions;
unrestricted keys (including the bootstrap key) are unchanged. (#916, #920) -
The last-admin guard is race-safe, and webhook media fan-out is bounded. The last-usable-admin check
ran check-then-act across anawait, so two concurrent demote/delete requests against the last two admins
both passed and produced a total management lockout; the check and its mutation now share one in-process
mutex (a DB transaction cannot provide this atomicity on the better-sqlite3 driver), entered whenever the
target key is an ADMIN and the operation can strip its capability, with the target re-read inside the
critical section so the decision never runs against a pre-lock snapshot. A usable admin is an active,
unexpired ADMIN key with no session scope: the key-lifecycle routes are fenced behind
@RequireUnscopedKey(), so a session-scoped admin can authenticate but can never manage keys, and
counting it as a survivor would bless the removal of the last key that can — a lockout with no in-band
recovery, since the boot seed only re-seeds an empty key table. Applying a session scope to the last
unscoped admin is therefore refused with 409 Conflict, exactly like demoting, revoking, deleting, or
expiring it. On the webhook side, one inbound media message cloned its full base64 payload per registered
webhook (with no per-session webhook cap) and retained it in Redis on failure: new registrations above
WEBHOOK_MAX_PER_SESSION(default 16; existing webhooks grandfathered) are rejected, media above
WEBHOOK_MEDIA_INLINE_MAX_BYTES(default 1 MiB) travels as an omitted marker instead of inline base64,
oversized serialized bodies shed their media before enqueue (delivering the event as a marker rather than
dropping it), and the serialized bytes are built once per delivery for both signing and posting.
Breaking (behavior): media above the inline threshold now arrives as a marker (fetch it via history or
raise the knob), and webhook registration past the cap returns 400. (#950, #948) -
The plugin sandbox runtime is bounded and no longer fails silently. A hung plugin could pin all 32
in-flight capability slots forever — capability RPCs now time out (PLUGIN_CAP_TIMEOUT_MS, default 30s),
freeing the slot and logging late settles as warnings (worker work already running is not cancelled, by
design). Hook handler errors inside the sandbox were swallowed wholesale; the first handler error per hook
now surfaces as a rate-limited structured host log and in the plugin's health check, and a plugin whose
load fails at boot has its registry entry reconciled to ERROR instead of still reporting installed/enabled
(the operator config is preserved, and a later successful load restores the status). Per-plugin storage
writes are quota-bounded (PLUGIN_STORAGE_MAX_BYTES, default 50 MiB) and the log relay truncates, caps
throughput per plugin, and flushes its dropped-line count on worker exit so a plugin that goes quiet
before the window rolls over still reports what it dropped. Plugin search-provider responses are validated
at the host boundary — malformed hits/totals now fail with 502 instead of a bogus 200/500.
conversation.sendwithtype: 'location'fell through to an empty text message; coordinates are now
part of the envelope and send a real location (invalid ranges are rejected). Breaking (behavior):
malformed plugin search results now 502, and plugins relying on the empty-text fallthrough get an explicit
error. (#954)
Changed
-
Dashboard stats aggregates now use a standalone
messages(createdAt)index and a short TTL
memo, and ingress replay/dedup-row growth is now bounded. The dashboard timeline and stats
queries (getOverview,getMessageStats) filter oncreatedAtalone (WHERE m.createdAt >= :since, nosessionId), which the existing composite(sessionId, createdAt)cannot serve
(Postgres has no skip-scan; SQLite withoutANALYZEfull-scans) — a new standalone index
IDX_messages_createdAtserves the predicate directly (the migration lifts the runtime
statement_timeouton Postgres to mirror the sibling index migrations), and the per-period
aggregates are memoized forSTATS_CACHE_TTL_MS(default 30s) so a dashboard refresh no longer
re-runs the cross-sessionGROUP BYon every call. Separately, ingress replay protection and
the dedup-row store grew without bound: a route declaringtimestampHeaderalone hit a freshness
trap (the per-routetoleranceSecwas undefined, so the skew check 401'd every delivery), and
the full{headers,query,body,rawBody}payload (~512 KiB/row) was retained for the full 90-day
window — replay windows are now enforced whenevertimestampHeaderis declared, with a host-wide
INGRESS_TIMESTAMP_TOLERANCE_SECfallback (default 300s, Standard-Webhooks convention); dedup
rows retire to 7 days (INGRESS_DEDUP_RETENTION_DAYS) while DLQ rows keep the 90-day compliance
window, and the payload is slimmed to NULL the moment an outcome is recorded (a sha256
payloadHashstays as the permanent fingerprint). The dedup bound is TTL-only (no row cap) — a
count cap would evict legit dedup rows under a forged-id flood and re-admit their replays, which
the per-instance ingress throttle already bounds. Breaking (behavior): dashboard stats now
lag by up toSTATS_CACHE_TTL_MS(default 30s); an ingress route withtimestampHeaderbut no
per-routetoleranceSecnow accepts deliveries (previously 401'd) using the host-wide default.
(#935, #941) -
The message
from-filter now matches group authors, JID candidate expansion is scoped by chat
kind, and session delete purges both engines' auth directories.GET /api/sessions/:id/messages ?from=<phone>filtered only thefromcolumn, but both engine mappers store a group message's
real sender inauthor(with the group JID infrom), so the filter silently skipped every
group message that person wrote. It now matches(message.from IN (:…) OR message.author IN (:…))
against the same lid-expanded candidate set.resolveJidCandidatespreviously expanded ANY filter
value into the user dialects and probed the lid table with its digits — so a group/newsletter
chatId (120363…@g.us,12345@newsletter) could mis-resolve onto an unrelated user chat whose
phone digits matched, and a@lidinput never forward-resolved to its phone; candidates are now
scoped byparseWaIdkind (user/unknown keep the expansion,@lidforward-resolves via
getCached, group/status/newsletter/broadcast fail closed on the literal id). Separately,
DELETE /sessions/:idonly purged the currently-active engine's auth directory, so a session
that ever ran under both engines (linked on whatsapp-web.js, deployment switched to baileys, or
vice versa) kept the other engine's WhatsApp credentials on disk after "delete" — leftovers that
could silently re-link on switchback and were carried into backups.EngineFactory.purgeSessionData
now removes both engine dir shapes (keyed by session name, behind the existing
isSafeSessionNametraversal guard and isolated best-effort per engine); start-time purge is
deliberately unchanged so trialling the other engine keeps the previous link for rollback.
Breaking (behavior, from-filter): filteringfromby a phone now also returns that person's
group messages — consumers that worked around the old miss by filtering client-side will simply
see the rows they expected. (#931, #928) -
Ten operator-tunable environment variables are now documented in
.env.example.
INGRESS_MAX_ATTEMPTS,INGRESS_RETRY_DELAY_MS,INGRESS_RETENTION_DAYS,SSRF_DNS_TIMEOUT_MS,
INGRESS_WORKER_CONCURRENCY,WEBHOOK_WORKER_CONCURRENCY,INBOUND_MEDIA_CONCURRENCY,
STATUS_MEDIA_MAX_BYTES,PLUGIN_DOWNLOAD_MAX_BYTES, andBULK_MAX_CONCURRENT_BATCHESare read from the
environment with sensible defaults but were absent from the configuration reference. Each is now
documented next to its related block, with the exact default and the parse semantics
(INGRESS_RETENTION_DAYS <= 0disables pruning,BULK_MAX_CONCURRENT_BATCHES = 0is unlimited, the DoS
trade-off onSSRF_DNS_TIMEOUT_MS). The numeric knobs whose0is a documented opt-out also share one
parse rule now: the value must be plain decimal digits, an explicit0selects that knob's opt-out
(unlimited forBULK_MAX_CONCURRENT_BATCHESandLID_MAPPING_CACHE_MAX, sweep-off for
MESSAGE_REAPER_INTERVAL_MSandINGRESS_RECONCILE_INTERVAL_MS), and a blank or otherwise unparseable
value falls back to the documented default rather than the opt-out — a compose${KEY:-}forward or a
stray typo can no longer silently disable a cap, a sweep, or a retry backoff. -
Dockerfile
apt-get installinvocations now use--no-install-recommends
(build-stage build deps and production-stage Chromium/Puppeteer deps).
Smaller image; no behavior change for the explicitly-listed packages. -
The in-memory
@lid -> phonemirror is now bounded by an LRU cap (LID_MAPPING_CACHE_MAX,
default 5000). The mirror backs synchronous sender resolution on the dispatch hot path; it was
write-through but never evicted, so on a long-running, contact-heavy account it accumulated one
entry per distinct privacy-LID sender ever seen — a slow memory leak, and the lone unbounded
long-lived map in the process.getCachednow touches the entry so iteration order tracks recency,
and the map evicts the least-recently-used entry when it exceeds the cap; the reversephone -> lids
map is reconciled on each eviction. A cache miss falls back to engine re-resolution (the table
remains the source of truth), so the cap trades a re-resolution for bounded memory, never data loss.
LID_MAPPING_CACHE_MAX=0restores the legacy unbounded behaviour. -
The Kubernetes deployment example now ships with OS-level containment, matching the shipped
Docker image. The StatefulSet indocs/13-horizontal-scaling.mdpreviously had no
securityContext, so an operator following the manifest ran the plugin sandbox without its
OS-containment half (read-only rootfs, non-root,cap_drop: ALL) — weaker than the threat model
assumes. The manifest now setsrunAsNonRoot,readOnlyRootFilesystem,allowPrivilegeEscalation: false, andcapabilities.drop: ['ALL'], with the writable/app/dataand/tmpmounts that
readOnlyRootFilesystemrequires. The stale image tag (0.4.6) was also bumped to the current
release. -
The plugin sandboxing doc no longer lists
auto-replyandtranslationas built-in extensions.
Both ids were removed in v0.7 (LEGACY_REMOVED_PLUGIN_IDS), butdocs/23-plugin-sandboxing.md's
trust-tiers table still named them as built-in examples — misleading an operator about what counts
as a trusted in-process plugin. The table now names only the two engine adapters, matching
docs/19-plugin-architecture.mdand the code. -
The default WhatsApp Web version pin now discloses its trust posture instead of being
misdocumented as a library auto-select. WithWWEBJS_WEB_VERSIONunset,auto, orlatest,
OpenWA resolves a settled build from the third-partywppconnect-team/wa-versionregistry and
pins its HTML — content executed inside the authenticatedweb.whatsapp.comorigin without an
integrity check (the pin exists to avoid the scan→stuck→disconnect-loop class, #488/#684) — but
.env.example, both compose files, the adapter comment, and the troubleshooting FAQ all claimed
unset/offalike "let whatsapp-web.js auto-select". The docs now describe the real default and
its trust implication, onlyWWEBJS_WEB_VERSION=offselects the first-party build served by
WhatsApp, and a once-per-process WARN at pin time names the resolved version, the source URL, and
the opt-outs (an operator-controlled copy viaWWEBJS_WEB_VERSION_REMOTE_PATH). The active build
and its source (pinned/auto/native) remain visible on the dashboard's Infrastructure page.
(#917) -
The peer-fed Baileys session maps and chat-history inline media are now bounded, and an RFC for
wa-version content integrity is open for a maintainer decision.BaileysSessionStoreheld five
unbounded maps fed by peer traffic (contacts, chats, lastMessages, lid, ephemeral markers); all are now
LRU-capped atBAILEYS_SESSION_STORE_MAX_ENTRIES(default 5000,0restores unbounded) with defined
fallbacks on eviction.getChatHistory(includeMedia=true)accumulated up to ~100 × 50 MiB of base64 in
one response with no way to cancel — an aggregate budget (CHAT_HISTORY_MEDIA_BUDGET_BYTES, default 25
MiB) now omits further media behind the existingomittedmarker, and the loop honors request abort. A
caller that ingests into a store rather than into one HTTP response — the status seed, which passes its
own per-item media cap — is budgeted from that cap instead of the response default, so a story feed
carrying several full-size videos is stored whole while the pass stays bounded.
docs/plans/2026-07-27-wa-version-integrity-options.mdanalyzes the structural options for
integrity-checking the pinned WhatsApp Web HTML (per-release hash allowlist, signed hash channel/mirror,
documented accepted-risk, operator pins) and asks the maintainer for a decision; the transparency layer
(warn log, accurate docs, dashboard source badge) already shipped. (#951, #962) -
MCP tool inputs now enforce the same caps as the REST DTOs, and the dashboard's catalogs and modals are
complete. Nine MCP tool fields (location description/address, contact name/number, reply text, reaction
emoji, group name/subject/description) accepted values the equivalent REST endpoints reject — the Zod
schemas now share the DTOs' cap constants, including the 256-entry participant cap on the group create/add
tools.GroupAddParticipantsalso returns the per-participantresultsand derives itssuccessflag
from them, so a batch in which every participant was refused (not registered, already a member) no longer
reports success to the caller; a partial refusal reports how many were added rather than failing the
batch. On the dashboard, 15plugins.*keys used by the Plugins page are translated in all 12 locales,
count badges use real plural forms (including the Arabic and Hebrew category sets),
failed/authenticatingsession statuses render localized in both status renderers, and all 13
hand-written modals moved to the shared Modal withrole="dialog", focus trap + restore-to-trigger,
Escape-to-close, and background scroll-lock. (#959, #965) -
CI now boots the queued dispatch path against a real Redis, and the Docker managed profiles
match compose. A newqueue-one2e suite starts the app withQUEUE_ENABLED=trueagainst a
Redis service (new in the CI test job) and proves ingress deliveries and webhooks actually travel
the BullMQ queues to their workers — the posture that previously shipped green while always
dispatching inline (the suite skips gracefully without a local Redis). DockerService's managed
containers now pin the same MinIO release as compose and setno-new-privileges, a
compose-parity spec locks the contract, and SECURITY.md's supported-versions table reflects the
current linear release policy. (#967, #968)