Skip to content

Commit c870b8c

Browse files
authored
fix: sanitize control bytes in x-amzn-{request,lambda}-context headers (#734)
1 parent a0f1f3c commit c870b8c

1 file changed

Lines changed: 158 additions & 3 deletions

File tree

src/lib.rs

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ use lambda_http::Body;
114114
pub use lambda_http::Error;
115115
use lambda_http::{Request, RequestExt, Response};
116116
use readiness::Checkpoint;
117+
use std::borrow::Cow;
117118
use std::fmt::Debug;
118119
use 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\tb\nc\rd\u{00}e\u{04}f\u{18}g\u{7f}h");
1443+
assert_eq!(out.as_ref(), b"a\tbcdefgh");
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\there";
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

Comments
 (0)