Skip to content

Commit 0bc195f

Browse files
committed
Harden native stream forwarding
1 parent 966a169 commit 0bc195f

12 files changed

Lines changed: 322 additions & 137 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ behavior when the change improves security or project direction.
2323

2424
### Security
2525

26+
- Normalize IPv4-mapped and legacy IPv4-compatible IPv6 stream DNS answers
27+
before rebinding policy checks, preventing loopback, private, link-local,
28+
carrier-grade NAT, benchmark, and other reserved IPv4 targets from bypassing
29+
the stream SSRF guard.
30+
- Replace cancellation-unsafe inline bidirectional stream copies with two
31+
persistent pinned direction futures and a shared last-activity idle timer,
32+
preventing partial writes from losing or duplicating plaintext under
33+
simultaneous traffic and backpressure.
34+
- Clear forwarded stream plaintext immediately and clear each full 16 KiB copy
35+
buffer on drop with `sanitization`.
36+
- Enforce positive per-upstream and checked aggregate stream weights inside the
37+
public runtime selector boundary, independently of config validation.
2638
- Make OpenSSL cipher allow-lists deterministic across protocol families:
2739
omitting all TLS 1.2 or TLS 1.3 suites now disables that protocol version
2840
instead of retaining Mozilla acceptor defaults. Use the current Mozilla v5
@@ -39,6 +51,8 @@ behavior when the change improves security or project direction.
3951
- Disable rustls and tokio-rustls default provider features so normal Ring
4052
builds no longer compile AWS-LC; FIPS profiles continue to select AWS-LC
4153
explicitly.
54+
- Make `base64-ng` optional in `fluxheim-tls` and activate it only for Rustls
55+
key parsing, keeping it out of the crate's default and OpenSSL-only graphs.
4256
- Validate every `wasi_snapshot_preview1` import against the plugin's explicit
4357
capability grants before instantiation. Environment, arguments, inherited
4458
stdio, filesystem, sockets/network, polling, and process state remain denied.

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.

crates/fluxheim-config/src/config_stream.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ pub const MAX_STREAM_LISTENERS: usize = 64;
1919
pub const MAX_STREAM_UPSTREAMS: usize = 64;
2020
pub const MAX_STREAM_SOURCE_MATCHERS: usize = 256;
2121
pub const MAX_STREAM_MAX_CONNECTIONS: usize = 1_000_000;
22-
const MAX_STREAM_UPSTREAM_WEIGHT: usize = 1000;
23-
const MAX_STREAM_UPSTREAM_TOTAL_WEIGHT: usize = u16::MAX as usize;
22+
pub const MAX_STREAM_UPSTREAM_WEIGHT: usize = 1000;
23+
pub const MAX_STREAM_UPSTREAM_TOTAL_WEIGHT: usize = u16::MAX as usize;
2424

2525
#[derive(Debug, Clone, Default, Eq, PartialEq, Deserialize, Serialize)]
2626
#[serde(deny_unknown_fields)]

crates/fluxheim-stream/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ description = "Internal Fluxheim TCP stream proxy runtime boundary."
1111
fluxheim-common = { path = "../fluxheim-common" }
1212
fluxheim-config = { path = "../fluxheim-config", features = ["stream-proxy"] }
1313
fluxheim-protocol = { path = "../fluxheim-protocol" }
14-
tokio = { version = "1.52.3", features = ["io-util", "macros", "time"] }
14+
sanitization = { version = "1.2.4" }
15+
tokio = { version = "1.52.3", features = ["io-util", "macros", "rt", "sync", "time"] }

crates/fluxheim-stream/src/copy.rs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
use std::io;
2+
use std::time::Duration;
3+
4+
use fluxheim_common::{FluxError, FluxResult};
5+
use sanitization::sanitize_bytes;
6+
use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
7+
use tokio::sync::watch;
8+
9+
const STREAM_COPY_BUFFER_BYTES: usize = 16 * 1024;
10+
11+
struct StreamCopyBuffer([u8; STREAM_COPY_BUFFER_BYTES]);
12+
13+
impl StreamCopyBuffer {
14+
fn new() -> Self {
15+
Self([0; STREAM_COPY_BUFFER_BYTES])
16+
}
17+
18+
fn clear_forwarded(&mut self, bytes: usize) {
19+
sanitize_bytes(&mut self.0[..bytes]);
20+
}
21+
}
22+
23+
impl Drop for StreamCopyBuffer {
24+
fn drop(&mut self) {
25+
sanitize_bytes(&mut self.0);
26+
}
27+
}
28+
29+
pub fn checked_stream_byte_count(
30+
current: u64,
31+
additional: u64,
32+
max_connection_bytes: Option<u64>,
33+
) -> FluxResult<u64> {
34+
let next = current.checked_add(additional).ok_or_else(|| {
35+
FluxError::io(
36+
"count stream bytes",
37+
io::Error::new(
38+
io::ErrorKind::InvalidData,
39+
"stream copied byte counter overflowed",
40+
),
41+
)
42+
})?;
43+
if max_connection_bytes.is_some_and(|limit| next > limit) {
44+
return Err(FluxError::io(
45+
"enforce stream byte limit",
46+
io::Error::new(
47+
io::ErrorKind::PermissionDenied,
48+
"stream max connection bytes exceeded",
49+
),
50+
));
51+
}
52+
Ok(next)
53+
}
54+
55+
pub async fn copy_bidirectional_with_limits(
56+
downstream: &mut (impl AsyncRead + AsyncWrite + Unpin),
57+
upstream: &mut (impl AsyncRead + AsyncWrite + Unpin),
58+
idle_timeout: Duration,
59+
max_connection_bytes: Option<u64>,
60+
) -> FluxResult<(u64, u64)> {
61+
let (downstream_reader, downstream_writer) = tokio::io::split(downstream);
62+
let (upstream_reader, upstream_writer) = tokio::io::split(upstream);
63+
let (activity_tx, mut activity_rx) = watch::channel(tokio::time::Instant::now());
64+
let downstream_copy = copy_direction(
65+
downstream_reader,
66+
upstream_writer,
67+
max_connection_bytes,
68+
activity_tx.clone(),
69+
);
70+
let upstream_copy = copy_direction(
71+
upstream_reader,
72+
downstream_writer,
73+
max_connection_bytes,
74+
activity_tx,
75+
);
76+
tokio::pin!(downstream_copy);
77+
tokio::pin!(upstream_copy);
78+
79+
let idle_timer = tokio::time::sleep(idle_timeout);
80+
tokio::pin!(idle_timer);
81+
let mut downstream_total = None;
82+
let mut upstream_total = None;
83+
let mut activity_open = true;
84+
85+
loop {
86+
tokio::select! {
87+
biased;
88+
result = &mut downstream_copy, if downstream_total.is_none() => {
89+
downstream_total = Some(result?);
90+
}
91+
result = &mut upstream_copy, if upstream_total.is_none() => {
92+
upstream_total = Some(result?);
93+
}
94+
activity = activity_rx.changed(), if activity_open => {
95+
if activity.is_ok() {
96+
let last_activity = *activity_rx.borrow_and_update();
97+
idle_timer.as_mut().reset(last_activity + idle_timeout);
98+
} else {
99+
activity_open = false;
100+
}
101+
}
102+
_ = &mut idle_timer => {
103+
return Err(FluxError::timeout(
104+
"stream idle timeout",
105+
"stream idle timeout elapsed",
106+
));
107+
}
108+
}
109+
if let (Some(downstream_total), Some(upstream_total)) = (downstream_total, upstream_total) {
110+
return Ok((downstream_total, upstream_total));
111+
}
112+
}
113+
}
114+
115+
async fn copy_direction<R, W>(
116+
mut reader: R,
117+
mut writer: W,
118+
max_connection_bytes: Option<u64>,
119+
activity: watch::Sender<tokio::time::Instant>,
120+
) -> FluxResult<u64>
121+
where
122+
R: AsyncRead + Unpin,
123+
W: AsyncWrite + Unpin,
124+
{
125+
let mut buffer = StreamCopyBuffer::new();
126+
let mut total = 0u64;
127+
loop {
128+
let bytes = reader
129+
.read(&mut buffer.0)
130+
.await
131+
.map_err(|error| FluxError::io("read stream", error))?;
132+
if bytes == 0 {
133+
writer
134+
.shutdown()
135+
.await
136+
.map_err(|error| FluxError::io("shutdown stream", error))?;
137+
return Ok(total);
138+
}
139+
total = checked_stream_byte_count(total, bytes as u64, max_connection_bytes)?;
140+
writer
141+
.write_all(&buffer.0[..bytes])
142+
.await
143+
.map_err(|error| FluxError::io("write stream", error))?;
144+
buffer.clear_forwarded(bytes);
145+
activity.send_replace(tokio::time::Instant::now());
146+
}
147+
}
148+
149+
#[cfg(test)]
150+
mod tests {
151+
use super::*;
152+
153+
#[test]
154+
fn stream_copy_buffer_clears_forwarded_prefix() {
155+
let mut buffer = StreamCopyBuffer([7; STREAM_COPY_BUFFER_BYTES]);
156+
buffer.clear_forwarded(3);
157+
assert_eq!(&buffer.0[..3], &[0, 0, 0]);
158+
assert_eq!(buffer.0[3], 7);
159+
}
160+
}

