@@ -24,6 +24,7 @@ type FetchOptions = {
2424} ;
2525
2626const DEFAULT_FETCH_TIMEOUT_MS = 30_000 ;
27+ const FETCH_RESPONSE_MAX_BYTES = 256 * 1024 ;
2728
2829const mask = ( value : string ) => {
2930 return maskIdentifier (
@@ -124,6 +125,93 @@ const withFetchTimeout = async <T>(
124125 }
125126} ;
126127
128+ const responseBodyTooLargeError = ( label : string , maxBytes : number ) : Error =>
129+ new Error ( `${ label } response body exceeded ${ maxBytes } bytes` ) ;
130+
131+ const readResponseChunk = async (
132+ reader : ReadableStreamDefaultReader < Uint8Array > ,
133+ label : string ,
134+ signal : AbortSignal ,
135+ markCanceled : ( ) => void ,
136+ ) : Promise < ReadableStreamReadResult < Uint8Array > > => {
137+ if ( signal . aborted ) {
138+ markCanceled ( ) ;
139+ await reader . cancel ( ) . catch ( ( ) => undefined ) ;
140+ throw signal . reason instanceof Error ? signal . reason : new Error ( `${ label } request aborted` ) ;
141+ }
142+
143+ let removeAbortListener : ( ( ) => void ) | undefined ;
144+ const abortPromise = new Promise < ReadableStreamReadResult < Uint8Array > > ( ( _resolve , reject ) => {
145+ const onAbort = ( ) => {
146+ markCanceled ( ) ;
147+ void reader . cancel ( ) . catch ( ( ) => undefined ) ;
148+ reject (
149+ signal . reason instanceof Error ? signal . reason : new Error ( `${ label } request aborted` ) ,
150+ ) ;
151+ } ;
152+ signal . addEventListener ( "abort" , onAbort , { once : true } ) ;
153+ removeAbortListener = ( ) => signal . removeEventListener ( "abort" , onAbort ) ;
154+ } ) ;
155+
156+ try {
157+ return await Promise . race ( [ reader . read ( ) , abortPromise ] ) ;
158+ } finally {
159+ removeAbortListener ?.( ) ;
160+ }
161+ } ;
162+
163+ const readBoundedResponseText = async (
164+ response : Response ,
165+ label : string ,
166+ signal : AbortSignal ,
167+ maxBytes = FETCH_RESPONSE_MAX_BYTES ,
168+ ) : Promise < string > => {
169+ const contentLength = Number ( response . headers . get ( "content-length" ) ?? "" ) ;
170+ if ( Number . isSafeInteger ( contentLength ) && contentLength > maxBytes ) {
171+ await response . body ?. cancel ( ) . catch ( ( ) => undefined ) ;
172+ throw responseBodyTooLargeError ( label , maxBytes ) ;
173+ }
174+
175+ if ( ! response . body ) {
176+ return "" ;
177+ }
178+
179+ const reader = response . body . getReader ( ) ;
180+ const decoder = new TextDecoder ( ) ;
181+ const chunks : string [ ] = [ ] ;
182+ let totalBytes = 0 ;
183+ let canceled = false ;
184+
185+ try {
186+ for ( ; ; ) {
187+ const { done, value } = await readResponseChunk ( reader , label , signal , ( ) => {
188+ canceled = true ;
189+ } ) ;
190+ if ( done ) {
191+ const tail = decoder . decode ( ) ;
192+ if ( tail ) {
193+ chunks . push ( tail ) ;
194+ }
195+ break ;
196+ }
197+
198+ totalBytes += value . byteLength ;
199+ if ( totalBytes > maxBytes ) {
200+ canceled = true ;
201+ await reader . cancel ( ) . catch ( ( ) => undefined ) ;
202+ throw responseBodyTooLargeError ( label , maxBytes ) ;
203+ }
204+ chunks . push ( decoder . decode ( value , { stream : true } ) ) ;
205+ }
206+ } finally {
207+ if ( ! canceled ) {
208+ reader . releaseLock ( ) ;
209+ }
210+ }
211+
212+ return chunks . join ( "" ) ;
213+ } ;
214+
127215const fetchText = async (
128216 label : string ,
129217 url : string ,
@@ -134,7 +222,7 @@ const fetchText = async (
134222 const timeoutMs = options . timeoutMs ?? resolveFetchTimeoutMs ( ) ;
135223 return await withFetchTimeout ( label , timeoutMs , async ( signal ) => {
136224 const res = await fetchImpl ( url , { ...init , signal } ) ;
137- const text = await res . text ( ) ;
225+ const text = await readBoundedResponseText ( res , label , signal ) ;
138226 return { res, text } ;
139227 } ) ;
140228} ;
@@ -467,9 +555,11 @@ const main = async () => {
467555export const testing = {
468556 CLAUDE_COOKIE_HOST_SQL ,
469557 CLAUDE_FIREFOX_COOKIE_HOST_SQL ,
558+ FETCH_RESPONSE_MAX_BYTES ,
470559 browserRootLabel,
471560 fetchAnthropicOAuthUsage,
472561 mask,
562+ readBoundedResponseText,
473563 resolveFetchTimeoutMs,
474564} ;
475565
0 commit comments