Skip to content

Commit 26d2d36

Browse files
committed
security: strip identity-revealing forwarding headers in relay path (#104)
filter_forwarded_headers was stripping hop-by-hop headers (Host, Connection, Content-Length, etc.) but not identity-revealing forwarding headers. If a user sat behind another proxy or ran a browser extension that inserts any of: X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-Port, X-Forwarded-Server, X-Forwarded-Ssl, Forwarded, Via, X-Real-IP, X-Client-IP, X-Originating-IP, True-Client-IP, CF-Connecting-IP, Fastly-Client-IP, X-Cluster-Client-IP, Client-IP those would carry the client's real IP all the way through the Apps Script relay to the origin server. Stripping them so the origin only ever sees whatever source IP the Apps Script / GFE path terminates on. This covers the Apps Script relay path (the main leak vector). The SNI-rewrite tunnel path is a raw TLS byte bridge — it doesn't parse HTTP at all — so any headers the client emits there pass through as opaque bytes to the Google edge that terminates TLS. In practice that's narrower (origin sees GFE) but documenting the caveat on the issue thread. Adds a focused regression test that locks in every stripped header. Reported in #104.
1 parent ccfa62d commit 26d2d36

1 file changed

Lines changed: 76 additions & 0 deletions

File tree

src/domain_fronter.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,12 +1235,35 @@ fn build_sni_pool(primary: &str) -> Vec<String> {
12351235

12361236
pub fn filter_forwarded_headers(headers: &[(String, String)]) -> Vec<(String, String)> {
12371237
const SKIP: &[&str] = &[
1238+
// Hop-by-hop / framing — must not be forwarded across the proxy.
12381239
"host",
12391240
"connection",
12401241
"content-length",
12411242
"transfer-encoding",
12421243
"proxy-connection",
12431244
"proxy-authorization",
1245+
// Identity-revealing forwarding headers (issue #104).
1246+
// If the user sits behind another proxy or uses a browser
1247+
// extension that inserts any of these, they'd normally carry
1248+
// the client's real IP. We strip every known variant so the
1249+
// origin server only ever sees whatever source IP the Apps
1250+
// Script or GFE path terminates on — never the user's home IP.
1251+
"x-forwarded-for",
1252+
"x-forwarded-host",
1253+
"x-forwarded-proto",
1254+
"x-forwarded-port",
1255+
"x-forwarded-server",
1256+
"x-forwarded-ssl",
1257+
"forwarded",
1258+
"via",
1259+
"x-real-ip",
1260+
"x-client-ip",
1261+
"x-originating-ip",
1262+
"true-client-ip",
1263+
"cf-connecting-ip",
1264+
"fastly-client-ip",
1265+
"x-cluster-client-ip",
1266+
"client-ip",
12441267
];
12451268
headers
12461269
.iter()
@@ -1722,6 +1745,59 @@ mod tests {
17221745
use super::*;
17231746
use tokio::io::{duplex, AsyncWriteExt};
17241747

1748+
#[test]
1749+
fn filter_forwarded_headers_strips_identity_revealing_headers() {
1750+
// Issue #104: any proxy/extension that inserts these must not
1751+
// leak the client's real IP to origin via the Apps Script relay.
1752+
let input: Vec<(String, String)> = vec![
1753+
("X-Forwarded-For".into(), "203.0.113.42".into()),
1754+
("X-Real-IP".into(), "203.0.113.42".into()),
1755+
("Forwarded".into(), "for=203.0.113.42".into()),
1756+
("Via".into(), "1.1 squid".into()),
1757+
("CF-Connecting-IP".into(), "203.0.113.42".into()),
1758+
("True-Client-IP".into(), "203.0.113.42".into()),
1759+
("X-Client-IP".into(), "203.0.113.42".into()),
1760+
("Fastly-Client-IP".into(), "203.0.113.42".into()),
1761+
("X-Cluster-Client-IP".into(), "203.0.113.42".into()),
1762+
("Client-IP".into(), "203.0.113.42".into()),
1763+
("X-Originating-IP".into(), "203.0.113.42".into()),
1764+
("X-Forwarded-Host".into(), "internal.example".into()),
1765+
("X-Forwarded-Proto".into(), "https".into()),
1766+
("X-Forwarded-Port".into(), "8080".into()),
1767+
("X-Forwarded-Server".into(), "lb-01.example".into()),
1768+
("X-Forwarded-Ssl".into(), "on".into()),
1769+
// Mix in a legitimate header that MUST pass through.
1770+
("User-Agent".into(), "Mozilla/5.0".into()),
1771+
("Accept".into(), "text/html".into()),
1772+
];
1773+
let out = filter_forwarded_headers(&input);
1774+
let keys: Vec<String> = out.iter().map(|(k, _)| k.to_ascii_lowercase()).collect();
1775+
// All identity-revealing headers must be dropped.
1776+
for h in [
1777+
"x-forwarded-for",
1778+
"x-real-ip",
1779+
"forwarded",
1780+
"via",
1781+
"cf-connecting-ip",
1782+
"true-client-ip",
1783+
"x-client-ip",
1784+
"fastly-client-ip",
1785+
"x-cluster-client-ip",
1786+
"client-ip",
1787+
"x-originating-ip",
1788+
"x-forwarded-host",
1789+
"x-forwarded-proto",
1790+
"x-forwarded-port",
1791+
"x-forwarded-server",
1792+
"x-forwarded-ssl",
1793+
] {
1794+
assert!(!keys.iter().any(|k| k == h), "{} must be stripped", h);
1795+
}
1796+
// And legitimate headers must survive.
1797+
assert!(keys.iter().any(|k| k == "user-agent"));
1798+
assert!(keys.iter().any(|k| k == "accept"));
1799+
}
1800+
17251801
#[test]
17261802
fn normalize_x_graphql_trims_after_variables() {
17271803
// Real-looking x.com GraphQL URL with variables + features +

0 commit comments

Comments
 (0)