Skip to content

Commit 58b754a

Browse files
committed
Resolve PR 624 round-6 review findings
Blocking fixes: - Restore drop(streaming_body) on error path so client sees EOF/truncated response instead of a silently completed partial response (cd4c621 regression) - Add HandlerOutcome::AuthChallenge variant to distinguish our enforce_basic_auth 401s from origin-forwarded 401s; gate geo lookup on AuthChallenge only so origin-forwarded 401s still carry geo headers Non-blocking fixes: - Fix stream_publisher_body doc: remove incorrect async claim; function is sync - Clarify to_fastly_request_ref doc: non-empty body is a caller error - Add enforce_max_body_size helper in http_util; replace 6 duplicated body-size cap blocks across proxy, auction, and request_signing endpoints - Add test for to_fastly_response_skeleton covering status + header round-trip - Restore "bytes" unit in enforce_max_body_size error message for log clarity - Widen enforce_max_body_size what param from &'static str to &str
1 parent b30581c commit 58b754a

7 files changed

Lines changed: 88 additions & 71 deletions

File tree

crates/trusted-server-adapter-fastly/src/main.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,13 @@ use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}
4848
///
4949
/// The streaming arm keeps the publisher body out of WASM heap until it is written directly
5050
/// to the client via [`fastly::Response::stream_to_client`]. All other routes are buffered.
51+
///
52+
/// [`AuthChallenge`](HandlerOutcome::AuthChallenge) marks responses produced by this server's
53+
/// own `enforce_basic_auth` so the geo-lookup gate can distinguish them from origin-forwarded
54+
/// 401s, which should still carry geo headers.
5155
enum HandlerOutcome {
5256
Buffered(HttpResponse),
57+
AuthChallenge(HttpResponse),
5358
Streaming {
5459
response: HttpResponse,
5560
body: EdgeBody,
@@ -58,9 +63,10 @@ enum HandlerOutcome {
5863
}
5964

6065
impl HandlerOutcome {
66+
#[cfg(test)]
6167
fn status(&self) -> edgezero_core::http::StatusCode {
6268
match self {
63-
HandlerOutcome::Buffered(resp) => resp.status(),
69+
HandlerOutcome::Buffered(resp) | HandlerOutcome::AuthChallenge(resp) => resp.status(),
6470
HandlerOutcome::Streaming { response, .. } => response.status(),
6571
}
6672
}
@@ -142,8 +148,10 @@ fn main() {
142148
))
143149
.unwrap_or_else(|e| HandlerOutcome::Buffered(http_error_response(&e)));
144150

145-
// Skip geo lookup for 401s: avoids exposing geo headers to unauthenticated callers.
146-
let geo_info = if outcome.status() == edgezero_core::http::StatusCode::UNAUTHORIZED {
151+
// Skip geo lookup for our own auth challenges: avoids exposing geo headers to
152+
// unauthenticated callers. Origin-forwarded 401s are not AuthChallenge and
153+
// do receive geo headers — the client already reached the origin anyway.
154+
let geo_info = if matches!(outcome, HandlerOutcome::AuthChallenge(_)) {
147155
None
148156
} else {
149157
runtime_services
@@ -156,7 +164,7 @@ fn main() {
156164
};
157165

158166
match outcome {
159-
HandlerOutcome::Buffered(mut response) => {
167+
HandlerOutcome::Buffered(mut response) | HandlerOutcome::AuthChallenge(mut response) => {
160168
finalize_response(&settings, geo_info.as_ref(), &mut response);
161169
compat::to_fastly_response(response).send_to_client();
162170
}
@@ -182,9 +190,9 @@ fn main() {
182190
}
183191
Err(e) => {
184192
log::error!("streaming processing failed: {e:?}");
185-
if let Err(finish_err) = streaming_body.finish() {
186-
log::error!("failed to finish streaming body after error: {finish_err}");
187-
}
193+
// Headers already committed. Drop the body so the client sees a
194+
// truncated response (EOF mid-stream) — standard proxy behavior.
195+
drop(streaming_body);
188196
}
189197
}
190198
}
@@ -244,7 +252,7 @@ async fn route_request(
244252
// Keep this fallback so manually-constructed or otherwise unprepared
245253
// settings still become an error response instead of panicking.
246254
match enforce_basic_auth(settings, &req) {
247-
Ok(Some(response)) => return Ok(HandlerOutcome::Buffered(response)),
255+
Ok(Some(response)) => return Ok(HandlerOutcome::AuthChallenge(response)),
248256
Ok(None) => {}
249257
Err(e) => return Err(e),
250258
}

crates/trusted-server-core/src/auction/endpoints.rs

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::consent;
1010
use crate::cookies::handle_request_cookies;
1111
use crate::edge_cookie::get_or_generate_ec_id_from_http_request;
1212
use crate::error::TrustedServerError;
13+
use crate::http_util::enforce_max_body_size;
1314
use crate::platform::RuntimeServices;
1415
use crate::settings::Settings;
1516

@@ -42,15 +43,7 @@ pub async fn handle_auction(
4243

4344
// Parse request body
4445
let body_bytes = body.into_bytes();
45-
if body_bytes.len() > AUCTION_MAX_BODY_BYTES {
46-
return Err(Report::new(TrustedServerError::RequestTooLarge {
47-
message: format!(
48-
"auction payload {} exceeds limit of {}",
49-
body_bytes.len(),
50-
AUCTION_MAX_BODY_BYTES,
51-
),
52-
}));
53-
}
46+
enforce_max_body_size(&body_bytes, AUCTION_MAX_BODY_BYTES, "auction")?;
5447
let body: AdRequest =
5548
serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction {
5649
message: "Failed to parse auction request body".to_string(),

crates/trusted-server-core/src/compat.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ pub fn to_fastly_request(req: http::Request<EdgeBody>) -> fastly::Request {
9696

9797
/// Convert a borrowed `http::Request<EdgeBody>` into a `fastly::Request`.
9898
///
99-
/// Headers, method, and URI are copied; the body is empty.
99+
/// Headers, method, and URI are copied; the body is always empty. This function
100+
/// requires that the caller has already consumed or discarded the body — passing
101+
/// a request with a non-empty body is a caller error: the body bytes will be
102+
/// silently lost with no warning or panic.
100103
///
101104
/// # PR 15 removal target
102105
pub fn to_fastly_request_ref(req: &http::Request<EdgeBody>) -> fastly::Request {
@@ -670,6 +673,38 @@ mod tests {
670673
);
671674
}
672675

676+
#[test]
677+
fn to_fastly_response_skeleton_copies_status_and_headers_discards_body() {
678+
let http_resp = http::Response::builder()
679+
.status(206)
680+
.header("content-type", "text/html; charset=utf-8")
681+
.header("x-custom", "value")
682+
.body(EdgeBody::from(b"some body bytes".as_ref()))
683+
.expect("should build response");
684+
685+
let fastly_resp = to_fastly_response_skeleton(http_resp);
686+
687+
assert_eq!(
688+
fastly_resp.get_status().as_u16(),
689+
206,
690+
"should copy status code"
691+
);
692+
assert_eq!(
693+
fastly_resp
694+
.get_header("content-type")
695+
.and_then(|v| v.to_str().ok()),
696+
Some("text/html; charset=utf-8"),
697+
"should copy content-type header"
698+
);
699+
assert_eq!(
700+
fastly_resp
701+
.get_header("x-custom")
702+
.and_then(|v| v.to_str().ok()),
703+
Some("value"),
704+
"should copy custom header"
705+
);
706+
}
707+
673708
#[test]
674709
fn to_fastly_request_with_streaming_body_produces_empty_body() {
675710
// Stream bodies cannot cross the compat boundary: the Fastly SDK has no

crates/trusted-server-core/src/http_util.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
22
use chacha20poly1305::{aead::Aead, aead::KeyInit, XChaCha20Poly1305, XNonce};
33
use edgezero_core::body::Body as EdgeBody;
4+
use error_stack::Report;
45
use http::{header, Request, Response, StatusCode};
56
use sha2::{Digest, Sha256};
67
use subtle::ConstantTimeEq as _;
78

89
use crate::constants::INTERNAL_HEADERS;
10+
use crate::error::TrustedServerError;
911
use crate::platform::ClientInfo;
1012
use crate::settings::Settings;
1113

@@ -399,6 +401,27 @@ pub fn compute_encrypted_sha256_token(settings: &Settings, full_url: &str) -> St
399401
URL_SAFE_NO_PAD.encode(digest)
400402
}
401403

404+
/// Return an error if `bytes` exceeds `limit`.
405+
///
406+
/// # Errors
407+
///
408+
/// Returns [`TrustedServerError::RequestTooLarge`] when `bytes.len() > limit`.
409+
pub fn enforce_max_body_size(
410+
bytes: &[u8],
411+
limit: usize,
412+
what: &str,
413+
) -> Result<(), Report<TrustedServerError>> {
414+
if bytes.len() > limit {
415+
return Err(Report::new(TrustedServerError::RequestTooLarge {
416+
message: format!(
417+
"{what} payload {} exceeds limit of {limit} bytes",
418+
bytes.len()
419+
),
420+
}));
421+
}
422+
Ok(())
423+
}
424+
402425
#[cfg(test)]
403426
mod tests {
404427
use super::*;

crates/trusted-server-core/src/proxy.rs

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::backend::DEFAULT_FIRST_BYTE_TIMEOUT;
2-
use crate::http_util::{compute_encrypted_sha256_token, ct_str_eq};
2+
use crate::http_util::{compute_encrypted_sha256_token, ct_str_eq, enforce_max_body_size};
33
use edgezero_core::body::Body as EdgeBody;
44
use edgezero_core::http::{request_builder as edge_request_builder, Uri as EdgeUri};
55
use error_stack::{Report, ResultExt};
@@ -883,15 +883,7 @@ pub async fn handle_first_party_proxy_sign(
883883

884884
let payload = if method == Method::POST {
885885
let body_bytes = req.into_body().into_bytes();
886-
if body_bytes.len() > SIGN_MAX_BODY_BYTES {
887-
return Err(Report::new(TrustedServerError::RequestTooLarge {
888-
message: format!(
889-
"payload size {} exceeds limit of {} bytes",
890-
body_bytes.len(),
891-
SIGN_MAX_BODY_BYTES,
892-
),
893-
}));
894-
}
886+
enforce_max_body_size(&body_bytes, SIGN_MAX_BODY_BYTES, "first-party sign")?;
895887
let body =
896888
std::str::from_utf8(&body_bytes).change_context(TrustedServerError::InvalidUtf8 {
897889
message: "first-party sign request body should be valid UTF-8".to_string(),
@@ -1006,15 +998,7 @@ pub async fn handle_first_party_proxy_rebuild(
1006998
let req_url = req.uri().to_string();
1007999
let payload = if method == Method::POST {
10081000
let body_bytes = req.into_body().into_bytes();
1009-
if body_bytes.len() > REBUILD_MAX_BODY_BYTES {
1010-
return Err(Report::new(TrustedServerError::RequestTooLarge {
1011-
message: format!(
1012-
"payload size {} exceeds limit of {} bytes",
1013-
body_bytes.len(),
1014-
REBUILD_MAX_BODY_BYTES,
1015-
),
1016-
}));
1017-
}
1001+
enforce_max_body_size(&body_bytes, REBUILD_MAX_BODY_BYTES, "first-party rebuild")?;
10181002
let body =
10191003
std::str::from_utf8(&body_bytes).change_context(TrustedServerError::InvalidUtf8 {
10201004
message: "first-party rebuild request body should be valid UTF-8".to_string(),

crates/trusted-server-core/src/publisher.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -396,13 +396,10 @@ pub struct OwnedProcessResponseParams {
396396

397397
/// Stream the publisher response body through the processing pipeline.
398398
///
399-
/// Called by the adapter after `stream_to_client()` has committed the
400-
/// response headers. Writes processed chunks directly to `output`.
401-
///
402-
/// This is `async` because it uses `services.http_client().send(...).await` rather
403-
/// than the synchronous Fastly SDK `req.send()`. The only caller wraps the entire
404-
/// route handler in `block_on`, so behavior is equivalent — the change reflects the
405-
/// migration to the platform-agnostic HTTP client.
399+
/// Called by the adapter after `stream_to_client()` has committed the response
400+
/// headers. Runs synchronously against an already-materialised body; the async
401+
/// I/O happened upstream in [`handle_publisher_request`]. Writes processed
402+
/// chunks directly to `output`.
406403
///
407404
/// # Errors
408405
///

crates/trusted-server-core/src/request_signing/endpoints.rs

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use http::{header, Request, Response, StatusCode};
99
use serde::{Deserialize, Serialize};
1010

1111
use crate::error::{IntoHttpResponse, TrustedServerError};
12+
use crate::http_util::enforce_max_body_size;
1213
use crate::platform::RuntimeServices;
1314
use crate::request_signing::discovery::TrustedServerDiscovery;
1415
use crate::request_signing::rotation::KeyRotationManager;
@@ -100,15 +101,7 @@ pub fn handle_verify_signature(
100101
req: Request<EdgeBody>,
101102
) -> Result<Response<EdgeBody>, Report<TrustedServerError>> {
102103
let body = req.into_body().into_bytes();
103-
if body.len() > VERIFY_MAX_BODY_BYTES {
104-
return Err(Report::new(TrustedServerError::RequestTooLarge {
105-
message: format!(
106-
"verify-signature payload {} exceeds limit of {}",
107-
body.len(),
108-
VERIFY_MAX_BODY_BYTES,
109-
),
110-
}));
111-
}
104+
enforce_max_body_size(&body, VERIFY_MAX_BODY_BYTES, "verify-signature")?;
112105
let verify_req: VerifySignatureRequest =
113106
serde_json::from_slice(&body).change_context(TrustedServerError::Configuration {
114107
message: "invalid JSON request body".into(),
@@ -251,15 +244,7 @@ pub fn handle_rotate_key(
251244
} = signing_store_ids(settings)?;
252245

253246
let body = req.into_body().into_bytes();
254-
if body.len() > ADMIN_MAX_BODY_BYTES {
255-
return Err(Report::new(TrustedServerError::RequestTooLarge {
256-
message: format!(
257-
"rotate-key payload {} exceeds limit of {}",
258-
body.len(),
259-
ADMIN_MAX_BODY_BYTES,
260-
),
261-
}));
262-
}
247+
enforce_max_body_size(&body, ADMIN_MAX_BODY_BYTES, "rotate-key")?;
263248
let rotate_req: RotateKeyRequest = if body.is_empty() {
264249
RotateKeyRequest { kid: None }
265250
} else {
@@ -378,15 +363,7 @@ pub fn handle_deactivate_key(
378363
} = signing_store_ids(settings)?;
379364

380365
let body = req.into_body().into_bytes();
381-
if body.len() > ADMIN_MAX_BODY_BYTES {
382-
return Err(Report::new(TrustedServerError::RequestTooLarge {
383-
message: format!(
384-
"deactivate-key payload {} exceeds limit of {}",
385-
body.len(),
386-
ADMIN_MAX_BODY_BYTES,
387-
),
388-
}));
389-
}
366+
enforce_max_body_size(&body, ADMIN_MAX_BODY_BYTES, "deactivate-key")?;
390367
let deactivate_req: DeactivateKeyRequest =
391368
serde_json::from_slice(&body).change_context(TrustedServerError::Configuration {
392369
message: "invalid JSON request body".into(),

0 commit comments

Comments
 (0)