@@ -114,6 +114,7 @@ use lambda_http::Body;
114114pub use lambda_http:: Error ;
115115use lambda_http:: { Request , RequestExt , Response } ;
116116use readiness:: Checkpoint ;
117+ use std:: borrow:: Cow ;
117118use std:: fmt:: Debug ;
118119use std:: {
119120 env,
@@ -475,6 +476,33 @@ fn parse_status_codes(input: &str) -> Vec<u16> {
475476 . collect ( )
476477}
477478
479+ /// Returns `s` with bytes that `http::HeaderValue` rejects removed.
480+ ///
481+ /// RFC 7230 limits header field values to visible ASCII plus SP/HTAB; bytes
482+ /// `< 0x20` (except `\t` = 0x09) and DEL (`0x7F`) are forbidden. The
483+ /// `x-amzn-request-context` and `x-amzn-lambda-context` headers carry
484+ /// JSON serialized from the Lambda event, which can echo arbitrary bytes
485+ /// from the original request path. Without this, a request whose path
486+ /// contains control bytes (e.g. from a security scanner) would fail the
487+ /// whole invocation with `InvalidHeaderValue`.
488+ ///
489+ /// Returns `Cow::Borrowed` when no forbidden byte is present (the common
490+ /// case), avoiding any allocation.
491+ fn strip_forbidden_header_bytes ( s : & str ) -> Cow < ' _ , [ u8 ] > {
492+ let bytes = s. as_bytes ( ) ;
493+ if bytes. iter ( ) . all ( |& b| b == b'\t' || ( b >= 0x20 && b != 0x7F ) ) {
494+ Cow :: Borrowed ( bytes)
495+ } else {
496+ Cow :: Owned (
497+ bytes
498+ . iter ( )
499+ . copied ( )
500+ . filter ( |& b| b == b'\t' || ( b >= 0x20 && b != 0x7F ) )
501+ . collect ( ) ,
502+ )
503+ }
504+ }
505+
478506/// The Lambda Web Adapter.
479507///
480508/// This is the main struct that handles forwarding Lambda events to your web application.
@@ -917,7 +945,11 @@ impl Adapter<HttpConnector, Body> {
917945
918946 // strip away Base Path if environment variable REMOVE_BASE_PATH is set.
919947 if let Some ( base_path) = self . base_path . as_deref ( ) {
920- path = path. trim_start_matches ( base_path) ;
948+ let stripped = path. trim_start_matches ( base_path) ;
949+ if stripped. len ( ) != path. len ( ) {
950+ tracing:: debug!( base_path = %base_path, original = %path, stripped = %stripped, "stripped base path" ) ;
951+ }
952+ path = stripped;
921953 }
922954
923955 if matches ! ( request_context, RequestContext :: PassThrough ) && parts. method == Method :: POST {
@@ -929,13 +961,13 @@ impl Adapter<HttpConnector, Body> {
929961 // include request context in http header "x-amzn-request-context"
930962 req_headers. insert (
931963 HeaderName :: from_static ( "x-amzn-request-context" ) ,
932- HeaderValue :: from_bytes ( serde_json:: to_string ( & request_context) ?. as_bytes ( ) ) ?,
964+ HeaderValue :: from_bytes ( & strip_forbidden_header_bytes ( & serde_json:: to_string ( & request_context) ?) ) ?,
933965 ) ;
934966
935967 // include lambda context in http header "x-amzn-lambda-context"
936968 req_headers. insert (
937969 HeaderName :: from_static ( "x-amzn-lambda-context" ) ,
938- HeaderValue :: from_bytes ( serde_json:: to_string ( & lambda_context) ?. as_bytes ( ) ) ?,
970+ HeaderValue :: from_bytes ( & strip_forbidden_header_bytes ( & serde_json:: to_string ( & lambda_context) ?) ) ?,
939971 ) ;
940972
941973 // Multi-tenancy support: propagate tenant_id from Lambda context
@@ -1402,4 +1434,127 @@ mod tests {
14021434 let response = adapter. fetch_response ( request) . await . expect ( "Request failed" ) ;
14031435 assert_eq ! ( 200 , response. status( ) . as_u16( ) ) ;
14041436 }
1437+
1438+ #[ test]
1439+ fn test_strip_forbidden_header_bytes ( ) {
1440+ // Tab (0x09) and printable ASCII are preserved; CR/LF, NUL, DEL, and other
1441+ // C0 control bytes are removed.
1442+ let out = strip_forbidden_header_bytes ( "a\t b\n c\r d\u{00} e\u{04} f\u{18} g\u{7f} h" ) ;
1443+ assert_eq ! ( out. as_ref( ) , b"a\t bcdefgh" ) ;
1444+ assert ! (
1445+ matches!( out, Cow :: Owned ( _) ) ,
1446+ "input had forbidden bytes — must allocate"
1447+ ) ;
1448+
1449+ // UTF-8 multi-byte characters are preserved (all continuation bytes >= 0x80
1450+ // and lead bytes >= 0xC0 are above the 0x7F threshold).
1451+ let out = strip_forbidden_header_bytes ( "héllo" ) ;
1452+ assert_eq ! ( out. as_ref( ) , "héllo" . as_bytes( ) ) ;
1453+ }
1454+
1455+ /// Fast path: input that is already header-safe must not allocate.
1456+ #[ test]
1457+ fn test_strip_forbidden_header_bytes_all_clean ( ) {
1458+ let input = r#"{"http":{"path":"/api/users"},"requestId":"abc-123"}"# ;
1459+ let out = strip_forbidden_header_bytes ( input) ;
1460+ assert_eq ! ( out. as_ref( ) , input. as_bytes( ) ) ;
1461+ assert ! (
1462+ matches!( out, Cow :: Borrowed ( _) ) ,
1463+ "header-safe input must not allocate (Cow::Borrowed expected)"
1464+ ) ;
1465+
1466+ // Tab is allowed and should also stay on the borrowed fast path.
1467+ let input = "tab\t here" ;
1468+ let out = strip_forbidden_header_bytes ( input) ;
1469+ assert ! ( matches!( out, Cow :: Borrowed ( _) ) ) ;
1470+ assert_eq ! ( out. as_ref( ) , input. as_bytes( ) ) ;
1471+ }
1472+
1473+ /// Regression test for https://github.com/aws/aws-lambda-web-adapter/issues/732
1474+ ///
1475+ /// When the Lambda event's request context contains bytes that are forbidden in
1476+ /// HTTP header values (control bytes < 0x20 except \t, and 0x7F), serializing
1477+ /// the request context to JSON and inserting it as `x-amzn-request-context`
1478+ /// must not fail. Such bytes can appear when scanners (e.g. nuclei) probe a
1479+ /// Lambda Function URL with crafted paths.
1480+ #[ tokio:: test]
1481+ async fn test_request_context_with_control_bytes_in_path ( ) {
1482+ let app_server = MockServer :: start ( ) ;
1483+ app_server. mock ( |when, then| {
1484+ when. method ( GET ) . is_true ( |req| {
1485+ let headers = req. headers ( ) ;
1486+
1487+ // --- x-amzn-request-context: this is where the control bytes
1488+ // came from (echoed via request_context.http.path).
1489+ let Some ( req_ctx) = headers. get ( "x-amzn-request-context" ) else {
1490+ return false ;
1491+ } ;
1492+ if req_ctx
1493+ . as_bytes ( )
1494+ . iter ( )
1495+ . any ( |& b| b != b'\t' && ( b < 0x20 || b == 0x7F ) )
1496+ {
1497+ return false ;
1498+ }
1499+ // Stripped JSON must deserialize back into the typed RequestContext
1500+ // (not just generic JSON) — proving the structure consumers rely on
1501+ // survives sanitization.
1502+ let Ok ( ctx) = serde_json:: from_slice :: < RequestContext > ( req_ctx. as_bytes ( ) ) else {
1503+ return false ;
1504+ } ;
1505+ if !matches ! ( ctx, RequestContext :: ApiGatewayV2 ( _) ) {
1506+ return false ;
1507+ }
1508+
1509+ // --- x-amzn-lambda-context: parallel assertion — the second
1510+ // call site also goes through strip_forbidden_header_bytes, so
1511+ // the header must be present, header-safe, and round-trip into
1512+ // a Context value.
1513+ let Some ( lambda_ctx) = headers. get ( "x-amzn-lambda-context" ) else {
1514+ return false ;
1515+ } ;
1516+ if lambda_ctx
1517+ . as_bytes ( )
1518+ . iter ( )
1519+ . any ( |& b| b != b'\t' && ( b < 0x20 || b == 0x7F ) )
1520+ {
1521+ return false ;
1522+ }
1523+ serde_json:: from_slice :: < serde_json:: Value > ( lambda_ctx. as_bytes ( ) )
1524+ . ok ( )
1525+ . and_then ( |v| v. get ( "request_id" ) . and_then ( |r| r. as_str ( ) ) . map ( str:: to_owned) )
1526+ . is_some ( )
1527+ } ) ;
1528+ then. status ( 200 ) . body ( "OK" ) ;
1529+ } ) ;
1530+
1531+ let options = AdapterOptions {
1532+ host : app_server. host ( ) ,
1533+ port : app_server. port ( ) . to_string ( ) ,
1534+ readiness_check_port : app_server. port ( ) . to_string ( ) ,
1535+ readiness_check_path : "/" . to_string ( ) ,
1536+ ..Default :: default ( )
1537+ } ;
1538+
1539+ let adapter = Adapter :: new ( & options) . expect ( "Failed to create adapter" ) ;
1540+
1541+ // Build an ApiGatewayV2 request whose request_context.http.path contains
1542+ // control bytes that http::HeaderValue rejects (DEL = 0x7F, plus 0x04, 0x18).
1543+ let v2_req = lambda_http:: request:: LambdaRequest :: ApiGatewayV2 ( {
1544+ use lambda_http:: aws_lambda_events:: apigw:: ApiGatewayV2httpRequest ;
1545+ let mut req = ApiGatewayV2httpRequest :: default ( ) ;
1546+ req. raw_path = Some ( "/hello" . into ( ) ) ;
1547+ req. request_context . http . method = Method :: GET ;
1548+ req. request_context . http . path = Some ( "/\u{04} \u{7f} \u{18} ;{curl,http://test.oast.site}" . into ( ) ) ;
1549+ req
1550+ } ) ;
1551+ let mut request = Request :: from ( v2_req) ;
1552+ request. extensions_mut ( ) . insert ( make_lambda_context ( None ) ) ;
1553+
1554+ let response = adapter
1555+ . fetch_response ( request)
1556+ . await
1557+ . expect ( "Request failed despite control bytes in request context path" ) ;
1558+ assert_eq ! ( 200 , response. status( ) . as_u16( ) ) ;
1559+ }
14051560}
0 commit comments