v0.12.0
The session-lifecycle security hardening release. Lifecycle endpoints, the
logout operation, and plugin authorization now describe and enforce a single
evidence-accurate contract: teardown fences fail closed, an unlink success
reflects the engine-native operation plus local cleanup, and full plugin
activation replacement requires an unrestricted ADMIN key. Several pre-existing
races around engine teardown and re-initialization are closed.
Added
-
The whatsapp-web.js engine auto-dismisses a new account's "What's new on WhatsApp Web" onboarding
modal (#982, #1003). A freshly-linked account shows this modal afterready, and left unacknowledged it
gets the companion unlinked roughly five minutes later — surfacing asdisconnected: LOGOUT.
whatsapp-web.js exposes no API for the modal (its own #3550 is still open), so the adapter reaches
the underlying page directly and clicks Continue best-effort, matching by visible text rather than
class selectors, which WhatsApp Web rebuilds across releases. The watcher starts onreadyand
self-terminates after a lifetime cap since the modal is one-shot per account; its interval, cap and
probe timeout are all bounded and.unref()'d, and it is cleared on every teardown path.Detection keys on the Continue button, within a bounded number of levels of an element that also
carries the heading — not on the heading text alone. An element's text content includes all of its
descendants, so a chat-list row previewing the words "what's new", an entirely ordinary English
message, satisfies a heading-only test; a control whose exact label is "Continue" sitting inside
that same subtree is a shape the chat list does not produce. The heading match accepts the
typographic apostrophe WhatsApp Web actually renders as well as the ASCII one.Limitation: both halves of that match are English strings — the button label
Continueand the
heading "What's new" — so the feature only works while WhatsApp Web renders the modal in English.
The rendered language follows the browser locale, which OpenWA does not set (no--lang, no
Accept-Languageoverride), so in practice it is whatever the browser the image launches
(PUPPETEER_EXECUTABLE_PATH) defaults to rather than anything derived from the WhatsApp account.
If a deployment does get a localised modal, the probe finds nothing and does nothing: no
auto-dismissal, and no move toaction_requiredeither —
that deployment sees the pre-fix behaviour, where the companion is unlinked unless someone
acknowledges the modal in a browser signed in as that account. Matching every localisation would
mean carrying WhatsApp's own translation table for two strings it can change at will, so the
detector stays English-only and fails silent rather than guessing.A session leaves
readyonly when Continue has been clicked repeatedly and the modal is still
there — evidence the click is not landing and a human has to acknowledge it on the phone. It then
moves to a newaction_requiredstatus carrying a human-readable reason vialastError
(relaxed from FAILED-only to also cover this status), which flows through the existing
session.statuswebhook and WebSocket channels, the dashboard status pill, and all five SDKs. A
probe that cannot reach the page — a reload, a teardown, a timeout — is logged and otherwise
ignored: it says nothing about the modal, and taking a working session out ofreadyblocks every
send for a reason the operator cannot act on. The status is deliberately expensive to reach for
that reason: five failed dismiss clicks (up from three), so a multi-step "What's new" flow clicked
through one screen per tick no longer reads as a stuck modal. -
POST /sessions/:id/logoutunlinks the device from the WhatsApp account (#984, #1003). Both engines
already implemented a real protocol-level unlink — whatsapp-web.js callsWAWebSocketModel.Socket.logout()
and Baileys sends<remove-companion-device reason="user_initiated"/>— but the method had no caller,
so it was unreachable over the API.stop()disconnects while keeping the stored credentials (a later
start reconnects without a QR), anddelete()additionally purges the on-disk auth directories and the
session row, but neither tells WhatsApp anything, so the device stayed listed under the account
holder's Linked Devices on the phone until they removed it by hand. The new route mirrorsstop()'s
lifecycle — stop-mark, reconnection cancel, bounded and isolated teardown, engine-map reconciliation
— while callingengine.logout()instead ofengine.disconnect(), and records aSESSION_LOGGED_OUT
audit action so an intentional unlink is distinguishable from a plain stop or a WhatsApp-side eviction.
The route requires a started session: with no engine loaded there is nothing to send the unlink
through, so it returns 400 instead of reporting an unlink that never happened (unlikestop(),
which treats an already-stopped session as a successful no-op). The engines enforce the same rule
one level down — a loaded engine whose browser or socket has gone (a stuck-auth recovery, or a
WhatsApp-side logout still inside its reconnect backoff) refuses the unlink rather than resolving
as though it had sent one, which would have produced a success response and aSESSION_LOGGED_OUT
audit row for a device still listed under Linked Devices.200means the engine-native unlink operation AND the required local credential cleanup both
completed — for Baileys a valid companion identity, an acknowledgedremove-companion-deviceIQ
response, and removal of the on-disk auth dir; for whatsapp-web.js the nativeClient.logout()
promise settled. It is NOT an independent observation that the handset UI no longer shows the
device. Because a completed unlink wipes the stored credentials, reconnecting after a200
always requires a fresh QR scan or pairing code.If the logout operation does not complete — no send, no acknowledgement, a timeout/transport
error, or a local-cleanup failure — the session is still stopped locally andphoneis cleared,
but the route returns502with the stableSESSION_LOGOUT_INCOMPLETEcode and no
SESSION_LOGGED_OUTaudit row is written (#993). An explicit start is then required, and whether
the old credentials remain usable depends on the failure point; the route does not promise an
automatic retry or a guaranteed QR state.docs/06spells both cases out. The route itself is
additive, and the stop and delete semantics are unchanged; force-kill gains the same not-started
refusal (see the breaking note below). -
All five SDKs and the dashboard's API client gain the session logout operation (#984). The
JavaScript, Python, Go, PHP, and Java SDKs each expose alogoutmethod mirroring the neighbouring
forceKill, and the dashboard'ssessionApigains a matchinglogout(id), so every client covers
the full session lifecycle again. The reference docs (docs/06, the curl collection indocs/07,
the audit-action enumeration indocs/05, the method tables indocs/18andsdk/README.md) now
include the route. -
The dashboard's Sessions page gains an Unlink action (#984, #1003). Wired to the logout endpoint, it
is the only lifecycle button that tells WhatsApp anything: the device disappears from the account
holder's Linked Devices list, and reconnecting requires a fresh QR scan or pairing code. It is
labelled "Unlink" rather than "Logout" so it cannot be confused with the sidebar's own
(authentication) logout. Unlink and Stop now derive their visibility from one shared definition of
"this session has a live engine", so a card in one of those states cannot offer an action the API
rejects, nor offer Start to a session that is already started — which answers 400 and then leaves a
QR dialog open that cannot resolve. That definition now includesauthenticating(the
whatsapp-web.js engine can hold it for 90 seconds) andaction_required, neither of which
previously offered any working action. One gap remains, unchanged from previous releases: a
disconnectedsession keeps its engine registered for the duration of the automatic reconnect
backoff, and status alone cannot distinguish that from a stopped session with no engine — so Start
is still offered there and still answers 400. Closing it needs the API to publish whether an engine
is loaded, which is a separate change. The confirmation dialog spells out both consequences (fresh QR to reconnect;
delete the session separately if local data should go too), and the 502 case — session stopped
locally but the unlink unconfirmed by WhatsApp — surfaces as a warning toast with retry guidance
instead of a plain error. Localized across all twelve dashboard locales.
Fixed
-
send-documentreally sends a document on the whatsapp-web.js engine (#989, #996, #1000, #1003). Without an
explicit document flag, WhatsApp Web classifies an attachment from its declared mimetype, so an
image/*,video/*oraudio/*payload posted to the document endpoint arrived as a photo, video
or audio bubble — re-encoded, and with its filename dropped. Baileys has always forced the document
form, so the two engines disagreed on the same request. The flag is withheld forstatus@broadcast
and broadcast lists, where whatsapp-web.js refuses every recipient once it is set: setting it there
would turn a working send into a failure rather than improve it, so those recipients keep the
classification they have today. A document with no filename now defaults tofileinstead of
reaching the recipient labelled literallyundefined. -
A remote-URL send keeps the mimetype and filename the caller declared. Media fetched from a URL
derived both from the response — content-type and URL basename — because that is all the fetch has
to go on, and it overrode what the request actually said. The caller's values now win, matching what
the Baileys engine already did;application/octet-streamis treated as the placeholder it is
rather than a statement about the bytes, so omitting the mimetype still falls back to the response.Stickers are the one exception, and keep the fetched content-type. There the mimetype is not a label
but an instruction: whatsapp-web.js picks the conversion from it and returns the media untouched once
it reads asimage/webp. A URL sticker declaredimage/webpover bytes that are not webp would
therefore be sent unconverted. The response saw those bytes and the caller did not, so for that one
decision it is the better source. A declared filename still wins everywhere — nothing branches on it. -
A wedged logout can no longer delete a freshly re-paired profile (#994).
teardownEngineSafely
races an engine teardown against a 10s deadline, but the losing promise kept running — and
logout()'s ends in anfs.rmof the session's on-disk profile, the same deterministic path a
laterstart()re-creates. ApupBrowser.close()wedged past the deadline could therefore land
thatrmon credentials written by a subsequent pairing. Losing teardowns are now tracked against
the session name, andPOST /sessions/:id/startandDELETE /sessions/:idfail closed with a
retryable409(SESSION_NAME_TEARDOWN_PENDING) while a prior logout still owns destructive
cleanup for that name — they no longer touch the profile path or proceed anyway. The dashboard's
start path honours the refusal, so it never opens a QR modal that cannot resolve. On the rare
concurrent-logout case the engine may already be stopped to close the race, but the hook, DB
deletion and auth purge have not run; retrying the delete after cleanup settles completes it. -
npm installfrom source no longer fails on native Windows, and the whatsapp-web.js backport
actually applies there (#889, #1003). A unified diff's lines must begin with a space,+,-or@, so
neither applier accepts one whose lines end CRLF — both stop at this patch's first empty context
line (git apply: "corrupt patch at line 7";patch: "malformed patch at line 7"). Windows checks
the file out exactly that way whenevercore.autocrlfis on, which is its default, so thegit applyfallback introduced for Windows could never run on Windows, and its failure was additionally
misread as a half-written tree — which turned a skippable warning into a hard install failure.
Three changes: the patch is normalized to LF before either applier sees it, which also repairs
clones already on disk with CRLF; a.gitattributesrule keeps fresh clones on LF; and a refusal to
parse the patch is now correctly treated as "nothing was written", so--best-effortdegrades
instead of aborting the install. That last one is matched on the messagesgit applyemits before
it writes anything, not on its exit code alone — the code is shared with fatals raised part-way
through writing results, and calling one of those a pristine tree would let--best-effortwave a
half-patched dependency through as a clean skip, which is the one outcome it must never produce.
Only source installs on Windows were affected — the published Docker image and every platform with
thepatchbinary applied the backport normally. -
Native Windows and npm 12 source installs no longer fail during dependency setup. The
whatsapp-web.js backport now normalizes reject-file paths before comparing them, so Windows'
src\structures\Contact.js.rejis recognized as the same expected, harmless reject as the POSIX
path and is cleaned up after verification. The lockfile also resolveslibsignal@6.0.0from its
npm registry tarball (published from the same commit previously fetched over Git SSH), avoiding
npm 12'sEALLOWGITdefault without weakening its project security policy. -
AUTO_START_SESSIONSset in.envnow reaches the container under the bundled production
compose.docker-compose.ymlnever forwarded the variable — not in any revision of the file — so
on a stock Docker Compose deployment the flag was inert: previously authenticated sessions were
reset todisconnectedat boot and left there, and the operator got no error to work from, because
nothing was misconfigured from the application's point of view..env.exampledocuments the
setting, anddocker-compose.dev.ymldid forward it, which made the omission read as a working
feature everywhere except where most deployments run. The forward is blank-defaulted like the
neighbouring engine options, so an unset value still resolves to the documented opt-out default and
only an explicittruestarts sessions at boot. -
Deleting a session's stored WhatsApp credentials is now recorded when it happens (#981). The
whatsapp-web.js adapter clears a session's auth directory to recover one that authenticated but never
reached runtime readiness within 90 seconds, and that directory holds the only copy of the
credentials — once removed, no restart can restore the link and every later start can do nothing but
present a fresh QR. The adapter logged only the failure to remove it, so a successful wipe left no
trace at all: the sole symptom was a session that quietly stopped reconnecting, indistinguishable in
the logs from a WhatsApp-side logout or a profile nothing had touched. The removal now logs a warning
naming the directory and the session, and the readiness-timeout warning that precedes it carries the
session id too — on a multi-session host that is what separates "one session timed out" from "every
session did", which have very different causes. No behaviour changes; this closes a blind spot that
made the destructive path impossible to confirm from a deployment's own logs. -
A whatsapp-web.js session no longer publishes a QR belonging to the browser it is about to
replace (#982). OnLOGOUT, whatsapp-web.js deletes the auth profile and re-runs its injection
against the same browser, so the old client keeps serving QR codes while the session lifecycle waits
out the reconnect backoff before tearing it down. The adapter suppressed such events only once
teardown had begun, so throughout that window — five seconds by default, as long as the configured
reconnectBaseDelayat most — a stale QR reached the dashboard and thesession.qrwebhook seconds
ahead of the real one; scanning it linked a phantom device and left the session unauthenticated. An
adapter that has reported a disconnect is now treated as finished forqrandauthenticated
alike, matching the guard that already covered teardown. -
A
LOGOUTdisconnect now says what it means in the logs (#982). The reason reached operators as
the bare engine token, reading like any other transient drop, when in fact WhatsApp Web itself ran a
logout and whatsapp-web.js deleted the session's stored credentials before the event was even
handed over — so no reconnect can restore the link and the device must be re-scanned. The adapter
now states that once, alongside a pointer to check Linked devices on the phone. The
session.disconnectedpayload is unchanged. -
The expected Puppeteer rejection that follows an engine teardown no longer logs as an error with a
raw stack (#982). whatsapp-web.js re-runs its injection from an async listener it never awaits, so
closing that browser leaves a pending page evaluate to reject with no owner. It is expected and
self-healing, so it is now a warning naming the cause instead of anUnhandled promise rejection
error that reads like a crash. Nothing is muted: the full reason is still logged, and the actionable
variant of the same message — a browser profile left stale by a Chromium-binary change (#663/#708) —
is raised insideinitialize(), caught by the adapter, and keeps its existing advisory. -
Service start during Docker orchestration now applies the same managed-profile allowlist as
teardown. Non-managed profile names are dropped before reachingDockerServicein both directions,
so the start path cannot select an unrelated host container any more than teardown can. -
A session cannot be torn down before its engine has finished initializing. The teardown path
previously ran against an engine handle that the initialize step had not yet produced, racing the
pre-init window; teardown is now retired until initialization reports an engine, so a stop/delete
that lands during startup cannot act on a handle that was never ready. -
An async disconnect is fenced by engine identity. whatsapp-web.js raises its disconnect event
from a listener it never awaits, so a teardown that closed the browser could still deliver a stale
disconnect for the engine that was just destroyed. The disconnect handler now only acts when the
event belongs to the currently-loaded engine for that session, so a superseded client cannot drive
a lifecycle transition for its replacement. -
Stuck-auth recovery keeps its readiness budget across reconnects. The 90-second
readiness-to-wipe budget that lets a stuck-auth self-heal clear and re-pair was reset on every
reconnect, so repeated short reconnects kept deferring the wipe and the session sat in
authenticatingindefinitely. The budget is now hoisted above the reconnect loop, so it advances
across reconnects and the self-heal still fires. -
Force-kill no longer reports a kill it did not perform.
POST /sessions/:id/force-killon a
session with no live engine used to answer200and write aSESSION_FORCE_KILLEDaudit row,
although there was no engine to SIGKILL — the audit trail recorded a recovery that never happened.
It now returns400with the same not-started message the logout route uses.⚠️ This is a
behaviour change for existing callers: see the breaking note below. The side effect that call used
to have — writingdisconnectedover a stale row — moves toPOST /sessions/:id/stop, which still
reconciles the status of a session with no live engine and remains the way to correct a row left
readingreadyorauthenticatingafter its engine was evicted. -
The dashboard reconciles session status from authoritative engine state. Sessions-page
visibility and status now derive from one shared definition of "this session has a live engine",
the FAILED status no longer holds a concurrency slot (it is evicted by design, with no live engine
to offer actions against), force-kill is offered only while an engine-backed status exists, and
the logout200/502outcomes surface with matching evidence-level copy. -
Both bundled compose files forward the new plugin-download redirect flag.
PLUGIN_DOWNLOAD_ALLOW_INSECURE_REDIRECTS(added in this release — see the https→http item under
Security) is read straight from the environment, so without a forward an operator could set it in
.envand see no effect on a Compose deployment: the container never received the variable. Both
docker-compose.ymlanddocker-compose.dev.ymlnow pass it through with its secure default
(:-false), so the documented opt-in is reachable. While the variable is unset nothing changes —
:-falseand an absent variable are read identically. Once you do set it, spell it exactlytrue
orfalse: the value now reaches boot validation, which rejects anything else by name rather than
silently falling back, so a typo fails the boot instead of quietly choosing a security posture.
Note also that the refusal it opts out of is new in this release — see the Security item for the
upgrade impact. -
A caller's timeout now bounds the SSRF guard's own DNS resolution, and reports itself honestly.
The guard resolves each host before connecting, and that step previously ignored the caller's
AbortSignal— it was bounded only by the guard's internal DNS deadline, which raises a blocked-URL
error. Redaction turns any such error intoDestination address is not allowedfor the client, so a
webhook or plugin-download timeout that landed during resolution was reported as though the
destination had failed the policy check. The signal is now honoured before and during the lookup and
its own reason propagates, so a timeout reads as a timeout, and one caller deadline covers the whole
chain including every redirect hop's resolution rather than each hop restarting a fresh DNS budget. -
The MCP adapter dependency is refreshed past a transitive advisory. The bundled MCP SDK was
bumped past the Hono advisory in its dependency tree. The MCP adapter remains v1 and optional; the
refresh is a dependency-security change and does not alter the MCP wire contract.
Security
-
Infrastructure routes (
/api/infra/*) now reject API keys restricted to specific sessions.
These routes act on the whole deployment, so a key confined to a subset of sessions must not
reach them regardless of its role. Unrestricted ADMIN keys are unaffected. -
Plugin installation and lifecycle routes (
/api/plugins/*) now reject API keys restricted to
specific sessions. Installing or enabling a plugin runs its code in the gateway process, which is
a deployment-wide action. Full plugin activation replacement (PUT /api/plugins/:id/sessions,
which overwrites the entire active set) now requires an unrestricted ADMIN key — sending[]or
a single session from a scoped key would otherwise delete every other tenant's activation, so a
scoped key receives403on that route. The per-session config route
(PUT /api/plugins/:id/config/:sessionId) continues to accept restricted keys and remains scoped
to the sessions the key allows. -
The queue dashboard (
/api/admin/queues, available whenQUEUE_ENABLED=true) now refuses API keys
restricted to specific sessions. The dashboard exposes and mutates every queue in the deployment
and has no session dimension to scope against. -
Cross-session statistics (
GET /api/stats/overview,GET /api/stats/messages), application
settings (GET /api/settings), and session creation (POST /api/sessions) now require an API key
that is not restricted to specific sessions. Per-session statistics remain available to restricted
keys for the sessions they allow. -
Redriving a dead-lettered integration delivery now fails closed for session-restricted keys when
the integration instance no longer exists. Previously the scope check was skipped for a missing
instance, so retained dead-letter rows could be re-dispatched for sessions outside the key's scope.
Scoped redrive additionally filters DLQ rows by thesessionIdstored at provenance time against
the key's authorized binding, so each restricted key only ever re-dispatches its own sessions;
historical/legacy rows that carry no stored session remain available only to unrestricted redrive,
and the reportedremainingdepth is authority-filtered to match. -
A pending credential teardown for a session name now fences its lifecycle with a retryable
409(SESSION_NAME_TEARDOWN_PENDING).POST /sessions/:id/startandDELETE /sessions/:id
refuse to run a destructive side effect while a prior logout still owns cleanup for that name,
closing the window where a wedged teardown could land on freshly re-paired credentials. -
Outbound downloads that follow redirects (plugin packages and the plugin catalog) now validate
every redirect hop before connecting (hosts named inSSRF_ALLOWED_HOSTSremain the documented
opt-out). Previously a redirect whose target was a bare IP address bypassed the destination check,
because the platform skips name resolution for address literals. Redirect chains are also capped. -
⚠️ A redirect hop that downgradeshttpsto plainhttpis now refused on those same download
paths. The payload is executable code, and an http hop exposes it to on-path substitution; the
previous implementation delegated redirect-following to the HTTP client and never inspected the
scheme of a hop, so such a chain was followed. A chain that starts on plainhttpis unaffected —
it was never secure to begin with — but anhttp→https→httpchain is refused. Upgrade impact:
if your plugin or catalog host redirects anhttpsURL to anhttpone, that download worked on
0.11.1 and now fails.PLUGIN_DOWNLOAD_ALLOW_INSECURE_REDIRECTS=truere-allows that specific hop;
it does not opt out of the other constraints on this path, which apply either way — every hop is
still re-checked before connecting (hosts named inSSRF_ALLOWED_HOSTSstay the documented
exception: deliberately neither resolved nor pinned), and the chain is still capped (at 5 hops,
where 0.11.1 inherited the HTTP client's limit of 20). Prefer checking whether the host can serve
the redirect target over https before setting it. -
Added a build-time check that fails when a route acting on the whole deployment is added without
refusing session-restricted API keys, so this class of gap cannot be reintroduced silently. -
⚠️ Breaking (behavior). Three refusals change behaviour for existing API consumers:403for session-restricted keys on the deployment-global surfaces listed above — most
notably full plugin activation replacement (PUT /api/plugins/:id/sessions). Unrestricted keys
are unaffected.- A retryable
409(SESSION_NAME_TEARDOWN_PENDING) fromPOST /sessions/:id/startand
DELETE /sessions/:idwhile a name-keyed credential teardown is still in flight. Applies to all
keys. 400instead of200fromPOST /sessions/:id/force-killwhen the session has no live
engine. Applies to all keys.
Migration. For (1), use an unrestricted ADMIN key to manage plugin activation; the per-session
config route (PUT /api/plugins/:id/config/:sessionId) still accepts restricted keys. For (2),
treat the409as retryable and retry after a short delay — the body carries
code: 'SESSION_NAME_TEARDOWN_PENDING', and no destructive side effect ran before the refusal.
For (3), read a force-kill400as "there was nothing to kill" rather than a failed recovery;
callers that used force-kill to reconcile a stale row should callPOST /sessions/:id/stop
instead. Under SemVer 0.x these breaking changes bump the minor version.