Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 0 additions & 27 deletions .cargo/audit.toml

This file was deleted.

5 changes: 3 additions & 2 deletions .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions-rs/audit-check@v1.2.0
- uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tool: cargo-audit
- run: cargo audit
26 changes: 18 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/common/src/pbs/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ pub enum PbsError {
#[error("json decode error: {err:?}, raw: {raw}")]
JsonDecode { err: serde_json::Error, raw: String },

#[error("json encode error: {0:?}")]
JsonEncode(serde_json::Error),

#[error("ssz decode error: {err:?}, fork: {fork}")]
SSZDecode { err: String, fork: ForkName },

Expand Down Expand Up @@ -116,6 +119,9 @@ pub enum ValidationError {

#[error("unsupported fork")]
UnsupportedFork,

#[error("fork mismatch: request is {expected} but relay response is {got}")]
ForkMismatch { expected: ForkName, got: ForkName },
}

#[derive(Debug, Error, PartialEq, Eq)]
Expand Down
27 changes: 19 additions & 8 deletions crates/common/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,10 @@ pub fn get_accept_types(
return Err(AcceptedEncodingsError::UnsupportedAcceptType)
}

// No accept header (or only q=0 rejections): fall back to the request
// Content-Type, which mirrors the historical behavior.
Ok(AcceptedEncodings::single(get_content_type(req_headers)))
// No Accept header (or only q=0 rejections): per the Builder API a missing
// Accept means JSON, and request/response encodings are independent — so do
// NOT inherit the request Content-Type (an SSZ request still gets JSON).
Ok(AcceptedEncodings::single(NO_PREFERENCE_DEFAULT))
}

fn essence_encoding(mt: &MediaType) -> Option<EncodingType> {
Expand Down Expand Up @@ -293,11 +294,10 @@ fn format_accept_entry(enc: EncodingType, q: f32) -> String {
format!("{};q={:.1}", enc.content_type(), q)
}

/// Build an `Accept` header that mirrors the caller's preference order
/// so the relay sees the same priority the beacon node asked us for. Each
/// subsequent entry receives a q-value 0.1 lower than the previous one,
/// starting at 1.0. Returns a ready-to-use `HeaderValue` — the output is
/// always valid ASCII, so infallible.
/// Build an `Accept` header listing the given encodings in preference order:
/// the first entry gets q=1.0 and each subsequent one a q-value 0.1 lower.
/// Returns a ready-to-use `HeaderValue` — the output is always valid ASCII, so
/// infallible.
pub fn build_outbound_accept(preferred: AcceptedEncodings) -> HeaderValue {
let s = preferred
.iter()
Expand Down Expand Up @@ -468,6 +468,17 @@ mod test {
assert_eq!(result, AcceptedEncodings::single(EncodingType::Json));
}

/// A missing Accept header defaults to JSON even when the request body is
/// SSZ: request and response encodings are independent, so the response
/// encoding MUST NOT inherit the request Content-Type.
#[test]
fn test_missing_accept_header_ignores_ssz_content_type() {
let mut headers = HeaderMap::new();
headers.append(CONTENT_TYPE, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap());
let result = get_accept_types(&headers).unwrap();
assert_eq!(result, AcceptedEncodings::single(EncodingType::Json));
}

/// Test accepting JSON
#[test]
fn test_accept_header_json() {
Expand Down
24 changes: 24 additions & 0 deletions crates/pbs/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ impl PbsClientError {
PbsClientError::NoResponse => StatusCode::BAD_GATEWAY,
PbsClientError::NoPayload => StatusCode::BAD_GATEWAY,
PbsClientError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
PbsClientError::DecodeError(BodyDeserializeError::UnsupportedMediaType) => {
StatusCode::UNSUPPORTED_MEDIA_TYPE
}
PbsClientError::DecodeError(_) => StatusCode::BAD_REQUEST,
PbsClientError::HeaderError(_) => StatusCode::NOT_ACCEPTABLE,
}
Expand All @@ -45,3 +48,24 @@ impl IntoResponse for PbsClientError {
(self.status_code(), msg).into_response()
}
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn unsupported_media_type_maps_to_415() {
assert_eq!(
PbsClientError::DecodeError(BodyDeserializeError::UnsupportedMediaType).status_code(),
StatusCode::UNSUPPORTED_MEDIA_TYPE,
);
}

#[test]
fn other_decode_errors_map_to_400() {
assert_eq!(
PbsClientError::DecodeError(BodyDeserializeError::MissingVersionHeader).status_code(),
StatusCode::BAD_REQUEST,
);
}
}
11 changes: 11 additions & 0 deletions crates/pbs/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,15 @@ lazy_static! {
&["http_status_code", "endpoint"],
PBS_METRICS_REGISTRY
).unwrap();

/// Count of v2 submit_block requests that could not be served because the
/// relay returned 404 on the v2 endpoint. A non-zero value means the relay
/// fleet has not been upgraded to support submitBlindedBlockV2 and those
/// blocks were not submitted.
pub static ref RELAY_V2_UNSUPPORTED: IntCounterVec = register_int_counter_vec_with_registry!(
"pbs_submit_block_v2_unsupported_total",
"Count of v2 submit_block requests a relay could not serve because it does not support v2",
&["relay_id"],
PBS_METRICS_REGISTRY
).unwrap();
}
29 changes: 13 additions & 16 deletions crates/pbs/src/mev_boost/get_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ use cb_common::{
types::{BlsPublicKey, BlsPublicKeyBytes, BlsSignature, Chain},
utils::{ms_into_slot, timestamp_of_slot_start_sec, utcnow_ms},
wire::{
AcceptedEncodings, EncodingType, build_outbound_accept, get_accept_types,
get_user_agent_with_version, parse_response_encoding_and_fork, safe_read_http_response,
AcceptedEncodings, EncodingType, build_outbound_accept, get_user_agent_with_version,
parse_response_encoding_and_fork, safe_read_http_response,
},
};
use futures::future::join_all;
Expand Down Expand Up @@ -147,21 +147,18 @@ pub async fn get_header<S: BuilderApiState>(
let mut send_headers = HeaderMap::new();
send_headers.insert(USER_AGENT, get_user_agent_with_version(&req_headers)?);

// Forward the caller's Accept preference to the relay so the relay
// returns data in the format the BN expects, avoiding decode→re-encode.
// Always offer both encodings as fallback so a format-limited relay
// still returns a bid (PBS converts if needed).
let caller_accept = get_accept_types(&req_headers).inspect_err(|err| {
error!(%err, "error parsing accept header");
})?;
let relay_accept = AcceptedEncodings {
primary: caller_accept.primary,
fallback: Some(match caller_accept.primary {
EncodingType::Ssz => EncodingType::Json,
EncodingType::Json => EncodingType::Ssz,
// PBS always decodes and re-validates every relay bid, then the route
// re-encodes the winning bid to the BN's Accept. So always request SSZ from
// the relay (smaller on the wire, faster to decode than JSON); JSON is the
// fallback for a relay that can't do SSZ. The BN's own format preference is
// applied later by the route, not here.
send_headers.insert(
ACCEPT,
build_outbound_accept(AcceptedEncodings {
primary: EncodingType::Ssz,
fallback: Some(EncodingType::Json),
}),
};
send_headers.insert(ACCEPT, build_outbound_accept(relay_accept));
);

// Send requests to all relays concurrently
let slot = params.slot as i64;
Expand Down
Loading