Skip to content

fix: Adding a event size cap and stream timeout to prevent compromised BMC… - #167

Merged
rohanharkin merged 4 commits into
mainfrom
rharkin/sse-events-byte-cap
Jul 22, 2026
Merged

fix: Adding a event size cap and stream timeout to prevent compromised BMC…#167
rohanharkin merged 4 commits into
mainfrom
rharkin/sse-events-byte-cap

Conversation

@rohanharkin

Copy link
Copy Markdown
Contributor

#138
Adds a configurable per-event byte cap that aborts the SSE stream if a single event exceeds the limit without terminating, preventing a compromised BMC from exhausting memory by sending an endless unterminated event.
Adds a configurable idle timeout that aborts the stream if no event arrives within the window, catching connections that have stalled

This was tested manually using a mock BMC that opens a text/event-stream response and then streams an SSE event that never terminates, against a client that consumes the stream through EventService/Bmc::stream in a memory-capped container.

Two tests added as well
sse_aborts_when_event_exceeds_byte_limit()
sse_aborts_on_idle_timeout()

@rohanharkin
rohanharkin requested review from poroh and yoks July 21, 2026 09:59
@rohanharkin rohanharkin self-assigned this Jul 21, 2026
@rohanharkin
rohanharkin marked this pull request as draft July 21, 2026 10:00
@rohanharkin rohanharkin changed the title Adding a event size cap and stream timeout to prevent compromised BMC… fix: Adding a event size cap and stream timeout to prevent compromised BMC… Jul 21, 2026
@copy-pr-bot

copy-pr-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@rohanharkin
rohanharkin marked this pull request as ready for review July 21, 2026 10:38
@rohanharkin
rohanharkin force-pushed the rharkin/sse-events-byte-cap branch 3 times, most recently from e64174e to 7aea943 Compare July 21, 2026 11:27
@rohanharkin
rohanharkin force-pushed the rharkin/sse-events-byte-cap branch from f574400 to c30e0f3 Compare July 21, 2026 13:12
…'s from crashing the nv-redfish process by sending unending BMC events.
@rohanharkin
rohanharkin force-pushed the rharkin/sse-events-byte-cap branch from c30e0f3 to 4383ace Compare July 21, 2026 13:12
Comment thread bmc-http/src/reqwest.rs Outdated
Comment thread bmc-http/src/reqwest.rs Outdated
Comment thread bmc-http/src/reqwest.rs Outdated
@yoks

yoks commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

SSEStream implementation discrards comments, this means for healthy stream which sends heartbeats via comments would not:

  • Count towards byte-limit (same issue with unbounded growth)
  • Would terminate idle stream even if it sends heartbeats

I do not know how to properly fix it with SSEStream as it not supports it. So either commnet and contribution to upstream lib to fix it. Or rewrite SSE stream ourselves to properly fix it.

Comment thread bmc-http/src/reqwest.rs Outdated
/// let params = ClientParams::new().retry(policy);
/// ```
#[derive(Clone)]
#[allow(clippy::struct_field_names)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does seems like unrelated change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

before adding this CI was failing as the additional fields went past Clippys struct_field_name_threshold. It was then failing because of the field client within Client

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, this is valid lint then. As your sse prefixed params can be moved to SSE options struct

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I was confused - I added this in the wrong place

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we still can address it by making SSL params their separate struct and reuse it in Client and ClientParams.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah okay yes, will do that now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clippy will still fail the CI on the client: ReqwestClient - I can keep the allow in or rename it what do you think is better

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can (and arguably should), reuse it for Client struct as well, as you copy same param there

Comment thread bmc-http/src/reqwest.rs Outdated
@yoks

yoks commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

After playing with Claude with this change, it looks like this would be a better shape here:

#[derive(Clone, Copy)]
enum DelimiterState {
    /// Inside a line.
    Data,
    /// Saw `\r`; a following `\n` belongs to the same terminator.
    Cr,
    /// Saw one complete line terminator.
    Eol,
}

// This should fix the issue with heartbeat and byte counting, as we look at raw data
fn cap_event_bytes(
    bytes: impl Stream<Item = Result<Bytes, reqwest::Error>>,
    limit: usize,
) -> impl Stream<Item = Result<Bytes, SseByteError>> {
    use DelimiterState::{Cr, Data, Eol};
    bytes.scan((0_usize, Data), move |(count, state), chunk| {
        let item = chunk.map_err(SseByteError::Upstream).and_then(|bytes| {
            for &b in bytes.iter() {
                *count += 1;
                *state = match (*state, b) {
                    (Data, b'\r') => Cr,
                    (Data | Cr, b'\n') => Eol,
                    (Cr | Eol, b'\r') => {
                        *count = 0; // blank line: event delimited
                        Cr
                    }
                    (Eol, b'\n') => {
                        *count = 0; // blank line: event delimited
                        Eol
                    }
                    _ => Data,
                };
            }
            if *count > limit {
                Err(SseByteError::EventTooLarge { limit })
            } else {
                Ok(bytes)
            }
        });
        future::ready(Some(item))
    })
}

