Skip to content

Commit 49a7855

Browse files
avi-starkwareclaude
andcommitted
starknet_transaction_prover: per-request structured log with request-id
Adds `RequestLogLayer`, the outermost middleware that emits one `event="http_request"` log line per request (request_id, method, path, status, latency_ms) and echoes the id on the response. The id is accepted from `x-request-id` when it is a short printable-ASCII token, otherwise a UUID v4 is generated; hostile inputs are dropped to prevent header smuggling and log-field explosion. No request body bytes are inspected (transaction calldata is private user data). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 225e40f commit 49a7855

7 files changed

Lines changed: 298 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ tracing-test = "0.2"
415415
trybuild = "1.0.105"
416416
unsigned-varint = "0.8.0"
417417
url = "2.5.0"
418+
uuid = "1"
418419
validator = "0.20"
419420
void = "1.0.2"
420421
waker-fn = "1.2.0"

crates/starknet_transaction_prover/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ tower_ohttp.workspace = true
5757
tracing.workspace = true
5858
tracing-subscriber = { workspace = true, features = ["env-filter"] }
5959
url.workspace = true
60+
uuid = { workspace = true, features = ["v4"] }
6061

6162
[dev-dependencies]
6263
apollo_starknet_os_program.workspace = true

crates/starknet_transaction_prover/src/server.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ pub mod health;
3333
pub mod log_redact;
3434
#[cfg(test)]
3535
pub mod mock_rpc;
36+
pub mod request_log;
3637
pub mod rpc_api;
3738
pub mod rpc_impl;
3839
pub mod tls;
3940

4041
pub use health::{HealthLayer, HEALTH_PATH};
42+
pub use request_log::{RequestLogLayer, REQUEST_ID_HEADER};
4143

