Skip to content

fix(sdk): stop toBuffer from masking a decrypt failure as a network error (DSPX-4703) - #1035

Open
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4703-stream-error-propagation
Open

dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4703-stream-error-propagation

Conversation

@dmihalcik-virtru

Copy link
Copy Markdown
Member

Orthogonal to the #1030#1031#1034 stack, extracted so it can land on its own. Found while working DSPX-4703; the defect and the fix predate it.

The bug

streamToBuffer drained the plaintext with new Response(stream).arrayBuffer(). Chrome replaces any error raised while it pulls a Response body with a bare TypeError: Failed to fetch, discarding the original.

So in the browser — and only in the browser — this:

try {
  const plaintext = await (await client.decrypt({ source })).toBuffer();
} catch (e) {
  // e is TypeError: Failed to fetch
  // it was an IntegrityError
}

A caller cannot tell a tampered file from a dropped connection, which is the one distinction that matters most here: an IntegrityError means the bytes were altered and the operation must not be retried; a network error means it should be. Any consumer with retry-on-network-error logic will happily re-fetch a file that is being tampered with, and any consumer surfacing the message to a user tells them the wrong thing.

toString had the same problem via Response.text().

The fix

Drain with a reader, which rejects with the error the stream was actually errored with. toString now buffers first and decodes once — which incidentally fixes a latent bug of its own, since Response.text() decodes per-chunk and corrupted any multi-byte character split across a segment boundary.

Not a regression

streamToBuffer has used Response since 2023 (lib/tdf3/src/client/DecoratedReadableStream.ts, unchanged in substance across ~15 commits). karma-chrome-launcher launches the system Chrome — no Chrome version is pinned in any lockfile — so there is no dependency bump to point at either.

What changed is only that someone looked. No existing test drove a stream error through toBuffer() in a browser: the one IntegrityError case in encrypt-decrypt.spec.ts is a mocked KAS response, not a stream error. The masking has been silently present the whole time.

How to test

cd lib && npm run build && npm run test:with-server

lib/tests/mocha/unit/decorated-readable-stream.spec.ts is new — 7 cases:

  • streamToBuffer concatenates chunks in order, and handles an empty stream
  • streamToBuffer propagates the original error both on the first pull and after partial output
  • toBuffer and toString each propagate rather than mask
  • toString decodes utf-8 across a chunk boundary (the per-chunk-decode bug above)

The error cases assert strictEqual against the thrown instance, not instanceOf — the point is that no layer substitutes an error of its own, and a type check would pass on a re-wrap.

It lives under tests/mocha/unit/ so webpack.test.config.cjs picks it up and it runs under karma as well as Node. That placement is load-bearing: the bug does not reproduce outside a browser.

Baseline

Reverting the implementation alone, keeping the tests:

before after
mocha (Node) 7/7 pass 7/7 pass
karma (Chrome 153) 4 FAILED, 372 SUCCESS 376 SUCCESS

The 4 are exactly the error-propagation cases; the 3 that don't involve an errored stream pass either way. Node preserves the error through Response — this is Chrome-specific, which is why a Node-only test would not have caught it.

With the fix: 376 passing / 6 pending under mocha, 376 under karma, web-test-runner green, coverage thresholds met, npm run lint clean.

Risk

Decrypt read path, but narrowly. Success behaviour is unchanged — same bytes out, verified by the round-trip tests across the existing suite. The only behavioural change is which error object a caller sees on failure, and only in a browser. Callers matching on TypeError: Failed to fetch to detect decrypt failure would break, but that string is indistinguishable from a genuine network error, so no correct caller can be doing it.

DSPX-4703

…rror (DSPX-4703)

`streamToBuffer` drained the plaintext with `new Response(stream).arrayBuffer()`,
and Chrome replaces *any* error raised while it pulls a Response body with a
bare `TypeError: Failed to fetch`, discarding the original. So in the browser —
and only in the browser — a file that failed its integrity check surfaced to
the caller as something indistinguishable from a dropped connection. That is
the one distinction a caller most needs to make here: an `IntegrityError` means
the bytes were tampered with and the operation must not be retried, while a
network error means it should be.

Draining with a reader instead rejects with the error the stream was errored
with. `toString` had the same problem via `Response.text()`; it buffers first
now, which also fixes a latent bug of its own — `Response.text()` decodes
per-chunk, so a multi-byte character split across a segment boundary came back
corrupted.

Not a regression. `streamToBuffer` has used `Response` since 2023, and karma
launches the system Chrome rather than a version pinned in any lockfile, so
there is no dependency bump to point at. What changed is that nothing tested
it: no existing case drove a stream error through `toBuffer()` in a browser,
so the masking was invisible. The new
`lib/tests/mocha/unit/decorated-readable-stream.spec.ts` closes that hole with
7 cases, asserting the *identical* error instance comes back out rather than
merely the right type. It lives under `tests/mocha/unit/` so webpack picks it
up and it runs under karma as well as Node.

Verified by reverting the implementation alone: Node passes all 7 either way,
while Chrome goes from 376 passing to `4 FAILED, 372 SUCCESS` — every
error-propagation case, and only those. With the fix the suite is 376 passing
/ 6 pending under mocha and karma alike, coverage thresholds met, lint clean.

Found while working DSPX-4703, which adds decrypt-time integrity errors that
this would otherwise have swallowed, but the defect and the fix are
independent of that work.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@dmihalcik-virtru
dmihalcik-virtru requested a review from a team as a code owner September 11, 2026 13:49
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 32 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 76e333d8-66d8-48ed-a4f0-d1733798965f

📥 Commits

Reviewing files that changed from the base of the PR and between 0fef6fe and f105fd4.

📒 Files selected for processing (2)
  • lib/tdf3/src/client/DecoratedReadableStream.ts
  • lib/tests/mocha/unit/decorated-readable-stream.spec.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

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