Skip to content

Media errors without the link; session files replaced whole - #1

Merged
AlmogCohen merged 7 commits into
fork/2.3.7from
fork/wip-hardening
Sep 29, 2026
Merged

AlmogCohen merged 7 commits into
fork/2.3.7from
fork/wip-hardening

Conversation

@AlmogCohen

Copy link
Copy Markdown
Owner

Two hardening fixes, each a failing test commit then its fix, verified red then green commit by commit.

A failed media download answers without the media link. getBase64FromMediaMessage put the raw download error in its 400 answer, and that text is Baileys' Failed to fetch stream from https://mmg.whatsapp.net/...?oh=...&oe=..., so the signed media link left the process (in the 404/410/403 cases and after a re-upload). The same thrown object reached the errors webhook and the S3 upload's error log line. The answer now reads The media could not be downloaded (HTTP <status>); reupload and reuploadReason are unchanged.

Session files are replaced whole, never written in place. Signal key files and the pending-logout marker were written with an in-place write, so a crash, a full disk or two overlapping writes left a torn or empty file, which reads as no key. They now go through src/utils/atomic-file.ts: a temp file in the same directory, fsync, rename over the target, directory fsync; writes of one file are queued in call order, and temp files left by a killed writer are removed when the store opens.

The crash test runs the real store in a child process writing 4 MB values and SIGKILLs it at random moments, 64 times. On the old code one run gave 32 whole, 26 torn and 6 empty files; with the fix all 64 are whole. A real partial write (ulimit -f, EFBIG) and 12 concurrent writes also left torn files before the fix.

Not covered: the provider-files store (it writes on its own server) and Redis (no files).

🤖 Generated with Claude Code

AlmogCohen and others added 7 commits September 29, 2026 11:34
POST /chat/getBase64FromMediaMessage answers a failed download with the
error's text, Baileys' "Failed to fetch stream from <link>", so the HTTP
answer carries WhatsApp's signed CDN link (oh, oe, the _nc_ parameters and
the directPath). The same object is what the error handler posts to the
errors webhook and what the S3 upload logs at ERROR.

The new file drives the real route against WhatsApp's CDN host, answered by
the undici mock agent: 404 with the phone refusing the re-upload, 410 with
no answer in time, 403 on an expired link, a re-upload whose new link fails
too, and a 403 on a valid link with and without reupload: false. It asserts
the whole answer, its headers and the output meanwhile, the thrown object,
and the S3 upload's error log. All eight fail on the code as it is.

media-skip-reupload.test.ts asserted the old text ("TypeError: fetch failed")
for a fallback that fails without an HTTP status; it now expects the stable
reason text (error kind and network code), and fails until the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getBase64FromMediaMessage answered a failed download with the error's own
text, which for Baileys' download error is "Failed to fetch stream from"
and the signed CDN link. Its 400 now carries a stable reason instead: the
CDN's HTTP status ("The media could not be downloaded (HTTP 404)"), or,
with no status, the kind of error and its network code. reupload and
reuploadReason are unchanged, and a text Evolution threw itself (it names
no link) stands. The object is built fresh, so no Boom data (data.url)
travels with it: not to the HTTP answer, not to the errors webhook main.ts
posts, and not to the S3 upload's error log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…concurrent one

The Prisma auth store writes each signal key over its file in place
(fs.writeFile truncates, then writes), and reads a file that does not parse
as no key at all. Four cases, on the real store and a real filesystem:

- Crash: a child process (the real store, bundled with esbuild) writes one
  key over and over with 4 MB values that name their write, and is
  SIGKILLed at random moments, 64 times across 4 folders. After each kill
  the file must hold one whole value. On the code as it is, one run: 32 of
  64 whole, 26 torn, 6 empty.
- A write that fails partway: the child runs under a file size limit
  (ulimit -f), so the second write fails with EFBIG after 1 MiB. The error
  reaches keys.set, and the previous value must survive; it is torn.
- Concurrency: twelve writes of the same key at once must end with the last
  one, whole; they end torn.
- A value written is read back by the store and by a new one after a
  restart (passes today, kept as the guard for the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Prisma auth store now writes a key through writeFileAtomic
(src/utils/atomic-file.ts): the value goes to a temp file in the same
directory (opened exclusively), is flushed with fsync, and is renamed over
the key file, which POSIX makes atomic; the directory is then flushed, so
the rename survives a power loss (renames that land together share one
directory flush). A failed write removes its temp file, leaves the previous
value and rejects, so keys.set fails as saveCreds does. Writes of the same
file run one at a time in call order, so the last one asked for stays.

A killed writer can leave its temp file behind; the store removes those
when it opens its folder (a temp file of a process that no longer runs, or
this process's own that it is not writing), and leaves one of another
running process alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…marker

The marker next to the session's key files is written in place
(fs.writeFile). A write that fails partway (injected here as a full disk:
half the bytes land, then ENOSPC) rejects as it should, but leaves half a
marker over the whole one, which reads as pending with no instance name.
The previous marker must survive, with no temp file left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
writeLogoutMarker writes through writeFileAtomic, as the key files do: a
temp file in the same directory, fsync, rename over the marker, fsync of the
directory. A write that fails keeps the previous marker, removes its temp
file and rejects, so the logout or delete still answers 500 as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…laced whole

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AlmogCohen
AlmogCohen merged commit 95c844b into fork/2.3.7 Sep 29, 2026
4 checks passed
@AlmogCohen
AlmogCohen deleted the fork/wip-hardening branch September 29, 2026 08:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant