Skip to content

Commit 12fb527

Browse files
jubradclaude
andcommitted
SQL-204: only trust forwarded client IPs behind a proxy
A client reaching environmentd directly could set the `mz_forwarded_for` pgwire startup parameter, or prepend a PROXY protocol v2 header on HTTP, and have that address recorded as the session's client IP. Network policies are evaluated against that address, so any client could claim to connect from an allowed one. Add a per-listener `behind_trusted_proxy` flag, defaulting to false, and honor `mz_forwarded_for`, `mz_connection_uuid`, and the PROXY protocol header only where it is set. orchestratord sets it on the external listeners, which balancerd fronts, and only when it also creates balancerd. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f9b255f commit 12fb527

14 files changed

Lines changed: 340 additions & 89 deletions

File tree

src/environmentd/src/http.rs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ pub struct HttpConfig {
188188
pub routes_enabled: HttpRoutesEnabled,
189189
/// Locator for cluster replica HTTP addresses, used for proxying requests.
190190
pub replica_http_locator: Arc<ReplicaHttpLocator>,
191+
/// Whether a trusted proxy (`balancerd`) fronts this listener, and thus
192+
/// whether a PROXY protocol header on the connection can be believed.
193+
pub behind_trusted_proxy: bool,
191194
}
192195

193196
#[derive(Debug, Clone)]
@@ -221,6 +224,7 @@ struct HelmChartVersion(Option<String>);
221224
pub struct HttpServer {
222225
tls: Option<ReloadingSslContext>,
223226
router: Router,
227+
behind_trusted_proxy: bool,
224228
}
225229

226230
impl HttpServer {
@@ -247,6 +251,7 @@ impl HttpServer {
247251
internal_route_config,
248252
routes_enabled,
249253
replica_http_locator,
254+
behind_trusted_proxy,
250255
}: HttpConfig,
251256
) -> HttpServer {
252257
let tls_enabled = tls.is_some();
@@ -679,7 +684,11 @@ impl HttpServer {
679684
.merge(base_router)
680685
.apply_default_layers(source, metrics);
681686

682-
HttpServer { tls, router }
687+
HttpServer {
688+
tls,
689+
router,
690+
behind_trusted_proxy,
691+
}
683692
}
684693
}
685694

@@ -693,16 +702,24 @@ impl Server for HttpServer {
693702
) -> ConnectionHandler {
694703
let router = self.router.clone();
695704
let tls_context = self.tls.clone();
705+
let behind_trusted_proxy = self.behind_trusted_proxy;
696706
let mut conn = TokioIo::new(conn);
697707

698-
Box::pin(async {
708+
Box::pin(async move {
699709
let direct_peer_addr = conn.inner().peer_addr().context("fetching peer addr")?;
700-
let peer_addr = conn
701-
.inner_mut()
702-
.take_proxy_header_address()
703-
.await
704-
.map(|a| a.source)
705-
.unwrap_or(direct_peer_addr);
710+
// A PROXY protocol header is only believable when a proxy fronts
711+
// this listener. Elsewhere the header bytes are left in the stream,
712+
// so the request fails to parse as HTTP, which is the right outcome
713+
// for a client that had no business sending one.
714+
let peer_addr = if behind_trusted_proxy {
715+
conn.inner_mut()
716+
.take_proxy_header_address()
717+
.await
718+
.map(|a| a.source)
719+
.unwrap_or(direct_peer_addr)
720+
} else {
721+
direct_peer_addr
722+
};
706723

707724
let (conn, conn_protocol) = match tls_context {
708725
Some(tls_context) => {

src/environmentd/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ impl Listener<SqlListenerConfig> {
303303
active_connection_counter,
304304
helm_chart_version,
305305
allowed_roles: self.config.allowed_roles,
306+
behind_trusted_proxy: self.config.behind_trusted_proxy,
306307
});
307308
mz_server_core::serve(ServeConfig {
308309
conns: self.connection_stream,
@@ -450,6 +451,7 @@ impl Listeners {
450451
internal_route_config: Arc::clone(&internal_route_config),
451452
routes_enabled: listener.config.routes,
452453
replica_http_locator: Arc::clone(&config.controller.replica_http_locator),
454+
behind_trusted_proxy: listener.config.behind_trusted_proxy,
453455
};
454456
http_listener_handles.insert(name.clone(), listener.serve_http(http_config).await);
455457
}

src/environmentd/src/test_util.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,19 +156,22 @@ impl Default for TestHarness {
156156
authenticator_kind: AuthenticatorKind::None,
157157
allowed_roles: AllowedRoles::Normal,
158158
enable_tls: false,
159+
behind_trusted_proxy: false,
159160
},
160161
"internal".to_owned() => SqlListenerConfig {
161162
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
162163
authenticator_kind: AuthenticatorKind::None,
163164
allowed_roles: AllowedRoles::NormalAndInternal,
164165
enable_tls: false,
166+
behind_trusted_proxy: false,
165167
},
166168
],
167169
http: btreemap![
168170
"external".to_owned() => HttpListenerConfig {
169171
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
170172
authenticator_kind: AuthenticatorKind::None,
171173
enable_tls: false,
174+
behind_trusted_proxy: false,
172175
routes: HttpRoutesEnabled {
173176
base: RouteGroup::Enabled(AllowedRoles::Normal),
174177
webhook: RouteGroup::Enabled(AllowedRoles::Normal),
@@ -184,6 +187,7 @@ impl Default for TestHarness {
184187
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
185188
authenticator_kind: AuthenticatorKind::None,
186189
enable_tls: false,
190+
behind_trusted_proxy: false,
187191
routes: HttpRoutesEnabled {
188192
base: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
189193
webhook: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
@@ -311,6 +315,18 @@ impl TestHarness {
311315
self
312316
}
313317

318+
/// Models listeners that a trusted proxy (`balancerd`) fronts, so that
319+
/// forwarded connection metadata is honored.
320+
pub fn behind_trusted_proxy(mut self) -> Self {
321+
for (_, listener) in &mut self.listeners_config.sql {
322+
listener.behind_trusted_proxy = true;
323+
}
324+
for (_, listener) in &mut self.listeners_config.http {
325+
listener.behind_trusted_proxy = true;
326+
}
327+
self
328+
}
329+
314330
pub fn unsafe_mode(mut self) -> Self {
315331
self.unsafe_mode = true;
316332
self
@@ -339,19 +355,22 @@ impl TestHarness {
339355
authenticator_kind: AuthenticatorKind::Frontegg,
340356
allowed_roles: AllowedRoles::Normal,
341357
enable_tls,
358+
behind_trusted_proxy: false,
342359
},
343360
"internal".to_owned() => SqlListenerConfig {
344361
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
345362
authenticator_kind: AuthenticatorKind::None,
346363
allowed_roles: AllowedRoles::NormalAndInternal,
347364
enable_tls: false,
365+
behind_trusted_proxy: false,
348366
},
349367
},
350368
http: btreemap! {
351369
"external".to_owned() => HttpListenerConfig {
352370
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
353371
authenticator_kind: AuthenticatorKind::Frontegg,
354372
enable_tls,
373+
behind_trusted_proxy: false,
355374
routes: HttpRoutesEnabled {
356375
base: RouteGroup::Enabled(AllowedRoles::Normal),
357376
webhook: RouteGroup::Enabled(AllowedRoles::Normal),
@@ -367,6 +386,7 @@ impl TestHarness {
367386
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
368387
authenticator_kind: AuthenticatorKind::None,
369388
enable_tls: false,
389+
behind_trusted_proxy: false,
370390
routes: HttpRoutesEnabled {
371391
base: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
372392
webhook: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
@@ -398,19 +418,22 @@ impl TestHarness {
398418
authenticator_kind: AuthenticatorKind::Oidc,
399419
allowed_roles: AllowedRoles::NormalAndInternal,
400420
enable_tls,
421+
behind_trusted_proxy: false,
401422
},
402423
"internal".to_owned() => SqlListenerConfig {
403424
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
404425
authenticator_kind: AuthenticatorKind::None,
405426
allowed_roles: AllowedRoles::NormalAndInternal,
406427
enable_tls: false,
428+
behind_trusted_proxy: false,
407429
},
408430
},
409431
http: btreemap! {
410432
"external".to_owned() => HttpListenerConfig {
411433
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
412434
authenticator_kind: AuthenticatorKind::Oidc,
413435
enable_tls,
436+
behind_trusted_proxy: false,
414437
routes: HttpRoutesEnabled {
415438
base: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
416439
webhook: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
@@ -426,6 +449,7 @@ impl TestHarness {
426449
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
427450
authenticator_kind: AuthenticatorKind::None,
428451
enable_tls: false,
452+
behind_trusted_proxy: false,
429453
routes: HttpRoutesEnabled {
430454
base: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
431455
webhook: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
@@ -478,13 +502,15 @@ impl TestHarness {
478502
authenticator_kind: AuthenticatorKind::Password,
479503
allowed_roles: AllowedRoles::NormalAndInternal,
480504
enable_tls,
505+
behind_trusted_proxy: false,
481506
},
482507
},
483508
http: btreemap! {
484509
"external".to_owned() => HttpListenerConfig {
485510
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
486511
authenticator_kind: AuthenticatorKind::Password,
487512
enable_tls,
513+
behind_trusted_proxy: false,
488514
routes: HttpRoutesEnabled {
489515
base: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
490516
webhook: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
@@ -500,6 +526,7 @@ impl TestHarness {
500526
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
501527
authenticator_kind: AuthenticatorKind::None,
502528
enable_tls: false,
529+
behind_trusted_proxy: false,
503530
routes: HttpRoutesEnabled {
504531
base: RouteGroup::Disabled,
505532
webhook: RouteGroup::Disabled,
@@ -526,13 +553,15 @@ impl TestHarness {
526553
authenticator_kind: AuthenticatorKind::Sasl,
527554
allowed_roles: AllowedRoles::NormalAndInternal,
528555
enable_tls,
556+
behind_trusted_proxy: false,
529557
},
530558
},
531559
http: btreemap! {
532560
"external".to_owned() => HttpListenerConfig {
533561
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
534562
authenticator_kind: AuthenticatorKind::Password,
535563
enable_tls,
564+
behind_trusted_proxy: false,
536565
routes: HttpRoutesEnabled {
537566
base: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
538567
webhook: RouteGroup::Enabled(AllowedRoles::NormalAndInternal),
@@ -548,6 +577,7 @@ impl TestHarness {
548577
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
549578
authenticator_kind: AuthenticatorKind::None,
550579
enable_tls: false,
580+
behind_trusted_proxy: false,
551581
routes: HttpRoutesEnabled {
552582
base: RouteGroup::Disabled,
553583
webhook: RouteGroup::Disabled,

src/environmentd/tests/pgwire.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,3 +1294,83 @@ fn test_pgtest_mz_frontend_occ_pipelined_dml() {
12941294
},
12951295
);
12961296
}
1297+
1298+
const CLIENT_IP_QUERY: &str =
1299+
"SELECT client_ip FROM mz_internal.mz_sessions WHERE connection_id = pg_backend_pid()";
1300+
1301+
/// Runs `query` on a connection whose startup packet carries `params`, and
1302+
/// returns the single text value it selects.
1303+
///
1304+
/// The whole exchange is pipelined and terminated up front so the server closes
1305+
/// the connection, which lets the response be read and parsed in one shot.
1306+
fn query_with_startup_params(
1307+
addr: std::net::SocketAddr,
1308+
params: Vec<(&str, &str)>,
1309+
query: &str,
1310+
) -> String {
1311+
use postgres_protocol::message::backend::Message;
1312+
use postgres_protocol::message::frontend;
1313+
1314+
let mut buf = BytesMut::new();
1315+
frontend::startup_message(params, &mut buf).unwrap();
1316+
frontend::query(query, &mut buf).unwrap();
1317+
frontend::terminate(&mut buf);
1318+
1319+
let mut stream = TcpStream::connect(addr).unwrap();
1320+
stream.write_all(&buf).unwrap();
1321+
let mut response = vec![];
1322+
stream.read_to_end(&mut response).unwrap();
1323+
1324+
let mut response = BytesMut::from(&response[..]);
1325+
let mut values = vec![];
1326+
while let Some(message) = Message::parse(&mut response).unwrap() {
1327+
if let Message::DataRow(body) = message {
1328+
let buf = body.buffer().to_vec();
1329+
values.extend(
1330+
body.ranges()
1331+
.map(|range| Ok(String::from_utf8(buf[range.unwrap()].to_vec()).unwrap()))
1332+
.collect::<Vec<_>>()
1333+
.unwrap(),
1334+
);
1335+
}
1336+
}
1337+
values.into_element()
1338+
}
1339+
1340+
// A client that reaches a listener directly must not be able to choose the
1341+
// client IP recorded for its session, as network policies are evaluated
1342+
// against it. Only a listener that a trusted proxy fronts may believe
1343+
// `mz_forwarded_for`.
1344+
#[mz_ore::test]
1345+
#[allow(clippy::disallowed_methods)]
1346+
fn test_forwarded_client_ip_requires_trusted_proxy() {
1347+
let untrusted = test_util::TestHarness::default().start_blocking();
1348+
assert_eq!(
1349+
query_with_startup_params(
1350+
untrusted.sql_local_addr(),
1351+
vec![
1352+
("user", "materialize"),
1353+
("mz_forwarded_for", "1.2.3.4"),
1354+
("welcome_message", "off"),
1355+
],
1356+
CLIENT_IP_QUERY,
1357+
),
1358+
"127.0.0.1",
1359+
);
1360+
1361+
let trusted = test_util::TestHarness::default()
1362+
.behind_trusted_proxy()
1363+
.start_blocking();
1364+
assert_eq!(
1365+
query_with_startup_params(
1366+
trusted.sql_local_addr(),
1367+
vec![
1368+
("user", "materialize"),
1369+
("mz_forwarded_for", "1.2.3.4"),
1370+
("welcome_message", "off"),
1371+
],
1372+
CLIENT_IP_QUERY,
1373+
),
1374+
"1.2.3.4",
1375+
);
1376+
}

src/environmentd/tests/server.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7356,3 +7356,63 @@ fn test_startup_only_system_var_warns() {
73567356
"RESET ALL warned without changing anything, notices: {notices:?}"
73577357
);
73587358
}
7359+
7360+
// A client that reaches a listener directly must not be able to choose the
7361+
// client IP recorded for its session, as network policies are evaluated
7362+
// against it. Only a listener that a trusted proxy fronts may believe a PROXY
7363+
// protocol header.
7364+
#[mz_ore::test]
7365+
#[allow(clippy::disallowed_methods)]
7366+
fn test_proxy_header_requires_trusted_proxy() {
7367+
const SPOOFED_IP: Ipv4Addr = Ipv4Addr::new(1, 2, 3, 4);
7368+
const CLIENT_IP_QUERY: &str =
7369+
"SELECT client_ip FROM mz_internal.mz_sessions WHERE connection_id = pg_backend_pid()";
7370+
7371+
/// A PROXY protocol v2 header for an IPv4 TCP connection from `source`.
7372+
fn proxy_v2_header(source: Ipv4Addr) -> Vec<u8> {
7373+
let mut header = b"\r\n\r\n\x00\r\nQUIT\n".to_vec();
7374+
header.extend([0x21, 0x11]); // v2 PROXY command, IPv4 over TCP
7375+
header.extend(12u16.to_be_bytes()); // length of the address block
7376+
header.extend(source.octets());
7377+
header.extend(Ipv4Addr::LOCALHOST.octets());
7378+
header.extend(1111u16.to_be_bytes()); // source port
7379+
header.extend(1111u16.to_be_bytes()); // destination port
7380+
header
7381+
}
7382+
7383+
fn query_behind_proxy_header(addr: std::net::SocketAddr) -> String {
7384+
let body = serde_json::json!({ "query": CLIENT_IP_QUERY }).to_string();
7385+
let request = format!(
7386+
"POST /api/sql HTTP/1.1\r\n\
7387+
Host: {addr}\r\n\
7388+
Content-Type: application/json\r\n\
7389+
Content-Length: {}\r\n\
7390+
Connection: close\r\n\r\n{body}",
7391+
body.len(),
7392+
);
7393+
let mut stream = std::net::TcpStream::connect(addr).unwrap();
7394+
stream.write_all(&proxy_v2_header(SPOOFED_IP)).unwrap();
7395+
stream.write_all(request.as_bytes()).unwrap();
7396+
let mut response = vec![];
7397+
std::io::Read::read_to_end(&mut stream, &mut response).unwrap();
7398+
String::from_utf8_lossy(&response).into_owned()
7399+
}
7400+
7401+
// The header bytes stay in the stream, so the request does not parse as
7402+
// HTTP and never reaches the session.
7403+
let untrusted = test_util::TestHarness::default().start_blocking();
7404+
let response = query_behind_proxy_header(untrusted.http_local_addr());
7405+
assert!(
7406+
!response.contains(&SPOOFED_IP.to_string()),
7407+
"unexpected response: {response}"
7408+
);
7409+
7410+
let trusted = test_util::TestHarness::default()
7411+
.behind_trusted_proxy()
7412+
.start_blocking();
7413+
let response = query_behind_proxy_header(trusted.http_local_addr());
7414+
assert!(
7415+
response.contains(&SPOOFED_IP.to_string()),
7416+
"unexpected response: {response}"
7417+
);
7418+
}

0 commit comments

Comments
 (0)