Skip to content

Commit 3663d51

Browse files
gitrcclaude
andcommitted
Add --allow-ip flag for IP-based access control
Requests from IPs not in the allowlist are rejected with 403 Forbidden. Uses CF-Connecting-IP (or X-Forwarded-For) header to identify the real client IP. Blocked requests still appear in the TUI dashboard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 80e94f9 commit 3663d51

5 files changed

Lines changed: 56 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,9 @@ pub struct Args {
5757
/// Update the cached cloudflared binary to the latest version
5858
#[arg(long)]
5959
pub update: bool,
60+
61+
/// Only allow requests from these IPs (checked via CF-Connecting-IP header).
62+
/// Can be specified multiple times. When set, all other IPs get 403.
63+
#[arg(long = "allow-ip", env = "CFPROXY_ALLOW_IP", value_delimiter = ',')]
64+
pub allow_ip: Vec<String>,
6065
}

src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub struct Config {
1212
pub mock_rules: Vec<MockRule>,
1313
pub host: Option<String>,
1414
pub quick: bool,
15+
pub allow_ips: Vec<String>,
1516
}
1617

1718
impl Config {
@@ -42,6 +43,7 @@ impl Config {
4243
mock_rules,
4344
host: args.host,
4445
quick: args.quick,
46+
allow_ips: args.allow_ip,
4547
}
4648
}
4749
}
@@ -77,6 +79,7 @@ mod tests {
7779
purge: false,
7880
doctor: false,
7981
update: false,
82+
allow_ip: Vec::new(),
8083
};
8184
let config = Config::from_args(args);
8285
assert!(!config.auto_download);

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ async fn main() -> error::Result<()> {
9696
}
9797

9898
// Start local reverse proxy to capture HTTP requests
99-
let proxy_port = proxy::start(config.port, tx.clone(), config.auth, mock_rules.clone()).await?;
99+
let proxy_port = proxy::start(config.port, tx.clone(), config.auth, mock_rules.clone(), config.allow_ips).await?;
100100
tracing::info!("request logger listening on 127.0.0.1:{}", proxy_port);
101101

102102
let port = config.port;

src/proxy.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashSet;
12
use std::sync::Arc;
23
use std::time::Instant;
34

@@ -17,17 +18,22 @@ use crate::error::Result;
1718
use crate::event::{HeaderPair, HttpRequest, TunnelEvent, WebSocketFrame, WsDirection, WsOpcode};
1819
use crate::mock::MockRules;
1920

21+
/// IP allowlist: when non-empty, only listed IPs may access the proxy.
22+
pub type AllowIps = Arc<HashSet<String>>;
23+
2024
/// Start a local reverse proxy that forwards to `target_port` and logs requests.
2125
/// Returns the port the proxy is listening on.
2226
pub async fn start(
2327
target_port: u16,
2428
tx: mpsc::Sender<TunnelEvent>,
2529
auth: Option<(String, String)>,
2630
mock_rules: MockRules,
31+
allow_ips: Vec<String>,
2732
) -> Result<u16> {
2833
let listener = TcpListener::bind("127.0.0.1:0").await?;
2934
let proxy_port = listener.local_addr()?.port();
3035
let auth = auth.map(Arc::new);
36+
let allow_ips: AllowIps = Arc::new(allow_ips.into_iter().collect());
3137

3238
tokio::spawn(async move {
3339
loop {
@@ -38,14 +44,16 @@ pub async fn start(
3844
let tx = tx.clone();
3945
let auth = auth.clone();
4046
let mock_rules = mock_rules.clone();
47+
let allow_ips = allow_ips.clone();
4148
let io = TokioIo::new(stream);
4249

4350
tokio::spawn(async move {
4451
let svc = service_fn(move |req| {
4552
let tx = tx.clone();
4653
let auth = auth.clone();
4754
let mock_rules = mock_rules.clone();
48-
handle(req, target_port, tx, auth, mock_rules)
55+
let allow_ips = allow_ips.clone();
56+
handle(req, target_port, tx, auth, mock_rules, allow_ips)
4957
});
5058
let conn = http1::Builder::new()
5159
.serve_connection(io, svc)
@@ -66,6 +74,7 @@ async fn handle(
6674
tx: mpsc::Sender<TunnelEvent>,
6775
auth: Option<Arc<(String, String)>>,
6876
mock_rules: MockRules,
77+
allow_ips: AllowIps,
6978
) -> std::result::Result<Response<Full<Bytes>>, hyper::Error> {
7079
let start = Instant::now();
7180
let method = req.method().to_string();
@@ -81,6 +90,42 @@ async fn handle(
8190
let user_agent = header_val(req.headers(), "user-agent");
8291
let request_headers = collect_headers(req.headers());
8392

93+
// Check IP allowlist before anything else
94+
if !allow_ips.is_empty() {
95+
let allowed = remote_ip
96+
.as_ref()
97+
.map(|ip| allow_ips.contains(ip))
98+
.unwrap_or(false);
99+
if !allowed {
100+
let duration = start.elapsed();
101+
let _ = tx
102+
.send(TunnelEvent::HttpRequest(HttpRequest {
103+
method,
104+
path,
105+
status: 403,
106+
duration,
107+
remote_ip,
108+
country,
109+
user_agent,
110+
request_headers,
111+
response_headers: Vec::new(),
112+
request_size: 0,
113+
response_size: 0,
114+
request_body: None,
115+
response_body: None,
116+
is_websocket: false,
117+
ws_frames: Vec::new(),
118+
is_mock: false,
119+
timestamp: chrono::Local::now(),
120+
}))
121+
.await;
122+
return Ok(Response::builder()
123+
.status(hyper::StatusCode::FORBIDDEN)
124+
.body(Full::new(Bytes::from("Forbidden")))
125+
.unwrap());
126+
}
127+
}
128+
84129
// Check Basic Auth before forwarding
85130
if let Some(ref expected) = auth {
86131
let authorized = header_val(req.headers(), "authorization")

0 commit comments

Comments
 (0)