-
Notifications
You must be signed in to change notification settings - Fork 659
fix(security): SSRF via unvalidated tokenUrl + unbounded response DoS #1663
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ad2e5e4
49666ac
e62b812
07e5ee5
58cc9da
4d3723b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import * as https from 'node:https'; | ||
| import * as querystring from 'node:querystring'; | ||
| import type { OAuthConfig } from '../plugins'; | ||
| import { validateTokenUrl } from './url-validator'; | ||
|
|
||
| const TOKEN_NUMERIC_FIELDS = ['expires_in', 'refresh_token_expires_in']; | ||
|
|
||
|
|
@@ -58,7 +59,7 @@ export function exchangeCodeForTokens( | |
| oauthConfig: OAuthConfig, | ||
| redirectUri: string, | ||
| ): Promise<TokenResponse> { | ||
| const tokenUrl = new URL(oauthConfig.tokenUrl); | ||
| const tokenUrl = validateTokenUrl(oauthConfig.tokenUrl); | ||
| const useBasicAuth = oauthConfig.tokenAuthMethod === 'basic'; | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
|
|
@@ -96,7 +97,17 @@ export function exchangeCodeForTokens( | |
| }, | ||
| (res) => { | ||
| let data = ''; | ||
| const MAX_RESPONSE_BYTES = 1024 * 1024; // 1 MB | ||
| res.on('data', (chunk) => { | ||
| if (data.length + chunk.length > MAX_RESPONSE_BYTES) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Count response bytes with a separate byte counter.
Increment a 🤖 Prompt for AI Agents |
||
| req.destroy(); | ||
| reject( | ||
| new Error( | ||
| `Token exchange response exceeded maximum size (${MAX_RESPONSE_BYTES} bytes)`, | ||
| ), | ||
| ); | ||
| return; | ||
| } | ||
| data += chunk; | ||
| }); | ||
| res.on('end', () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ | |
| // single implementation covers every provider that speaks standard OAuth 2.0, | ||
| // replacing the per-plugin refresh<X>AccessToken helpers. | ||
|
|
||
| import { validateTokenUrl } from './url-validator'; | ||
|
|
||
| export type OAuthTokenAuthMethod = | ||
| | 'body' // client_id + client_secret in the request body (most providers) | ||
| | 'basic' // HTTP Basic base64(client_id:client_secret) (Bitbucket) | ||
|
|
@@ -62,6 +64,10 @@ export async function refreshOAuthTokensLocal( | |
| headers['Content-Type'] = 'application/x-www-form-urlencoded'; | ||
| } | ||
|
|
||
| // SSRF guard: reject private IPs, cloud metadata, and non-HTTPS URLs before | ||
| // sending client credentials over the wire. | ||
| validateTokenUrl(tokenUrl); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: sed -n '1,125p' packages/corsair/core/auth/oauth-refresh-local.ts
printf '\n-- fetch overrides or wrappers --\n'
rg -n --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.mjs' 'globalThis\.fetch|(?:^|[^A-Za-z])fetch\s*=|undici|node-fetch|redirect\s*:' packages/corsair
printf '\n-- package runtime declarations --\n'
rg -n --glob 'package.json' --glob 'tsconfig*.json' '"engines"|"node"|lib' packages/corsair package.jsonRepository: corsairdev/corsair Length of output: 10468 SSRF (CWE-918): Server-Side Request Forgery (SSRF) Reachability: External · Exploitability: Moderate Block redirects or validate every redirect target.
🤖 Prompt for AI Agents |
||
|
|
||
| const response = await fetch(tokenUrl, { | ||
| method: 'POST', | ||
| headers, | ||
|
|
@@ -71,13 +77,20 @@ export async function refreshOAuthTokensLocal( | |
| : new URLSearchParams(params), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| // Cap response body to prevent OOM from oversized responses (DoS). | ||
| const MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024; // 1 MB | ||
| const body = await response.text(); | ||
| if (body.length > MAX_TOKEN_RESPONSE_BYTES) { | ||
|
Comment on lines
+82
to
+83
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The refresh path calls
Comment on lines
+82
to
+83
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🔴 Critical | 🏗️ Heavy lift Denial of Service (CWE-770): Allocation of Resources Without Limits or Throttling Reachability: External · Exploitability: Moderate Enforce the response-size limit while reading the response stream.
Read 🤖 Prompt for AI Agents |
||
| throw new Error( | ||
| `OAuth token refresh failed (${response.status}): ${await response.text()}`, | ||
| `OAuth token refresh response exceeded maximum size (${MAX_TOKEN_RESPONSE_BYTES} bytes)`, | ||
| ); | ||
| } | ||
|
|
||
| const json = JSON.parse(await response.text()) as Record<string, unknown>; | ||
| if (!response.ok) { | ||
| throw new Error(`OAuth token refresh failed (${response.status}): ${body}`); | ||
| } | ||
|
|
||
| const json = JSON.parse(body) as Record<string, unknown>; | ||
| if (typeof json.access_token !== 'string' || json.access_token.length === 0) { | ||
| throw new Error('OAuth token refresh returned no access_token'); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| /** | ||
| * URL validation for outbound OAuth requests (token exchange, refresh). | ||
| * | ||
| * Prevents SSRF by rejecting URLs that point to private networks, cloud | ||
| * metadata services, or use non-HTTPS schemes. Applied to every `tokenUrl` | ||
| * before the SDK sends client credentials over the wire. | ||
| * | ||
| * OWASP A10:2021 — Server-Side Request Forgery (SSRF) | ||
| */ | ||
|
|
||
| /** | ||
| * IPv4 ranges that must never be contacted by the token exchange. | ||
| * Covers RFC 1918 private, link-local (cloud metadata), loopback, and | ||
| * current-network addresses. | ||
| */ | ||
| const BLOCKED_IPV4_PATTERNS: RegExp[] = [ | ||
| /^127\./, // loopback | ||
| /^10\./, // RFC 1918 Class A | ||
| /^172\.(1[6-9]|2\d|3[01])\./, // RFC 1918 Class B | ||
| /^192\.168\./, // RFC 1918 Class C | ||
| /^169\.254\./, // link-local — AWS/Azure IMDS | ||
| /^0\./, // current network | ||
| ]; | ||
|
|
||
| /** | ||
| * Hostnames used by cloud providers for instance metadata endpoints. | ||
| * A `tokenUrl` pointing here is always malicious. | ||
| */ | ||
| const BLOCKED_HOSTNAMES = new Set([ | ||
| 'metadata.google.internal', // GCP | ||
| 'metadata.internal', // generic cloud metadata | ||
| 'localhost', | ||
| '[::1]', // IPv6 loopback | ||
| ]); | ||
|
|
||
| export class TokenUrlValidationError extends Error { | ||
| constructor(message: string) { | ||
| super(message); | ||
| this.name = 'TokenUrlValidationError'; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Validates a token endpoint URL before any credential material is sent. | ||
| * | ||
| * @param url - The raw `tokenUrl` string from the plugin's `oauthConfig`. | ||
| * @returns The parsed `URL` object (guaranteed HTTPS, non-private). | ||
| * @throws {TokenUrlValidationError} if the URL fails any check. | ||
| */ | ||
| export function validateTokenUrl(url: string): URL { | ||
| let parsed: URL; | ||
| try { | ||
| parsed = new URL(url); | ||
| } catch { | ||
| throw new TokenUrlValidationError( | ||
| `Invalid tokenUrl: unable to parse "${url}"`, | ||
| ); | ||
| } | ||
|
|
||
| // ── Scheme: HTTPS only ────────────────────────────────────────────── | ||
| if (parsed.protocol !== 'https:') { | ||
| throw new TokenUrlValidationError( | ||
| `tokenUrl must use HTTPS (got ${parsed.protocol}). ` + | ||
| 'Sending client credentials over plain HTTP exposes them to network interception.', | ||
| ); | ||
| } | ||
|
|
||
| // ── Blocked hostnames (cloud metadata, loopback) ──────────────────── | ||
| const hostname = parsed.hostname.toLowerCase(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The validator checks only the hostname text and never validates or pins its resolved addresses. A plugin can provide an allowed HTTPS hostname that resolves to a private or loopback HTTPS service; both request paths then perform unchecked DNS resolution and send OAuth credentials to that internal destination. Resolve and reject private or reserved addresses, and ensure the request connects to the validated address. How this was verified: Both request paths pass an accepted hostname directly to the network stack without validating its resolved destination. |
||
| if (BLOCKED_HOSTNAMES.has(hostname)) { | ||
|
Comment on lines
+69
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🔴 Critical | 🏗️ Heavy lift SSRF (CWE-918): Server-Side Request Forgery (SSRF) Reachability: External · Exploitability: Moderate Validate resolved addresses before the credential-bearing request. An allowed HTTPS hostname can resolve to Resolve all address records, reject every non-public result, and bind the connection lookup to the validated address. A preflight-only lookup permits DNS rebinding. 🤖 Prompt for AI Agents |
||
| throw new TokenUrlValidationError( | ||
| `tokenUrl hostname is blocked: "${hostname}". ` + | ||
| 'This address is reserved for cloud metadata or loopback.', | ||
| ); | ||
| } | ||
|
|
||
| // ── Blocked IPv4 ranges ───────────────────────────────────────────── | ||
| for (const pattern of BLOCKED_IPV4_PATTERNS) { | ||
| if (pattern.test(hostname)) { | ||
| throw new TokenUrlValidationError( | ||
| `tokenUrl points to a private/reserved IP range: "${hostname}". ` + | ||
| 'OAuth token endpoints must be publicly routable.', | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // ── IPv6 private / link-local ─────────────────────────────────────── | ||
| if (hostname.startsWith('[') || /^fd[0-9a-f]{2}:/i.test(hostname)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Do not reject public IPv6 token endpoints.
🤖 Prompt for AI Agents |
||
| throw new TokenUrlValidationError( | ||
| `tokenUrl points to a private IPv6 address: "${hostname}".`, | ||
| ); | ||
| } | ||
|
|
||
| return parsed; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| /** | ||
| * Security PoC tests — SSRF via unvalidated tokenUrl + Unbounded response DoS | ||
| * | ||
| * These tests prove exploitability of two critical vulnerabilities and verify | ||
| * the applied fixes block each attack vector. | ||
| * | ||
| * Vulnerability 1: SSRF — tokenUrl accepted ANY URL, sending client_secret + | ||
| * refresh_token to attacker-controlled servers or cloud metadata endpoints. | ||
| * | ||
| * Vulnerability 2: Unbounded response — no size cap on token exchange HTTP | ||
| * response bodies, allowing OOM-crash DoS via oversized responses. | ||
| */ | ||
|
|
||
| import { | ||
| TokenUrlValidationError, | ||
| validateTokenUrl, | ||
| } from '../core/auth/url-validator'; | ||
|
|
||
| // ───────────────────────────────────────────────────────────────────────────── | ||
| // PoC #1 — SSRF via unvalidated tokenUrl | ||
| // ───────────────────────────────────────────────────────────────────────────── | ||
|
|
||
| describe('PoC #1: SSRF via unvalidated tokenUrl', () => { | ||
| // ── Attack vector: HTTP scheme (credential interception) ───────────── | ||
|
|
||
| test('BLOCKS http:// tokenUrl — credentials would be sent in plaintext', () => { | ||
| const url = 'http://oauth.provider.com/token'; | ||
| expect(() => validateTokenUrl(url)).toThrow(TokenUrlValidationError); | ||
| expect(() => validateTokenUrl(url)).toThrow(/must use HTTPS/); | ||
| }); | ||
|
|
||
| // ── Attack vector: AWS Instance Metadata Service (IMDS) ───────────── | ||
|
|
||
| test('BLOCKS AWS metadata IP 169.254.169.254 — IAM credential theft', () => { | ||
| const url = | ||
| 'https://169.254.169.254/latest/meta-data/iam/security-credentials/'; | ||
| expect(() => validateTokenUrl(url)).toThrow(TokenUrlValidationError); | ||
| expect(() => validateTokenUrl(url)).toThrow(/private\/reserved IP/); | ||
| }); | ||
|
|
||
| // ── Attack vector: GCP metadata service ───────────────────────────── | ||
|
|
||
| test('BLOCKS GCP metadata hostname — service account token theft', () => { | ||
| const url = | ||
| 'https://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token'; | ||
| expect(() => validateTokenUrl(url)).toThrow(TokenUrlValidationError); | ||
| expect(() => validateTokenUrl(url)).toThrow(/blocked/); | ||
| }); | ||
|
|
||
| // ── Attack vector: Azure IMDS ─────────────────────────────────────── | ||
|
|
||
| test('BLOCKS Azure metadata IP 169.254.169.254 — managed identity theft', () => { | ||
| const url = 'https://169.254.169.254/metadata/identity/oauth2/token'; | ||
| expect(() => validateTokenUrl(url)).toThrow(TokenUrlValidationError); | ||
| }); | ||
|
|
||
| // ── Attack vector: localhost / loopback ────────────────────────────── | ||
|
|
||
| test('BLOCKS localhost — internal service access', () => { | ||
| const url = 'https://localhost:6379/'; | ||
| expect(() => validateTokenUrl(url)).toThrow(TokenUrlValidationError); | ||
| expect(() => validateTokenUrl(url)).toThrow(/blocked/); | ||
| }); | ||
|
|
||
| test('BLOCKS 127.x.x.x loopback range', () => { | ||
| expect(() => validateTokenUrl('https://127.0.0.1/token')).toThrow( | ||
| /private\/reserved IP/, | ||
| ); | ||
| expect(() => validateTokenUrl('https://127.0.0.53/token')).toThrow( | ||
| /private\/reserved IP/, | ||
| ); | ||
| }); | ||
|
|
||
| // ── Attack vector: RFC 1918 private networks ──────────────────────── | ||
|
|
||
| test('BLOCKS 10.x.x.x private range — internal network scanning', () => { | ||
| expect(() => validateTokenUrl('https://10.0.0.1:8500/v1/kv/')).toThrow( | ||
| /private\/reserved IP/, | ||
| ); | ||
| }); | ||
|
|
||
| test('BLOCKS 172.16-31.x.x private range', () => { | ||
| expect(() => validateTokenUrl('https://172.16.0.1/token')).toThrow( | ||
| /private\/reserved IP/, | ||
| ); | ||
| expect(() => validateTokenUrl('https://172.31.255.255/token')).toThrow( | ||
| /private\/reserved IP/, | ||
| ); | ||
| // 172.32.x.x is NOT private — should pass | ||
| expect(() => validateTokenUrl('https://172.32.0.1/token')).not.toThrow(); | ||
| }); | ||
|
|
||
| test('BLOCKS 192.168.x.x private range', () => { | ||
| expect(() => validateTokenUrl('https://192.168.1.1/token')).toThrow( | ||
| /private\/reserved IP/, | ||
| ); | ||
| }); | ||
|
|
||
| // ── Attack vector: Credential exfiltration to attacker server ─────── | ||
|
|
||
| test('ALLOWS legitimate HTTPS tokenUrl (positive case)', () => { | ||
| expect(() => | ||
| validateTokenUrl('https://accounts.google.com/o/oauth2/token'), | ||
| ).not.toThrow(); | ||
| expect(() => | ||
| validateTokenUrl('https://github.com/login/oauth/access_token'), | ||
| ).not.toThrow(); | ||
| expect(() => | ||
| validateTokenUrl( | ||
| 'https://login.microsoftonline.com/common/oauth2/v2.0/token', | ||
| ), | ||
| ).not.toThrow(); | ||
| expect(() => | ||
| validateTokenUrl('https://slack.com/api/oauth.v2.access'), | ||
| ).not.toThrow(); | ||
| }); | ||
|
|
||
| // ── Attack vector: Invalid/garbage URLs ───────────────────────────── | ||
|
|
||
| test('BLOCKS unparseable URLs', () => { | ||
| expect(() => validateTokenUrl('not-a-url')).toThrow( | ||
| TokenUrlValidationError, | ||
| ); | ||
| expect(() => validateTokenUrl('')).toThrow(TokenUrlValidationError); | ||
| }); | ||
|
|
||
| // ── Attack vector: IPv6 loopback ──────────────────────────────────── | ||
|
|
||
| test('BLOCKS IPv6 loopback [::1]', () => { | ||
| expect(() => validateTokenUrl('https://[::1]/token')).toThrow( | ||
| TokenUrlValidationError, | ||
| ); | ||
| }); | ||
|
Comment on lines
+129
to
+133
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 2042 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- changed test ---'
sed -n '100,145p' packages/corsair/tests/ssrf-dos-poc.test.ts
printf '%s\n' '--- validator ---'
sed -n '1,240p' packages/corsair/core/auth/url-validator.ts
printf '%s\n' '--- validator references ---'
rg -n "validateTokenUrl|TokenUrlValidationError|isPrivate|IPv6|hostname" packages/corsair/core packages/corsair/testsRepository: corsairdev/corsair Length of output: 10861 Add a public IPv6 token URL case.
🤖 Prompt for AI Agents |
||
|
|
||
| // ── Attack vector: 0.x.x.x current-network ───────────────────────── | ||
|
|
||
| test('BLOCKS 0.x.x.x current-network range', () => { | ||
| expect(() => validateTokenUrl('https://0.0.0.0/token')).toThrow( | ||
| /private\/reserved IP/, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // ───────────────────────────────────────────────────────────────────────────── | ||
| // PoC #2 — Unbounded OAuth response body → OOM DoS | ||
| // ───────────────────────────────────────────────────────────────────────────── | ||
|
|
||
| describe('PoC #2: Unbounded response body — OOM DoS', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| const fs = require('node:fs'); | ||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| const path = require('node:path'); | ||
|
|
||
| test('exchange.ts caps response body to prevent OOM', () => { | ||
| const src = fs.readFileSync( | ||
| path.join(__dirname, '..', 'core', 'auth', 'exchange.ts'), | ||
| 'utf8', | ||
| ); | ||
| expect(src).toContain('MAX_RESPONSE_BYTES'); | ||
| expect(src).toContain('1024 * 1024'); | ||
| expect(src).toContain('Token exchange response exceeded maximum size'); | ||
| }); | ||
|
Comment on lines
+154
to
+162
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟡 Minor | ⚡ Quick win Denial of Service (CWE-400): Uncontrolled Resource Consumption Exploitability: Moderate Count response bytes before appending chunks.
🧰 Tools🪛 ast-grep (0.45.2)[warning] 154-157: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use. (detect-non-literal-fs-filename-typescript) 🤖 Prompt for AI Agents |
||
|
|
||
| test('oauth-refresh-local.ts caps response body to prevent OOM', () => { | ||
| const src = fs.readFileSync( | ||
| path.join(__dirname, '..', 'core', 'auth', 'oauth-refresh-local.ts'), | ||
| 'utf8', | ||
| ); | ||
| expect(src).toContain('MAX_TOKEN_RESPONSE_BYTES'); | ||
| expect(src).toContain('1024 * 1024'); | ||
| expect(src).toContain('exceeded maximum size'); | ||
|
Comment on lines
+164
to
+171
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | 🏗️ Heavy lift Denial of Service (CWE-400): Uncontrolled Resource Consumption Test a bounded response read.
🧰 Tools🪛 ast-grep (0.45.2)[warning] 166-169: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use. (detect-non-literal-fs-filename-typescript) 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| test('SSRF guard runs before fetch in oauth-refresh-local', () => { | ||
| const src = fs.readFileSync( | ||
| path.join(__dirname, '..', 'core', 'auth', 'oauth-refresh-local.ts'), | ||
| 'utf8', | ||
| ); | ||
| const validatePos = src.indexOf('validateTokenUrl('); | ||
| const fetchPos = src.indexOf('await fetch('); | ||
| expect(validatePos).toBeGreaterThan(-1); | ||
| expect(fetchPos).toBeGreaterThan(-1); | ||
| expect(validatePos).toBeLessThan(fetchPos); | ||
| }); | ||
|
|
||
| test('SSRF guard replaces raw URL parsing in exchange.ts', () => { | ||
| const src = fs.readFileSync( | ||
| path.join(__dirname, '..', 'core', 'auth', 'exchange.ts'), | ||
| 'utf8', | ||
| ); | ||
| expect(src).toContain('validateTokenUrl(oauthConfig.tokenUrl)'); | ||
| expect(src).not.toContain('new URL(oauthConfig.tokenUrl)'); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The calculation adds
data.length, measured in UTF-16 code units, tochunk.length, measured in bytes. A non-ASCII response can therefore exceed the advertised one-megabyte byte limit several times over before rejection. Track a separate accumulated byte count and decode only after the bounded stream completes.