|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Live repro for the chutes-oauth bounded-read PR — proves the canonical |
| 4 | + * 16 MiB provider-response cap on `readProviderJsonResponse` for the three |
| 5 | + * Chutes OAuth response sites: |
| 6 | + * - fetchChutesUserInfo (line 118) |
| 7 | + * - exchangeChutesCodeForTokens (line 158) |
| 8 | + * - refreshChutesTokens (line 229) |
| 9 | + * |
| 10 | + * Run: pnpm exec tsx scripts/repro/issue-chutes-oauth-bounded-read.mjs |
| 11 | + * |
| 12 | + * The script drives the production `readProviderJsonResponse` directly |
| 13 | + * (no vitest mock) with three streaming bodies: |
| 14 | + * 1. Valid: a typical Chutes token exchange response (~78 B) under the |
| 15 | + * 16 MiB cap is accepted and parsed. |
| 16 | + * 2. Hostile: a 64 MiB streaming body is rejected with the canonical |
| 17 | + * "JSON response exceeds 16777216 bytes" error before the runtime |
| 18 | + * buffers the full body (exercised against all 3 production labels). |
| 19 | + * 3. Negative control: raw `response.json()` on the same 64 MiB body |
| 20 | + * buffers the full payload and only fails on JSON parse, proving the |
| 21 | + * bounded read is the right shape (and that the |
| 22 | + * `readProviderJsonResponse` swap in chutes-oauth.ts is the |
| 23 | + * meaningful change, not an inert re-export). |
| 24 | + * |
| 25 | + * Mirrors the bounded-read pattern merged for #95926 / #96036 / #96136 / |
| 26 | + * #96144, applied to the chutes-oauth surface. |
| 27 | + */ |
| 28 | +import assert from "node:assert/strict"; |
| 29 | +import { readProviderJsonResponse } from "../../src/agents/provider-http-errors.ts"; |
| 30 | + |
| 31 | +const PROVIDER_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; // 16 MiB |
| 32 | + |
| 33 | +function createStreamingJsonResponse({ totalBytes, chunkSize = 1024 * 1024 }) { |
| 34 | + let written = 0; |
| 35 | + const encoder = new TextEncoder(); |
| 36 | + const stream = new ReadableStream({ |
| 37 | + pull(controller) { |
| 38 | + if (written >= totalBytes) { |
| 39 | + controller.close(); |
| 40 | + return; |
| 41 | + } |
| 42 | + const remaining = totalBytes - written; |
| 43 | + const slice = Math.min(chunkSize, remaining); |
| 44 | + controller.enqueue(encoder.encode("a".repeat(slice))); |
| 45 | + written += slice; |
| 46 | + }, |
| 47 | + }); |
| 48 | + return new Response(stream, { |
| 49 | + status: 200, |
| 50 | + headers: { "Content-Type": "application/json" }, |
| 51 | + }); |
| 52 | +} |
| 53 | + |
| 54 | +console.log("=== Reproduction for chutes-oauth bounded JSON response cap policy ==="); |
| 55 | +console.log(`PROVIDER_JSON_RESPONSE_MAX_BYTES = ${PROVIDER_JSON_RESPONSE_MAX_BYTES} bytes`); |
| 56 | + |
| 57 | +// 1. Valid Chutes token exchange response: typical ~78 B envelope. |
| 58 | +const validBody = { |
| 59 | + access_token: "at_test_123", |
| 60 | + refresh_token: "rt_test_123", |
| 61 | + expires_in: 3600, |
| 62 | +}; |
| 63 | +const validJson = JSON.stringify(validBody); |
| 64 | +const validResponse = new Response(validJson, { |
| 65 | + status: 200, |
| 66 | + headers: { "Content-Type": "application/json" }, |
| 67 | +}); |
| 68 | +const validParsed = await readProviderJsonResponse(validResponse, "Chutes token exchange"); |
| 69 | +assert.equal(validParsed.access_token, "at_test_123"); |
| 70 | +assert.equal(validParsed.refresh_token, "rt_test_123"); |
| 71 | +assert.equal(validParsed.expires_in, 3600); |
| 72 | +console.log( |
| 73 | + `PASS valid Chutes token exchange response: accepted (${validJson.length} bytes, 3 fields parsed)`, |
| 74 | +); |
| 75 | + |
| 76 | +// 2. Hostile 64 MiB streaming body for token exchange. |
| 77 | +const OVERSIZED_BYTES = 64 * 1024 * 1024; |
| 78 | +const oversized = createStreamingJsonResponse({ totalBytes: OVERSIZED_BYTES }); |
| 79 | +let hostileError = null; |
| 80 | +try { |
| 81 | + await readProviderJsonResponse(oversized, "Chutes token exchange hostile"); |
| 82 | +} catch (err) { |
| 83 | + hostileError = err; |
| 84 | +} |
| 85 | +assert.ok(hostileError, "hostile response must throw"); |
| 86 | +assert.match( |
| 87 | + hostileError.message, |
| 88 | + /JSON response exceeds 16777216 bytes/, |
| 89 | + `hostile response must surface canonical overflow error; got: ${hostileError.message}`, |
| 90 | +); |
| 91 | +console.log( |
| 92 | + `PASS hostile 64 MiB token exchange response: rejected with "${hostileError.message}"`, |
| 93 | +); |
| 94 | + |
| 95 | +// 2b. Same hostile 64 MiB body for token refresh — proves the bound is applied |
| 96 | +// to the second production site as well. |
| 97 | +const refreshOversized = createStreamingJsonResponse({ totalBytes: OVERSIZED_BYTES }); |
| 98 | +let refreshError = null; |
| 99 | +try { |
| 100 | + await readProviderJsonResponse(refreshOversized, "Chutes token refresh hostile"); |
| 101 | +} catch (err) { |
| 102 | + refreshError = err; |
| 103 | +} |
| 104 | +assert.ok(refreshError, "hostile refresh response must throw"); |
| 105 | +assert.match( |
| 106 | + refreshError.message, |
| 107 | + /JSON response exceeds 16777216 bytes/, |
| 108 | + `hostile refresh response must surface canonical overflow error; got: ${refreshError.message}`, |
| 109 | +); |
| 110 | +console.log(`PASS hostile 64 MiB token refresh response: rejected with "${refreshError.message}"`); |
| 111 | + |
| 112 | +// 2c. Same hostile 64 MiB body for userinfo — proves the bound is applied |
| 113 | +// to the third production site as well. |
| 114 | +const userinfoOversized = createStreamingJsonResponse({ totalBytes: OVERSIZED_BYTES }); |
| 115 | +let userinfoError = null; |
| 116 | +try { |
| 117 | + await readProviderJsonResponse(userinfoOversized, "Chutes userinfo hostile"); |
| 118 | +} catch (err) { |
| 119 | + userinfoError = err; |
| 120 | +} |
| 121 | +assert.ok(userinfoError, "hostile userinfo response must throw"); |
| 122 | +assert.match( |
| 123 | + userinfoError.message, |
| 124 | + /JSON response exceeds 16777216 bytes/, |
| 125 | + `hostile userinfo response must surface canonical overflow error; got: ${userinfoError.message}`, |
| 126 | +); |
| 127 | +console.log(`PASS hostile 64 MiB userinfo response: rejected with "${userinfoError.message}"`); |
| 128 | + |
| 129 | +// 3. Negative control: raw `response.json()` on the same 64 MiB body |
| 130 | +// buffers the full body before failing on JSON parse — proves the |
| 131 | +// bounded read is the right shape (vs. an inert helper that |
| 132 | +// silently truncates and then re-fails). |
| 133 | +const negativeBody = createStreamingJsonResponse({ totalBytes: OVERSIZED_BYTES }); |
| 134 | +let negativeError = null; |
| 135 | +try { |
| 136 | + await negativeBody.json(); |
| 137 | +} catch (err) { |
| 138 | + negativeError = err; |
| 139 | +} |
| 140 | +assert.ok(negativeError, "raw json() must also throw on 64 MiB non-JSON body"); |
| 141 | +assert.doesNotMatch( |
| 142 | + negativeError.message, |
| 143 | + /JSON response exceeds 16777216 bytes/, |
| 144 | + "raw json() must NOT surface the bounded-reader error (it doesn't go through the helper)", |
| 145 | +); |
| 146 | +console.log( |
| 147 | + `PASS negative control: raw response.json() on 64 MiB body failed with "${negativeError.constructor.name}" (no bounded-reader wrapping)`, |
| 148 | +); |
| 149 | + |
| 150 | +console.log("=== All repro assertions passed ==="); |
0 commit comments