@@ -560,11 +560,17 @@ where
560560 // route chunked requests to the streaming path, but
561561 // guard here as defense-in-depth.
562562 let body_length = parse_body_length ( header_str) ?;
563- if matches ! ( body_length, BodyLength :: Chunked ) {
564- return Err ( miette ! (
565- "SigV4 body signing requires Content-Length; \
566- chunked transfer encoding is not supported in this mode"
567- ) ) ;
563+ match body_length {
564+ BodyLength :: ContentLength ( _) => { }
565+ BodyLength :: Chunked => {
566+ return Err ( miette ! (
567+ "SigV4 body signing requires Content-Length; \
568+ chunked transfer encoding is not supported in this mode"
569+ ) ) ;
570+ }
571+ BodyLength :: None => {
572+ return Err ( miette ! ( "SigV4 body signing requires Content-Length" ) ) ;
573+ }
568574 }
569575 // NOTE(defense-in-depth): Build the full request from
570576 // rewritten headers + body. `rewrite_result.rewritten`
@@ -5262,6 +5268,87 @@ mod tests {
52625268 assert ! ( !forwarded. contains( "openshell:resolve:env:" ) ) ;
52635269 }
52645270
5271+ #[ tokio:: test]
5272+ async fn relay_sigv4_body_signing_rejects_missing_content_length ( ) {
5273+ let ( _, resolver) = SecretResolver :: from_provider_env (
5274+ [
5275+ ( "AWS_ACCESS_KEY_ID" . to_string ( ) , "AKIATESTKEY" . to_string ( ) ) ,
5276+ (
5277+ "AWS_SECRET_ACCESS_KEY" . to_string ( ) ,
5278+ "test-secret-key" . to_string ( ) ,
5279+ ) ,
5280+ ]
5281+ . into_iter ( )
5282+ . collect ( ) ,
5283+ ) ;
5284+ let resolver = resolver. expect ( "resolver" ) ;
5285+
5286+ let raw_header =
5287+ b"PUT /bucket/key HTTP/1.1\r \n Host: s3.us-east-1.amazonaws.com\r \n \r \n " . to_vec ( ) ;
5288+ let req = L7Request {
5289+ action : "PUT" . to_string ( ) ,
5290+ target : "/bucket/key" . to_string ( ) ,
5291+ query_params : HashMap :: new ( ) ,
5292+ raw_header,
5293+ body_length : BodyLength :: None ,
5294+ } ;
5295+ let ( mut proxy_to_upstream, mut upstream_side) = tokio:: io:: duplex ( 8192 ) ;
5296+ let ( mut _app_side, mut proxy_to_client) = tokio:: io:: duplex ( 8192 ) ;
5297+
5298+ let upstream_task = tokio:: spawn ( async move {
5299+ let mut buf = vec ! [ 0u8 ; 8192 ] ;
5300+ let mut total = 0usize ;
5301+ loop {
5302+ let n = upstream_side. read ( & mut buf[ total..] ) . await . unwrap ( ) ;
5303+ if n == 0 {
5304+ break ;
5305+ }
5306+ total += n;
5307+ if buf[ ..total] . windows ( 4 ) . any ( |w| w == b"\r \n \r \n " ) {
5308+ upstream_side
5309+ . write_all ( b"HTTP/1.1 200 OK\r \n Content-Length: 2\r \n \r \n ok" )
5310+ . await
5311+ . unwrap ( ) ;
5312+ upstream_side. flush ( ) . await . unwrap ( ) ;
5313+ break ;
5314+ }
5315+ }
5316+ String :: from_utf8_lossy ( & buf[ ..total] ) . to_string ( )
5317+ } ) ;
5318+
5319+ let relay_result = tokio:: time:: timeout (
5320+ std:: time:: Duration :: from_secs ( 5 ) ,
5321+ relay_http_request_with_options_guarded (
5322+ & req,
5323+ & mut proxy_to_client,
5324+ & mut proxy_to_upstream,
5325+ RelayRequestOptions {
5326+ resolver : Some ( & resolver) ,
5327+ credential_signing : crate :: l7:: CredentialSigning :: SigV4Body ,
5328+ signing_service : "s3" ,
5329+ signing_region : "us-east-1" ,
5330+ host : "s3.us-east-1.amazonaws.com" ,
5331+ port : 443 ,
5332+ ..Default :: default ( )
5333+ } ,
5334+ ) ,
5335+ )
5336+ . await
5337+ . expect ( "relay must not deadlock" ) ;
5338+ drop ( proxy_to_upstream) ;
5339+ let forwarded = upstream_task. await . expect ( "upstream task should complete" ) ;
5340+
5341+ let err = relay_result. expect_err ( "missing Content-Length should fail closed" ) ;
5342+ assert ! (
5343+ err. to_string( ) . contains( "requires Content-Length" ) ,
5344+ "unexpected error: {err}"
5345+ ) ;
5346+ assert ! (
5347+ forwarded. is_empty( ) ,
5348+ "request missing Content-Length must not reach upstream: {forwarded}"
5349+ ) ;
5350+ }
5351+
52655352 #[ tokio:: test]
52665353 async fn relay_injects_url_path_credential_telegram_style ( ) {
52675354 let ( child_env, resolver) = SecretResolver :: from_provider_env (
0 commit comments