4244
#[cfg(test)]
4345
mod rpc_spec_test;
@@ -79,9 +81,11 @@ pub async fn start_server(
7981
// type it expects. `HttpBody::new` is a zero-cost wrapper, so
8082
// non-OHTTP requests still stream through unbuffered.
8183
.set_http_middleware(
82-
// `HealthLayer` sits outermost so `GET /health` is answered
83-
// before any other middleware runs.
84+
// `RequestLogLayer` is outermost so the latency it measures
85+
// covers every other layer. `HealthLayer` sits inside it so
86+
// probes complete before CORS/OHTTP.
8487
ServiceBuilder::new()
88+
.layer(RequestLogLayer)
8589
.layer(HealthLayer)
8690
.option_layer(cors_layer)
8791
.layer(MapRequestBodyLayer::new(HttpBody::new))
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! tower middleware that logs one structured line per HTTP request and
2+
//! propagates a request id.
3+
//!
4+
//! This is the outermost, envelope-level layer: it emits one log line with
5+
//! `event="http_request"`, `request_id`, `method`, `path`, `status`, and
6+
//! `latency_ms` per HTTP request, and echoes `request_id` on the response so
7+
//! callers can quote it. The id is accepted from the incoming `x-request-id`
8+
//! header or generated as a UUID v4.
9+
//!
10+
//! It deliberately does NOT bind the id to a span covering the downstream
11+
//! dispatch. For OHTTP traffic this layer runs on the *outer* envelope, whose
12+
//! id is visible to the relay (echoed on the ciphertext response). Propagating
13+
//! that id into the logs describing the *decapsulated* contents would create a
14+
//! join key linking the relay's view (who) to the gateway's view (what),
15+
//! defeating OHTTP unlinkability. Content-level correlation — a fresh,
16+
//! envelope-unlinkable id bound below the OHTTP layer — is added by a
17+
//! follow-up layer.
18+
//!
19+
//! Body bytes are never inspected — transaction calldata is private user data
20+
//! per the privacy-pool threat model.
21+
22+
use std::pin::Pin;
23+
use std::task::{Context, Poll};
24+
use std::time::Instant;
25+
26+
use http::{HeaderValue, Request, Response};
27+
use jsonrpsee::server::HttpBody;
28+
use tower::{Layer, Service};
29+
use tracing::info;
30+
31+
#[cfg(test)]
32+
#[path = "request_log_test.rs"]
33+
mod request_log_test;
34+
35+
/// HTTP header carrying the request id.
36+
pub const REQUEST_ID_HEADER: &str = "x-request-id";
37+
38+
/// Cap on accepted incoming request-id length. Anything longer is dropped
39+
/// in favour of a freshly generated id so the value never balloons into
40+
/// AsyncLocalStorage / tracing fields and so log aggregators don't have
41+
/// to parse megabyte-scale ids.
42+
const MAX_REQUEST_ID_LEN: usize = 128;
43+
44+
/// Cap on the logged request path. The URI is attacker-controlled and this
45+
/// layer is outermost, so an over-long path would bloat every log line; it is
46+
/// truncated for logging only (the request itself is untouched).
47+
const MAX_LOG_PATH_LEN: usize = 256;
48+
49+
pub(crate) fn new_request_id() -> String {
50+
uuid::Uuid::new_v4().to_string()
51+
}
52+
53+
/// Truncates an over-long path on a char boundary for safe logging.
54+
fn truncated_log_path(path: &str) -> String {
55+
if path.len() <= MAX_LOG_PATH_LEN {
56+
return path.to_string();
57+
}
58+
let mut end = MAX_LOG_PATH_LEN;
59+
while !path.is_char_boundary(end) {
60+
end -= 1;
61+
}
62+
format!("{}…(truncated)", &path[..end])
63+
}
64+
65+
/// Accepts the incoming `x-request-id` only when it's a short printable
66+
/// ASCII token. CR/LF would let a client smuggle headers into the
67+
/// response; arbitrary bytes (including unicode) make the value unsafe
68+
/// to round-trip through `HeaderValue::from_str`. Any reject falls back
69+
/// to a freshly generated UUID v4.
70+
pub(crate) fn extract_or_generate_request_id<B>(request: &Request<B>) -> String {
71+
request
72+
.headers()
73+
.get(REQUEST_ID_HEADER)
74+
.and_then(|value| value.to_str().ok())
75+
.filter(|value| !value.is_empty() && value.len() <= MAX_REQUEST_ID_LEN)
76+
.filter(|value| value.bytes().all(is_safe_request_id_byte))
77+
.map(|value| value.to_string())
78+
.unwrap_or_else(new_request_id)
79+
}
80+
81+
fn is_safe_request_id_byte(b: u8) -> bool {
82+
// Printable ASCII excluding whitespace. Rejects CR/LF/NUL/DEL etc.
83+
b.is_ascii_graphic()
84+
}
85+
86+
/// tower [`Layer`] producing [`RequestLogService`].
87+
#[derive(Clone, Copy, Default)]
88+
pub struct RequestLogLayer;
89+
90+
impl<S> Layer<S> for RequestLogLayer {
91+
type Service = RequestLogService<S>;
92+
93+
fn layer(&self, inner: S) -> Self::Service {
94+
RequestLogService { inner }
95+
}
96+
}
97+
98+
#[derive(Clone)]
99+
pub struct RequestLogService<S> {
100+
inner: S,
101+
}
102+
103+
impl<S, ReqB> Service<Request<ReqB>> for RequestLogService<S>
104+
where
105+
S: Service<Request<ReqB>, Response = Response<HttpBody>>,
106+
S::Future: Send + 'static,
107+
S::Error: Send + 'static,
108+
{
109+
type Response = Response<HttpBody>;
110+
type Error = S::Error;
111+
type Future =
112+
Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
113+
114+
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
115+
self.inner.poll_ready(cx)
116+
}
117+
118+
fn call(&mut self, mut request: Request<ReqB>) -> Self::Future {
119+
let request_id = extract_or_generate_request_id(&request);
120+
if let Ok(header_value) = HeaderValue::from_str(&request_id) {
121+
request.headers_mut().insert(REQUEST_ID_HEADER, header_value);
122+
}
123+
let method = request.method().clone();
124+
let path = truncated_log_path(request.uri().path());
125+
let start = Instant::now();
126+
127+
let future = self.inner.call(request);
128+
129+
Box::pin(async move {
130+
let result = future.await;
131+
let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
132+
match result {
133+
Ok(mut response) => {
134+
let status = response.status().as_u16();
135+
if let Ok(header_value) = HeaderValue::from_str(&request_id) {
136+
response.headers_mut().insert(REQUEST_ID_HEADER, header_value);
137+
}
138+
info!(
139+
event = "http_request",
140+
request_id = %request_id,
141+
method = %method,
142+
path = %path,
143+
status = status,
144+
latency_ms = latency_ms,
145+
"HTTP request handled."
146+
);
147+
Ok(response)
148+
}
149+
Err(err) => {
150+
info!(
151+
event = "http_request",
152+
request_id = %request_id,
153+
method = %method,
154+
path = %path,
155+
latency_ms = latency_ms,
156+
outcome = "service_error",
157+
"HTTP request failed in tower stack."
158+
);
159+
Err(err)
160+
}
161+
}
162+
})
163+
}
164+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
use bytes::Bytes;
2+
use http::{Method, Request, Response, StatusCode};
3+
use http_body_util::{BodyExt, Full};
4+
use jsonrpsee::server::HttpBody;
5+
use tower::{Layer, ServiceExt};
6+
7+
use crate::server::request_log::{RequestLogLayer, REQUEST_ID_HEADER};
8+
9+
fn echo_request_id_service() -> impl tower::Service<
10+
Request<HttpBody>,
11+
Response = Response<HttpBody>,
12+
Error = std::convert::Infallible,
13+
Future = futures::future::Ready<Result<Response<HttpBody>, std::convert::Infallible>>,
14+
> + Clone {
15+
tower::service_fn(|req: Request<HttpBody>| {
16+
let id = req
17+
.headers()
18+
.get(REQUEST_ID_HEADER)
19+
.map(|value| value.to_str().unwrap().to_string())
20+
.unwrap_or_default();
21+
let response = Response::builder()
22+
.status(StatusCode::OK)
23+
.body(HttpBody::new(Full::new(Bytes::from(id))))
24+
.expect("static body is infallible");
25+
futures::future::ready(Ok::<_, std::convert::Infallible>(response))
26+
})
27+
}
28+
29+
fn request_with_header(value: Option<&str>) -> Request<HttpBody> {
30+
let mut builder = Request::builder().method(Method::POST).uri("/");
31+
if let Some(value) = value {
32+
builder = builder.header(REQUEST_ID_HEADER, value);
33+
}
34+
builder.body(HttpBody::new(Full::new(Bytes::new()))).expect("static body is infallible")
35+
}
36+
37+
async fn read_body(response: Response<HttpBody>) -> (Vec<u8>, http::HeaderMap) {
38+
let (parts, body) = response.into_parts();
39+
let bytes = body.collect().await.expect("body collect").to_bytes().to_vec();
40+
(bytes, parts.headers)
41+
}
42+
43+
#[tokio::test]
44+
async fn echoes_supplied_request_id_on_response() {
45+
let svc = RequestLogLayer.layer(echo_request_id_service());
46+
47+
let response = svc.oneshot(request_with_header(Some("client-supplied-id"))).await.unwrap();
48+
49+
let (body, headers) = read_body(response).await;
50+
assert_eq!(headers.get(REQUEST_ID_HEADER).unwrap(), "client-supplied-id");
51+
assert_eq!(String::from_utf8(body).unwrap(), "client-supplied-id");
52+
}
53+
54+
#[tokio::test]
55+
async fn generates_request_id_when_absent_and_echoes_it() {
56+
let svc = RequestLogLayer.layer(echo_request_id_service());
57+
58+
let response = svc.oneshot(request_with_header(None)).await.unwrap();
59+
60+
let (body, headers) = read_body(response).await;
61+
let header_id = headers.get(REQUEST_ID_HEADER).unwrap().to_str().unwrap();
62+
let body_id = String::from_utf8(body).unwrap();
63+
// Body is what the inner service saw — they must match (the layer
64+
// rewrote the request header before forwarding).
65+
assert_eq!(header_id, body_id);
66+
// Canonical UUID v4: 8-4-4-4-12 hex with hyphens.
67+
assert!(uuid::Uuid::parse_str(header_id).is_ok(), "expected a UUID, got {header_id:?}");
68+
assert_eq!(uuid::Uuid::parse_str(header_id).unwrap().get_version_num(), 4);
69+
}
70+
71+
#[tokio::test]
72+
async fn drops_non_ascii_incoming_id_and_generates_a_fresh_one() {
73+
let mut request = request_with_header(None);
74+
request
75+
.headers_mut()
76+
.insert(REQUEST_ID_HEADER, http::HeaderValue::from_bytes(b"\xff\xfe").unwrap());
77+
78+
let svc = RequestLogLayer.layer(echo_request_id_service());
79+
let response = svc.oneshot(request).await.unwrap();
80+
81+
let (_body, headers) = read_body(response).await;
82+
let header_id = headers.get(REQUEST_ID_HEADER).unwrap().to_str().unwrap();
83+
assert!(uuid::Uuid::parse_str(header_id).is_ok(), "should have generated a fresh UUID");
84+
}
85+
86+
#[tokio::test]
87+
async fn drops_request_id_containing_whitespace() {
88+
// CRLF in header values is rejected by the http crate itself at parse
89+
// time, so the residual concern is whitespace and other ASCII bytes
90+
// that would confuse log parsers if echoed verbatim into structured
91+
// fields.
92+
for hostile in ["with space", "tab\there", "leading space "] {
93+
let mut request = request_with_header(None);
94+
request
95+
.headers_mut()
96+
.insert(REQUEST_ID_HEADER, http::HeaderValue::from_bytes(hostile.as_bytes()).unwrap());
97+
let svc = RequestLogLayer.layer(echo_request_id_service());
98+
let response = svc.oneshot(request).await.unwrap();
99+
let (_body, headers) = read_body(response).await;
100+
let header_id = headers.get(REQUEST_ID_HEADER).unwrap().to_str().unwrap();
101+
assert!(
102+
uuid::Uuid::parse_str(header_id).is_ok(),
103+
"expected fresh UUID for hostile input {hostile:?}, got {header_id:?}",
104+
);
105+
}
106+
}
107+
108+
#[tokio::test]
109+
async fn drops_oversize_request_id() {
110+
let huge = "a".repeat(2048);
111+
let mut request = request_with_header(None);
112+
request
113+
.headers_mut()
114+
.insert(REQUEST_ID_HEADER, http::HeaderValue::from_bytes(huge.as_bytes()).unwrap());
115+
let svc = RequestLogLayer.layer(echo_request_id_service());
116+
let response = svc.oneshot(request).await.unwrap();
117+
let (_body, headers) = read_body(response).await;
118+
let header_id = headers.get(REQUEST_ID_HEADER).unwrap().to_str().unwrap();
119+
assert!(uuid::Uuid::parse_str(header_id).is_ok());
120+
}

crates/starknet_transaction_prover/src/server/tls.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use tower_http::map_request_body::MapRequestBodyLayer;
2727
use tower_http::map_response_body::MapResponseBodyLayer;
2828
use tracing::warn;
2929

30-
use crate::server::{HealthLayer, OhttpJsonrpseeLayer};
30+
use crate::server::{HealthLayer, OhttpJsonrpseeLayer, RequestLogLayer};
3131

3232
/// Maximum time allowed for a TLS handshake before the connection is dropped.
3333
const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
@@ -59,9 +59,11 @@ pub async fn start_tls_server(
5959
let svc_builder = ServerBuilder::default()
6060
.set_config(server_config)
6161
.set_http_middleware(
62-
// `HealthLayer` sits outermost so `GET /health` is answered before
63-
// any other middleware runs.
62+
// `RequestLogLayer` is outermost so it sees status + latency for
63+
// every request (including health probes). See `server.rs` for the
64+
// layer-order rationale.
6465
ServiceBuilder::new()
66+
.layer(RequestLogLayer)
6567
.layer(HealthLayer)
6668
.option_layer(cors_layer)
6769
.layer(MapRequestBodyLayer::new(HttpBody::new))

0 commit comments

Comments
 (0)