@@ -24,4 +24,168 @@ describe("requestMatrixJson", () => {
2424 expect ( timeoutSpy ) . toHaveBeenCalledWith ( MAX_TIMER_TIMEOUT_MS ) ;
2525 expect ( fetchImpl ) . toHaveBeenCalledWith ( expect . any ( URL ) , expect . objectContaining ( { signal } ) ) ;
2626 } ) ;
27+
28+ it ( "fails closed when the homeserver streams an over-cap response body" , async ( ) => {
29+ // Stream past the 16 MiB cap one chunk at a time so the fixture proves the
30+ // bound trips on the prefix and cancels the body instead of buffering it
31+ // all. `cancel()` flipping `canceled` is what real-behavior fail-closed
32+ // looks like: the stream is torn down, not drained.
33+ const chunkSize = 1024 * 1024 ;
34+ const chunkCount = 32 ; // 32 MiB total, well past the 16 MiB limit
35+ let reads = 0 ;
36+ let canceled = false ;
37+ const encoder = new TextEncoder ( ) ;
38+ const stream = new ReadableStream < Uint8Array > ( {
39+ pull ( controller ) {
40+ reads += 1 ;
41+ controller . enqueue ( encoder . encode ( "a" . repeat ( chunkSize ) ) ) ;
42+ if ( reads >= chunkCount ) {
43+ controller . close ( ) ;
44+ }
45+ } ,
46+ cancel ( ) {
47+ canceled = true ;
48+ } ,
49+ } ) ;
50+ const fetchImpl = vi . fn < MatrixQaFetchLike > (
51+ async ( ) =>
52+ new Response ( stream , {
53+ status : 200 ,
54+ headers : { "content-type" : "application/json" } ,
55+ } ) ,
56+ ) ;
57+
58+ await expect (
59+ requestMatrixJson ( {
60+ baseUrl : "https://matrix.example.test" ,
61+ endpoint : "/_matrix/client/v3/sync" ,
62+ fetchImpl,
63+ method : "GET" ,
64+ } ) ,
65+ ) . rejects . toThrow ( / M a t r i x h o m e s e r v e r r e s p o n s e e x c e e d s 1 6 7 7 7 2 1 6 b y t e s / ) ;
66+
67+ // Fail-closed proof: the read stopped before draining all 32 chunks and the
68+ // stream was canceled rather than fully buffered.
69+ expect ( canceled ) . toBe ( true ) ;
70+ expect ( reads ) . toBeLessThan ( chunkCount ) ;
71+ } ) ;
72+
73+ it ( "rejects an oversized error-status body instead of buffering it whole" , async ( ) => {
74+ // Even on a non-2xx status the cap must trip first: an attacker controlling
75+ // the homeserver could otherwise return a 500 with a multi-GiB body knowing
76+ // the helper only inspects `body.error` after fully reading it.
77+ const chunkSize = 1024 * 1024 ;
78+ const chunkCount = 32 ;
79+ let reads = 0 ;
80+ let canceled = false ;
81+ const encoder = new TextEncoder ( ) ;
82+ const stream = new ReadableStream < Uint8Array > ( {
83+ pull ( controller ) {
84+ reads += 1 ;
85+ controller . enqueue ( encoder . encode ( "b" . repeat ( chunkSize ) ) ) ;
86+ if ( reads >= chunkCount ) {
87+ controller . close ( ) ;
88+ }
89+ } ,
90+ cancel ( ) {
91+ canceled = true ;
92+ } ,
93+ } ) ;
94+ const fetchImpl = vi . fn < MatrixQaFetchLike > (
95+ async ( ) =>
96+ new Response ( stream , {
97+ status : 500 ,
98+ headers : { "content-type" : "application/json" } ,
99+ } ) ,
100+ ) ;
101+
102+ await expect (
103+ requestMatrixJson ( {
104+ baseUrl : "https://matrix.example.test" ,
105+ endpoint : "/_matrix/client/v3/sync" ,
106+ fetchImpl,
107+ method : "GET" ,
108+ } ) ,
109+ ) . rejects . toThrow ( / M a t r i x h o m e s e r v e r r e s p o n s e e x c e e d s 1 6 7 7 7 2 1 6 b y t e s / ) ;
110+ expect ( canceled ) . toBe ( true ) ;
111+ expect ( reads ) . toBeLessThan ( chunkCount ) ;
112+ } ) ;
113+
114+ it ( "still falls back to an empty body for malformed in-bounds JSON" , async ( ) => {
115+ const fetchImpl = vi . fn < MatrixQaFetchLike > (
116+ async ( ) =>
117+ new Response ( "{ not valid json" , {
118+ status : 200 ,
119+ headers : { "content-type" : "application/json" } ,
120+ } ) ,
121+ ) ;
122+
123+ const result = await requestMatrixJson < { ok ?: boolean } > ( {
124+ baseUrl : "https://matrix.example.test" ,
125+ endpoint : "/_matrix/client/v3/account/whoami" ,
126+ fetchImpl,
127+ method : "GET" ,
128+ } ) ;
129+
130+ expect ( result . status ) . toBe ( 200 ) ;
131+ expect ( result . body ) . toEqual ( { } ) ;
132+ } ) ;
133+
134+ it ( "treats an empty in-bounds body as an empty object" , async ( ) => {
135+ const fetchImpl = vi . fn < MatrixQaFetchLike > (
136+ async ( ) =>
137+ new Response ( "" , {
138+ status : 200 ,
139+ headers : { "content-type" : "application/json" } ,
140+ } ) ,
141+ ) ;
142+
143+ const result = await requestMatrixJson < Record < string , unknown > > ( {
144+ baseUrl : "https://matrix.example.test" ,
145+ endpoint : "/_matrix/client/v3/account/whoami" ,
146+ fetchImpl,
147+ method : "GET" ,
148+ } ) ;
149+
150+ expect ( result . status ) . toBe ( 200 ) ;
151+ expect ( result . body ) . toEqual ( { } ) ;
152+ } ) ;
153+
154+ it ( "reads a normal in-bounds JSON body unchanged" , async ( ) => {
155+ const fetchImpl = vi . fn < MatrixQaFetchLike > ( async ( ) => Response . json ( { user_id : "@qa:test" } ) ) ;
156+
157+ const result = await requestMatrixJson < { user_id : string } > ( {
158+ baseUrl : "https://matrix.example.test" ,
159+ endpoint : "/_matrix/client/v3/account/whoami" ,
160+ fetchImpl,
161+ method : "GET" ,
162+ } ) ;
163+
164+ expect ( result . status ) . toBe ( 200 ) ;
165+ expect ( result . body ) . toEqual ( { user_id : "@qa:test" } ) ;
166+ } ) ;
167+
168+ it ( "reads an in-bounds body just under the cap without tripping the bound" , async ( ) => {
169+ // Boundary guard: a body close to but under 16 MiB must parse normally so
170+ // the cap does not regress legitimate large-but-valid Matrix responses.
171+ const filler = "x" . repeat ( 8 * 1024 * 1024 ) ; // 8 MiB string, well under cap
172+ const payload = JSON . stringify ( { data : filler } ) ;
173+ const fetchImpl = vi . fn < MatrixQaFetchLike > (
174+ async ( ) =>
175+ new Response ( payload , {
176+ status : 200 ,
177+ headers : { "content-type" : "application/json" } ,
178+ } ) ,
179+ ) ;
180+
181+ const result = await requestMatrixJson < { data : string } > ( {
182+ baseUrl : "https://matrix.example.test" ,
183+ endpoint : "/_matrix/client/v3/sync" ,
184+ fetchImpl,
185+ method : "GET" ,
186+ } ) ;
187+
188+ expect ( result . status ) . toBe ( 200 ) ;
189+ expect ( result . body . data ) . toHaveLength ( filler . length ) ;
190+ } ) ;
27191} ) ;
0 commit comments