Skip to content

Commit edea9a7

Browse files
immanuwellEugeny
andauthored
fix: parse forwarded header lists (#1944)
Co-authored-by: Eugene <inbox@null.page>
1 parent f50e074 commit edea9a7

4 files changed

Lines changed: 162 additions & 30 deletions

File tree

warpgate-common-http/src/auth.rs

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
use std::ops::Deref;
22

33
use poem::Request;
4-
use poem::http::header::HOST;
54
use poem::http::uri::{Authority, Scheme};
65
use serde::{Deserialize, Serialize};
76
use uuid::Uuid;
8-
use warpgate_common::http_headers::{X_FORWARDED_HOST, X_FORWARDED_PROTO};
7+
8+
use crate::request::{trusted_host_header, trusted_proto};
99

1010
#[derive(Clone, Serialize, Deserialize)]
1111
pub struct AuthStateId(pub Uuid);
@@ -86,16 +86,7 @@ impl UnauthenticatedRequestContext {
8686
}
8787

8888
pub fn trusted_host_header(&self, req: &Request) -> Option<String> {
89-
if self.should_trust_x_forwarded
90-
&& let Some(xfh) = req.header(&X_FORWARDED_HOST)
91-
{
92-
Some(xfh.to_string())
93-
} else {
94-
req.header(HOST).map(ToString::to_string).or_else(|| {
95-
let uri = req.original_uri();
96-
uri.authority().map(|authority| authority.to_string())
97-
})
98-
}
89+
trusted_host_header(self.should_trust_x_forwarded, req)
9990
}
10091

10192
/// Returns the trusted hostname only (port stripped),
@@ -115,17 +106,7 @@ impl UnauthenticatedRequestContext {
115106
/// Returns the trusted protocol scheme for the request, preferring X-Forwarded-Proto
116107
/// if trust_x_forwarded_headers is enabled in config.
117108
pub fn trusted_proto(&self, req: &Request) -> Scheme {
118-
if self.should_trust_x_forwarded
119-
&& let Some(proto) = req.header(&X_FORWARDED_PROTO)
120-
&& let Ok(s) = Scheme::try_from(proto)
121-
{
122-
s
123-
} else {
124-
req.original_uri()
125-
.scheme()
126-
.cloned()
127-
.unwrap_or(Scheme::HTTPS)
128-
}
109+
trusted_proto(self.should_trust_x_forwarded, req)
129110
}
130111
}
131112

warpgate-common-http/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod auth;
22
pub mod ext;
33
pub mod logging;
4+
mod request;
45

56
pub use auth::{AuthenticatedRequestContext, RequestAuthorization, SessionAuthorization};
67
use poem::http::HeaderName;

warpgate-common-http/src/logging.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use poem::{Addr, Request};
77
use tracing::*;
88
use warpgate_core::{Services, WarpgateServerHandle};
99

10+
use crate::request::trusted_client_ip;
11+
1012
pub async fn get_client_ip(req: &Request, services: &Services) -> Option<String> {
1113
let trust_x_forwarded_headers = {
1214
let config = services.config.lock().await;
@@ -28,13 +30,7 @@ pub async fn get_client_ip(req: &Request, services: &Services) -> Option<String>
2830

2931
let remote_ip = socket_addr.map(|x| x.ip().to_string());
3032

31-
if trust_x_forwarded_headers {
32-
req.header("x-forwarded-for")
33-
.map(str::to_string)
34-
.or(remote_ip)
35-
} else {
36-
remote_ip
37-
}
33+
trusted_client_ip(req, remote_ip, trust_x_forwarded_headers)
3834
}
3935

4036
pub async fn span_for_request(
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
use poem::Request;
2+
use poem::http::header::HOST;
3+
use poem::http::uri::Scheme;
4+
use warpgate_common::http_headers::{X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO};
5+
6+
pub(crate) fn first_forwarded_header_value(value: &str) -> Option<&str> {
7+
value
8+
.split(',')
9+
.map(str::trim)
10+
.find(|value| !value.is_empty())
11+
}
12+
13+
pub(crate) fn trusted_host_header(should_trust_x_forwarded: bool, req: &Request) -> Option<String> {
14+
if should_trust_x_forwarded
15+
&& let Some(host) = req
16+
.header(&X_FORWARDED_HOST)
17+
.and_then(first_forwarded_header_value)
18+
{
19+
return Some(host.to_string());
20+
}
21+
22+
req.header(HOST).map(ToString::to_string).or_else(|| {
23+
let uri = req.original_uri();
24+
uri.authority().map(|authority| authority.to_string())
25+
})
26+
}
27+
28+
pub(crate) fn trusted_proto(should_trust_x_forwarded: bool, req: &Request) -> Scheme {
29+
if should_trust_x_forwarded
30+
&& let Some(proto) = req
31+
.header(&X_FORWARDED_PROTO)
32+
.and_then(first_forwarded_header_value)
33+
&& let Ok(s) = Scheme::try_from(proto)
34+
{
35+
s
36+
} else {
37+
req.original_uri()
38+
.scheme()
39+
.cloned()
40+
.unwrap_or(Scheme::HTTPS)
41+
}
42+
}
43+
44+
pub(crate) fn trusted_client_ip(
45+
req: &Request,
46+
remote_ip: Option<String>,
47+
trust_x_forwarded: bool,
48+
) -> Option<String> {
49+
if trust_x_forwarded
50+
&& let Some(ip) = req
51+
.header(&X_FORWARDED_FOR)
52+
.and_then(first_forwarded_header_value)
53+
{
54+
Some(ip.to_string())
55+
} else {
56+
remote_ip
57+
}
58+
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use poem::Request;
63+
use poem::http::header::HOST;
64+
65+
use super::*;
66+
67+
fn trusted_header_request(
68+
forwarded_host: Option<&str>,
69+
forwarded_proto: Option<&str>,
70+
) -> Request {
71+
let mut builder = Request::builder()
72+
.uri_str("http://internal.example")
73+
.header(HOST, "fallback.example");
74+
75+
if let Some(value) = forwarded_host {
76+
builder = builder.header(&X_FORWARDED_HOST, value);
77+
}
78+
if let Some(value) = forwarded_proto {
79+
builder = builder.header(&X_FORWARDED_PROTO, value);
80+
}
81+
82+
builder.finish()
83+
}
84+
85+
#[test]
86+
fn trusted_host_uses_first_forwarded_host() {
87+
let req = trusted_header_request(Some("public.example, proxy.local"), None);
88+
89+
assert_eq!(
90+
trusted_host_header(true, &req),
91+
Some("public.example".to_string())
92+
);
93+
}
94+
95+
#[test]
96+
fn trusted_host_falls_back_when_forwarded_host_is_empty() {
97+
let req = trusted_header_request(Some(" , "), None);
98+
99+
assert_eq!(
100+
trusted_host_header(true, &req),
101+
Some("fallback.example".to_string())
102+
);
103+
}
104+
105+
#[test]
106+
fn trusted_proto_uses_first_forwarded_proto() {
107+
let req = trusted_header_request(None, Some("https, http"));
108+
109+
assert_eq!(trusted_proto(true, &req), Scheme::HTTPS);
110+
}
111+
112+
#[test]
113+
fn first_forwarded_header_value_skips_empty_items() {
114+
assert_eq!(
115+
first_forwarded_header_value(" , public.example, proxy.local"),
116+
Some("public.example")
117+
);
118+
assert_eq!(first_forwarded_header_value(" , "), None);
119+
}
120+
121+
#[test]
122+
fn trusted_client_ip_uses_first_forwarded_for_value() {
123+
let req = Request::builder()
124+
.header(&X_FORWARDED_FOR, "203.0.113.10, 10.0.0.2")
125+
.finish();
126+
127+
assert_eq!(
128+
trusted_client_ip(&req, Some("10.0.0.1".to_string()), true),
129+
Some("203.0.113.10".to_string())
130+
);
131+
}
132+
133+
#[test]
134+
fn trusted_client_ip_falls_back_when_forwarded_for_is_empty() {
135+
let req = Request::builder().header(&X_FORWARDED_FOR, " , ").finish();
136+
137+
assert_eq!(
138+
trusted_client_ip(&req, Some("10.0.0.1".to_string()), true),
139+
Some("10.0.0.1".to_string())
140+
);
141+
}
142+
143+
#[test]
144+
fn client_ip_ignores_forwarded_for_when_not_trusted() {
145+
let req = Request::builder()
146+
.header(&X_FORWARDED_FOR, "203.0.113.10")
147+
.finish();
148+
149+
assert_eq!(
150+
trusted_client_ip(&req, Some("10.0.0.1".to_string()), false),
151+
Some("10.0.0.1".to_string())
152+
);
153+
}
154+
}

0 commit comments

Comments
 (0)