Skip to content

v0.12.0

Choose a tag to compare

@rmyndharis rmyndharis released this 30 Jul 11:18

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 after ready, and left unacknowledged it
    gets the companion unlinked roughly five minutes later — surfacing as disconnected: 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 on ready and
    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 Continue and 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-Language override), 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 to action_required either —
    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 ready only 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 new action_required status carrying a human-readable reason via lastError
    (relaxed from FAILED-only to also cover this status), which flows through the existing
    session.status webhook 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 of ready blocks 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/logout unlinks the device from the WhatsApp account (#984, #1003). Both engines
    already implemented a real protocol-level unlink — whatsapp-web.js calls WAWebSocketModel.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), and delete() 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 mirrors stop()'s
    lifecycle — stop-mark, reconnection cancel, bounded and isolated teardown, engine-map reconciliation
    — while calling engine.logout() instead of engine.disconnect(), and records a SESSION_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 (unlike stop(),
    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 a SESSION_LOGGED_OUT
    audit row for a device still listed under Linked Devices.

    200 means the engine-native unlink operation AND the required local credential cleanup both
    completed — for Baileys a valid companion identity, an acknowledged remove-companion-device IQ
    response, and removal of the on-disk auth dir; for whatsapp-web.js the native Client.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 a 200
    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 and phone is cleared,
    but the route returns 502 with the stable SESSION_LOGOUT_INCOMPLETE code and no
    SESSION_LOGGED_OUT audit 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/06 spells 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 a logout method mirroring the neighbouring
    forceKill, and the dashboard's sessionApi gains a matching logout(id), so every client covers
    the full session lifecycle again. The reference docs (docs/06, the curl collection in docs/07,
    the audit-action enumeration in docs/05, the method tables in docs/18 and sdk/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 includes authenticating (the
    whatsapp-web.js engine can hold it for 90 seconds) and action_required, neither of which
    previously offered any working action. One gap remains, unchanged from previous releases: a
    disconnected session 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-document really 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/* or audio/* 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 for status@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 to file instead of
    reaching the recipient labelled literally undefined.

  • 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-stream is 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 as image/webp. A URL sticker declared image/webp over 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 an fs.rm of the session's on-disk profile, the same deterministic path a
    later start() re-creates. A pupBrowser.close() wedged past the deadline could therefore land
    that rm on credentials written by a subsequent pairing. Losing teardowns are now tracked against
    the session name, and POST /sessions/:id/start and DELETE /sessions/:id fail closed with a
    retryable 409 (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 install from 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 whenever core.autocrlf is on, which is its default, so the git apply fallback 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 .gitattributes rule keeps fresh clones on LF; and a refusal to
    parse the patch is now correctly treated as "nothing was written", so --best-effort degrades
    instead of aborting the install. That last one is matched on the messages git apply emits 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-effort wave 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
    the patch binary 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.rej is recognized as the same expected, harmless reject as the POSIX
    path and is cleaned up after verification. The lockfile also resolves libsignal@6.0.0 from its
    npm registry tarball (published from the same commit previously fetched over Git SSH), avoiding
    npm 12's EALLOWGIT default without weakening its project security policy.

  • AUTO_START_SESSIONS set in .env now reaches the container under the bundled production
    compose.
    docker-compose.yml never 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 to disconnected at 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.example documents the
    setting, and docker-compose.dev.yml did 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 explicit true starts 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). On LOGOUT, 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
    reconnectBaseDelay at most — a stale QR reached the dashboard and the session.qr webhook 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 for qr and authenticated
    alike, matching the guard that already covered teardown.

  • A LOGOUT disconnect 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.disconnected payload 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 an Unhandled 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 inside initialize(), 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 reaching DockerService in 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
    authenticating indefinitely. 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-kill on a
    session with no live engine used to answer 200 and write a SESSION_FORCE_KILLED audit row,
    although there was no engine to SIGKILL — the audit trail recorded a recovery that never happened.
    It now returns 400 with 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 — writing disconnected over a stale row — moves to POST /sessions/:id/stop, which still
    reconciles the status of a session with no live engine and remains the way to correct a row left
    reading ready or authenticating after 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 logout 200/502 outcomes 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
    .env and see no effect on a Compose deployment: the container never received the variable. Both
    docker-compose.yml and docker-compose.dev.yml now pass it through with its secure default
    (:-false), so the documented opt-in is reachable. While the variable is unset nothing changes —
    :-false and an absent variable are read identically. Once you do set it, spell it exactly true
    or false: 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 into Destination address is not allowed for 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 receives 403 on 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 when QUEUE_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 the sessionId stored 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 reported remaining depth 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/start and DELETE /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 in SSRF_ALLOWED_HOSTS remain 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 downgrades https to plain http is 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 plain http is unaffected —
    it was never secure to begin with — but an http→https→http chain is refused. Upgrade impact:
    if your plugin or catalog host redirects an https URL to an http one, that download worked on
    0.11.1 and now fails. PLUGIN_DOWNLOAD_ALLOW_INSECURE_REDIRECTS=true re-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 in SSRF_ALLOWED_HOSTS stay 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:

    1. 403 for 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.
    2. A retryable 409 (SESSION_NAME_TEARDOWN_PENDING) from POST /sessions/:id/start and
      DELETE /sessions/:id while a name-keyed credential teardown is still in flight. Applies to all
      keys.
    3. 400 instead of 200 from POST /sessions/:id/force-kill when 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 the 409 as 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-kill 400 as "there was nothing to kill" rather than a failed recovery;
    callers that used force-kill to reconcile a stale row should call POST /sessions/:id/stop
    instead. Under SemVer 0.x these breaking changes bump the minor version.