crates/fluxheim-stream/src/lib.rs

Lines changed: 5 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ use fluxheim_protocol::{
1919
};
2020
use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
2121

22+
mod copy;
2223
mod selector;
2324

25+
pub use copy::{checked_stream_byte_count, copy_bidirectional_with_limits};
2426
pub use selector::{StreamSelectedUpstream, StreamUpstreamSelector};
2527

2628
#[derive(Debug, Clone, Eq, PartialEq)]
@@ -140,6 +142,9 @@ fn stream_dns_resolved_ipv4_address_allowed(address: Ipv4Addr) -> bool {
140142
}
141143

142144
fn stream_dns_resolved_ipv6_address_allowed(address: Ipv6Addr) -> bool {
145+
if let Some(address) = address.to_ipv4() {
146+
return stream_dns_resolved_ipv4_address_allowed(address);
147+
}
143148
let segments = address.segments();
144149
!(address.is_unspecified()
145150
|| address.is_loopback()
@@ -172,115 +177,6 @@ pub fn stream_error_outcome(error: &FluxError) -> &'static str {
172177
}
173178
}
174179

175-
pub fn checked_stream_byte_count(
176-
current: u64,
177-
additional: u64,
178-
max_connection_bytes: Option<u64>,
179-
) -> FluxResult<u64> {
180-
let next = current.checked_add(additional).ok_or_else(|| {
181-
FluxError::io(
182-
"count stream bytes",
183-
io::Error::new(
184-
io::ErrorKind::InvalidData,
185-
"stream copied byte counter overflowed",
186-
),
187-
)
188-
})?;
189-
if max_connection_bytes.is_some_and(|limit| next > limit) {
190-
return Err(FluxError::io(
191-
"enforce stream byte limit",
192-
io::Error::new(
193-
io::ErrorKind::PermissionDenied,
194-
"stream max connection bytes exceeded",
195-
),
196-
));
197-
}
198-
Ok(next)
199-
}
200-
201-
enum StreamCopyEvent {
202-
DownstreamTotal(u64),
203-
UpstreamTotal(u64),
204-
DownstreamEof,
205-
UpstreamEof,
206-
}
207-
208-
pub async fn copy_bidirectional_with_limits(
209-
downstream: &mut (impl AsyncRead + AsyncWrite + Unpin),
210-
upstream: &mut (impl AsyncRead + AsyncWrite + Unpin),
211-
idle_timeout: Duration,
212-
max_connection_bytes: Option<u64>,
213-
) -> FluxResult<(u64, u64)> {
214-
let (mut downstream_reader, mut downstream_writer) = tokio::io::split(downstream);
215-
let (mut upstream_reader, mut upstream_writer) = tokio::io::split(upstream);
216-
let mut downstream_buffer = [0u8; 16 * 1024];
217-
let mut upstream_buffer = [0u8; 16 * 1024];
218-
let mut downstream_to_upstream = 0u64;
219-
let mut upstream_to_downstream = 0u64;
220-
let mut downstream_eof = false;
221-
let mut upstream_eof = false;
222-
223-
while !downstream_eof || !upstream_eof {
224-
let event = tokio::select! {
225-
result = async {
226-
let bytes = read_with_idle_timeout(
227-
&mut downstream_reader,
228-
&mut downstream_buffer,
229-
idle_timeout,
230-
).await?;
231-
if bytes == 0 {
232-
shutdown_with_idle_timeout(&mut upstream_writer, idle_timeout).await?;
233-
Ok::<_, FluxError>(StreamCopyEvent::DownstreamEof)
234-
} else {
235-
let next = checked_stream_byte_count(
236-
downstream_to_upstream,
237-
bytes as u64,
238-
max_connection_bytes,
239-
)?;
240-
write_with_idle_timeout(
241-
&mut upstream_writer,
242-
&downstream_buffer[..bytes],
243-
idle_timeout,
244-
).await?;
245-
Ok::<_, FluxError>(StreamCopyEvent::DownstreamTotal(next))
246-
}
247-
}, if !downstream_eof => result,
248-
result = async {
249-
let bytes = read_with_idle_timeout(
250-
&mut upstream_reader,
251-
&mut upstream_buffer,
252-
idle_timeout,
253-
).await?;
254-
if bytes == 0 {
255-
shutdown_with_idle_timeout(&mut downstream_writer, idle_timeout).await?;
256-
Ok::<_, FluxError>(StreamCopyEvent::UpstreamEof)
257-
} else {
258-
let next = checked_stream_byte_count(
259-
upstream_to_downstream,
260-
bytes as u64,
261-
max_connection_bytes,
262-
)?;
263-
write_with_idle_timeout(
264-
&mut downstream_writer,
265-
&upstream_buffer[..bytes],
266-
idle_timeout,
267-
).await?;
268-
Ok::<_, FluxError>(StreamCopyEvent::UpstreamTotal(next))
269-
}
270-
}, if !upstream_eof => result,
271-
}?;
272-
273-
match event {
274-
StreamCopyEvent::DownstreamTotal(total) => downstream_to_upstream = total,
275-
StreamCopyEvent::UpstreamTotal(total) => upstream_to_downstream = total,
276-
StreamCopyEvent::DownstreamEof => downstream_eof = true,
277-
StreamCopyEvent::UpstreamEof => upstream_eof = true,
278-
}
279-
}
280-
281-
Ok((downstream_to_upstream, upstream_to_downstream))
282-
}
283-
284180
pub async fn write_upstream_proxy_protocol(
285181
upstream: &mut (impl AsyncWrite + Unpin),
286182
protocol: UpstreamProxyProtocol,
@@ -414,19 +310,6 @@ where
414310
}
415311
}
416312

417-
async fn shutdown_with_idle_timeout<W>(writer: &mut W, idle_timeout: Duration) -> FluxResult<()>
418-
where
419-
W: AsyncWrite + Unpin,
420-
{
421-
match tokio::time::timeout(idle_timeout, writer.shutdown()).await {
422-
Ok(result) => result.map_err(|error| FluxError::io("shutdown stream", error)),
423-
Err(_) => Err(FluxError::timeout(
424-
"stream shutdown timeout",
425-
"stream shutdown timeout elapsed",
426-
)),
427-
}
428-
}
429-
430313
async fn read_exact_with_idle_timeout<R>(
431314
reader: &mut R,
432315
buffer: &mut [u8],

0 commit comments

Comments
 (0)