fn event_to_item<T: DeserializeOwned>(
    event: Result<sse_stream::Sse, sse_stream::Error>,
) -> Option<Result<T, BmcError>> {
    match event {
        Err(err) => Some(Err(map_sse_error(err))),
        Ok(sse) => sse.data.map(|data| {
            serde_path_to_error::deserialize(&mut serde_json::Deserializer::from_str(&data))
                .map_err(BmcError::JsonError)
        }),
    }
}

fn with_idle_timeout<T>(
    events: impl Stream<Item = Result<T, BmcError>>,
    idle: Option<Duration>,
) -> impl Stream<Item = Result<T, BmcError>> {
    match idle {
        // tokio_stream is additional dependency, but it should be fine 
        Some(d) => tokio_stream::StreamExt::timeout(events, d)
            .map(move |item| item.unwrap_or(Err(BmcError::SseIdleTimeout { idle: d })))
            .left_stream(),
        None => events.right_stream(),
    }
}

fn fuse_on_fatal<T>(
    events: impl Stream<Item = Result<T, BmcError>>,
) -> impl Stream<Item = Result<T, BmcError>> {
    events.scan(false, |errored, item| {
        // is_stream_fatal is impl on BmcError which checks if it JSON error or not, so we would
        // terminate on non fata errors
        let stop = std::mem::replace(errored, matches!(&item, Err(e) if e.is_stream_fatal()));
        future::ready((!stop).then_some(item))
    })
}

Ans SSE function can be compacted to this:

async fn sse<T: Send + Sized + for<'de> serde::Deserialize<'de>>(
    &self,
    url: Url,
    credentials: &BmcCredentials,
    custom_headers: &HeaderMap,
) -> Result<BoxTryStream<T, Self::Error>, Self::Error> {
    let request = auth_headers(self.client.get(url), credentials)
        .headers(custom_headers.clone())
        .header(header::ACCEPT, "text/event-stream")
        .timeout(self.sse_total_timeout.unwrap_or(Duration::MAX)); // note what it does default to MAX here

    let response = self.send(request.build()?).await?;

    if response.status() != StatusCode::OK {
        return Err(BmcError::HttpError {
            status: response.status(),
            error_response: None,
        });
    }

    let capped = cap_event_bytes(response.bytes_stream(), self.sse_max_event_bytes);
    let events = sse_stream::SseStream::from_bytes_stream(capped)
        .filter_map(|event| future::ready(event_to_item(event)));

    Ok(Box::pin(fuse_on_fatal(with_idle_timeout(
        events,
        self.sse_idle_timeout,
    ))))
}

@rohanharkin

Copy link
Copy Markdown
Contributor Author

SSEStream implementation discrards comments, this means for healthy stream which sends heartbeats via comments would not:

  • Count towards byte-limit (same issue with unbounded growth)
  • Would terminate idle stream even if it sends heartbeats

I do not know how to properly fix it with SSEStream as it not supports it. So either commnet and contribution to upstream lib to fix it. Or rewrite SSE stream ourselves to properly fix it.

It seems like a lot to rewrite SSE for this change - Probably makes more sense as you suggested to comment it and contribute to SSE

Comment thread bmc-http/src/reqwest.rs
Comment thread bmc-http/src/reqwest.rs Outdated
Self {
client,
retry: None,
sse: SseOptions {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have SseOptions::default(), why not reuse it here?

Comment thread bmc-http/src/reqwest.rs Outdated

/// Sets the maximum buffered size of a single, not-yet-terminated SSE event.
///
/// See [`ClientParams::sse_max_event_bytes`].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer the case, it is SseOptions now

Comment thread bmc-http/src/reqwest.rs Outdated
/// reqwest HTTP client library. It supports all standard HTTP features including
/// TLS, authentication, and connection pooling.
#[derive(Clone)]
#[allow(clippy::struct_field_names)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok i see, lint acts on client. Lets rename it client to inner. This is because it is wrapper on the ReqwestClient anyway.

@yoks

yoks commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Looks good, but can you address few last nits? After that we can merge it.

@rohanharkin
rohanharkin merged commit c62a65a into main Jul 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSE stream buffers malicious BMC data unbounded with timeout disabled

2 participants