From eac789e52f5fe9b4c10974ac6239c2e7b0f093b9 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:22:48 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Cloudflare=20=E5=9C=A8=E5=89=8D?= =?UTF-8?q?=E6=97=B6=E6=8C=89=20CF-Connecting-IP=20=E5=8F=96=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hub 前面套 Cloudflare(域名开代理)再接本地反代时,X-Forwarded-For 的最后一跳是 Cloudflare 边缘的地址。 节点的出口、按出口查的国家码、登录限流与注册锁定、登录通知里的地址,都会变成边缘的地址。 来源地址(XFF 链尾,或直连时的 peer)落在 Cloudflare 公布的网段里时,改读 CF-Connecting-IP。 Cloudflare 每次转发都会重写这个头;网段之外的连接带着它也不采信,直连源站伪造不了。 Closes #28 --- src/auth.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 21205dfe..24ddcbc1 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -392,23 +392,67 @@ async fn github_login(app: &App, code: &str) -> Result { /// the operator's address locks them out of the sign-in page. /// /// A second trusted proxy in front of the local one places its own address at -/// the tail instead. No single value in this header identifies the client, so -/// such a deployment must have its edge write the client address. +/// the tail instead. That address is Cloudflare's for most such deployments, and +/// Cloudflare replaces `CF-Connecting-IP` on every request it forwards, so the +/// header is read when, and only when, the hop is one of its published ranges. +/// Anyone can send the header; only Cloudflare can connect from those ranges. +/// Another CDN is left to the local proxy's real-IP configuration. /// -/// Both addresses are canonicalized: the default dual-stack `[::]` listener +/// Every address is canonicalized: the default dual-stack `[::]` listener /// reports IPv4 peers, 127.0.0.1 included, as `::ffff:a.b.c.d`, which no IPv6 /// range below recognizes as local. pub fn client_ip(headers: &HeaderMap, peer: IpAddr) -> IpAddr { + // The last value, which for CF-Connecting-IP is the only one. + let last = |name: &str| { + let value = headers.get(name)?.to_str().ok()?.rsplit(',').next()?; + value.trim().parse::().ok().map(|ip| ip.to_canonical()) + }; let peer = peer.to_canonical(); - if !behind_local_proxy(peer) { - return peer; + let hop = if behind_local_proxy(peer) { last("x-forwarded-for").unwrap_or(peer) } else { peer }; + if cloudflare(hop) { + return last("cf-connecting-ip").unwrap_or(hop); } - headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.rsplit(',').next()) - .and_then(|v| v.trim().parse::().ok()) - .map_or(peer, |ip| ip.to_canonical()) + hop +} + +/// https://www.cloudflare.com/ips/, unchanged since 2021. A range added later +/// only leaves its visitors under the edge's address, as before this list. +const CLOUDFLARE: [&str; 22] = [ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", +]; + +fn cloudflare(ip: IpAddr) -> bool { + CLOUDFLARE.iter().any(|net| { + let (base, len) = net.split_once('/').unwrap(); + let len: u32 = len.parse().unwrap(); + // Within the range when the first `len` bits agree. + match (ip, base.parse().unwrap()) { + (IpAddr::V4(a), IpAddr::V4(b)) => (u32::from(a) ^ u32::from(b)).leading_zeros() >= len, + (IpAddr::V6(a), IpAddr::V6(b)) => (u128::from(a) ^ u128::from(b)).leading_zeros() >= len, + _ => false, + } + }) } /// Loopback or a private network, where a reverse proxy resides. @@ -584,5 +628,26 @@ mod tests { assert_eq!(client_ip(&forged, ip("2001:db8::5")), ip("2001:db8::5")); // No header at all: the peer address is used. assert_eq!(client_ip(&HeaderMap::new(), ip("10.0.0.1")), ip("10.0.0.1")); + + // Cloudflare in front of the local proxy: the tail is its edge, and the + // client is in the header the edge rewrites. The same when the edge + // connects to the hub itself. + let mut edge = xff("10.0.0.2, 203.0.113.7, 162.158.88.126"); + edge.insert("cf-connecting-ip", "203.0.113.7".parse().unwrap()); + assert_eq!(client_ip(&edge, ip("127.0.0.1")), ip("203.0.113.7")); + // The last address of 162.158.0.0/15. + assert_eq!(client_ip(&edge, ip("::ffff:162.159.255.255")), ip("203.0.113.7")); + let mut edge6 = xff("2a06:98c0:3600::103"); + edge6.insert("cf-connecting-ip", "2001:db8::7".parse().unwrap()); + assert_eq!(client_ip(&edge6, ip("::1")), ip("2001:db8::7")); + // Next to the ranges, or without the edge in the path, the header is the + // caller's own and changes nothing. + let mut direct = xff("198.51.100.9"); + direct.insert("cf-connecting-ip", "203.0.113.7".parse().unwrap()); + assert_eq!(client_ip(&direct, ip("127.0.0.1")), ip("198.51.100.9")); + assert_eq!(client_ip(&direct, ip("162.160.0.1")), ip("162.160.0.1")); + assert_eq!(client_ip(&direct, ip("2a06:98c8::1")), ip("2a06:98c8::1")); + // An edge that sent nothing usable leaves its own address. + assert_eq!(client_ip(&xff("162.158.88.126"), ip("127.0.0.1")), ip("162.158.88.126")); } } From a26b810ba84cfca385b9ff1cb18af066a32ff800 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:15:21 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20CF-Connecting-IP=20=E5=8F=AA?= =?UTF-8?q?=E7=94=A8=E4=BA=8E=E8=8A=82=E7=82=B9=E5=9C=B0=E5=9D=80=EF=BC=8C?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=99=90=E6=B5=81=E4=B8=8E=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E9=94=81=E5=AE=9A=E4=B8=8D=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版按「来源在 Cloudflare 网段内」采信这个头,但能从 Cloudflare 网络连到源站的不只 Cloudflare 自己的代理:Worker 的原始 TCP socket 同样从这些网段出来,请求头随便写。于是任何人都能换着值绕过 登录锁定,或填上管理员的地址把他锁在外面;而且这条规则对不在 Cloudflare 后面的 hub 也生效。 改为新增 auth::node_ip,只在 agent 握手、节点 token 验过之后读这个头,用作节点的出口与国家码的 来源。持有 token 的一方本来就能上报任意地址,没有新增的伪造面。client_ip 回到原样,登录限流、 注册锁定仍按链尾计。网段表随之删除;隧道先经本机 nginx 再到 hub 的链路,节点地址也一并取对。 --- src/agent_ws.rs | 4 +- src/auth.rs | 107 ++++++++++++++++-------------------------------- 2 files changed, 37 insertions(+), 74 deletions(-) diff --git a/src/agent_ws.rs b/src/agent_ws.rs index a2aa1b93..53eec2f5 100644 --- a/src/agent_ws.rs +++ b/src/agent_ws.rs @@ -19,7 +19,7 @@ use serde_json::json; use tokio::sync::mpsc; use tracing::{debug, info, warn}; -use crate::auth::client_ip; +use crate::auth::node_ip; use crate::{App, Shared}; /// How often a quiet agent is probed, and how long the hub waits for any frame @@ -151,7 +151,7 @@ pub async fn handler( // The same response whether the token is malformed or merely unknown. return (StatusCode::UNAUTHORIZED, "invalid token").into_response(); }; - let ip = client_ip(&headers, peer.ip()).to_string(); + let ip = node_ip(&headers, peer.ip()).to_string(); upgrade.read_buffer_size(crate::api::SOCKET_BUFFER).max_message_size(crate::api::MAX_FRAME).on_upgrade( move |socket| async move { diff --git a/src/auth.rs b/src/auth.rs index 24ddcbc1..e9394cdb 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -392,67 +392,40 @@ async fn github_login(app: &App, code: &str) -> Result { /// the operator's address locks them out of the sign-in page. /// /// A second trusted proxy in front of the local one places its own address at -/// the tail instead. That address is Cloudflare's for most such deployments, and -/// Cloudflare replaces `CF-Connecting-IP` on every request it forwards, so the -/// header is read when, and only when, the hop is one of its published ranges. -/// Anyone can send the header; only Cloudflare can connect from those ranges. -/// Another CDN is left to the local proxy's real-IP configuration. +/// the tail instead. No single value in this header identifies the client, so +/// such a deployment must have its edge write the client address. /// -/// Every address is canonicalized: the default dual-stack `[::]` listener +/// Both addresses are canonicalized: the default dual-stack `[::]` listener /// reports IPv4 peers, 127.0.0.1 included, as `::ffff:a.b.c.d`, which no IPv6 /// range below recognizes as local. pub fn client_ip(headers: &HeaderMap, peer: IpAddr) -> IpAddr { - // The last value, which for CF-Connecting-IP is the only one. - let last = |name: &str| { - let value = headers.get(name)?.to_str().ok()?.rsplit(',').next()?; - value.trim().parse::().ok().map(|ip| ip.to_canonical()) - }; let peer = peer.to_canonical(); - let hop = if behind_local_proxy(peer) { last("x-forwarded-for").unwrap_or(peer) } else { peer }; - if cloudflare(hop) { - return last("cf-connecting-ip").unwrap_or(hop); + if !behind_local_proxy(peer) { + return peer; } - hop + headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.rsplit(',').next()) + .and_then(|v| v.trim().parse::().ok()) + .map_or(peer, |ip| ip.to_canonical()) } -/// https://www.cloudflare.com/ips/, unchanged since 2021. A range added later -/// only leaves its visitors under the edge's address, as before this list. -const CLOUDFLARE: [&str; 22] = [ - "173.245.48.0/20", - "103.21.244.0/22", - "103.22.200.0/22", - "103.31.4.0/22", - "141.101.64.0/18", - "108.162.192.0/18", - "190.93.240.0/20", - "188.114.96.0/20", - "197.234.240.0/22", - "198.41.128.0/17", - "162.158.0.0/15", - "104.16.0.0/13", - "104.24.0.0/14", - "172.64.0.0/13", - "131.0.72.0/22", - "2400:cb00::/32", - "2606:4700::/32", - "2803:f800::/32", - "2405:b500::/32", - "2405:8100::/32", - "2a06:98c0::/29", - "2c0f:f248::/32", -]; - -fn cloudflare(ip: IpAddr) -> bool { - CLOUDFLARE.iter().any(|net| { - let (base, len) = net.split_once('/').unwrap(); - let len: u32 = len.parse().unwrap(); - // Within the range when the first `len` bits agree. - match (ip, base.parse().unwrap()) { - (IpAddr::V4(a), IpAddr::V4(b)) => (u32::from(a) ^ u32::from(b)).leading_zeros() >= len, - (IpAddr::V6(a), IpAddr::V6(b)) => (u128::from(a) ^ u128::from(b)).leading_zeros() >= len, - _ => false, - } - }) +/// Where a node's connection came from: its exit on the panel, and the address +/// its country falls back to. Called only once the node's token has checked out. +/// +/// Cloudflare in front of the hub, by proxy or tunnel, writes the node's address +/// into `CF-Connecting-IP`, while [`client_ip`] sees its edge or the local proxy. +/// The header is not read for throttling: anyone reaching the origin from +/// Cloudflare's network -- a Worker's raw socket, for one -- can write it. A +/// token holder, the only caller here, can report any address of its own +/// already. +pub fn node_ip(headers: &HeaderMap, peer: IpAddr) -> IpAddr { + headers + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()) + .map_or_else(|| client_ip(headers, peer), |ip| ip.to_canonical()) } /// Loopback or a private network, where a reverse proxy resides. @@ -629,25 +602,15 @@ mod tests { // No header at all: the peer address is used. assert_eq!(client_ip(&HeaderMap::new(), ip("10.0.0.1")), ip("10.0.0.1")); - // Cloudflare in front of the local proxy: the tail is its edge, and the - // client is in the header the edge rewrites. The same when the edge - // connects to the hub itself. + // Cloudflare in front of the local proxy: the tail is its edge. The + // throttle stays on it, as anyone on Cloudflare's network can write + // CF-Connecting-IP; a node's address follows the header. let mut edge = xff("10.0.0.2, 203.0.113.7, 162.158.88.126"); - edge.insert("cf-connecting-ip", "203.0.113.7".parse().unwrap()); - assert_eq!(client_ip(&edge, ip("127.0.0.1")), ip("203.0.113.7")); - // The last address of 162.158.0.0/15. - assert_eq!(client_ip(&edge, ip("::ffff:162.159.255.255")), ip("203.0.113.7")); - let mut edge6 = xff("2a06:98c0:3600::103"); - edge6.insert("cf-connecting-ip", "2001:db8::7".parse().unwrap()); - assert_eq!(client_ip(&edge6, ip("::1")), ip("2001:db8::7")); - // Next to the ranges, or without the edge in the path, the header is the - // caller's own and changes nothing. - let mut direct = xff("198.51.100.9"); - direct.insert("cf-connecting-ip", "203.0.113.7".parse().unwrap()); - assert_eq!(client_ip(&direct, ip("127.0.0.1")), ip("198.51.100.9")); - assert_eq!(client_ip(&direct, ip("162.160.0.1")), ip("162.160.0.1")); - assert_eq!(client_ip(&direct, ip("2a06:98c8::1")), ip("2a06:98c8::1")); - // An edge that sent nothing usable leaves its own address. - assert_eq!(client_ip(&xff("162.158.88.126"), ip("127.0.0.1")), ip("162.158.88.126")); + edge.insert("cf-connecting-ip", "::ffff:203.0.113.7".parse().unwrap()); + assert_eq!(client_ip(&edge, ip("127.0.0.1")), ip("162.158.88.126")); + assert_eq!(node_ip(&edge, ip("127.0.0.1")), ip("203.0.113.7")); + // Without the header a node's address is the same as the throttle's. + assert_eq!(node_ip(&forged, ip("127.0.0.1")), ip("198.51.100.9")); + assert_eq!(node_ip(&forged, ip("203.0.113.5")), ip("203.0.113.5")); } }