Compare commits

...

1 Commits

Author SHA1 Message Date
viyatb-oai
3e4d071987 fix(network-proxy): block CONNECT tunnels to non-443 ports 2026-03-03 15:15:49 -08:00
5 changed files with 84 additions and 6 deletions

View File

@@ -27,7 +27,7 @@ socks_url = "http://127.0.0.1:8081"
enable_socks5_udp = true
# When `enabled` is false, the proxy no-ops and does not bind listeners.
# When true, respect HTTP(S)_PROXY/ALL_PROXY for upstream requests (HTTP(S) proxies only),
# including CONNECT tunnels in full mode.
# including HTTPS CONNECT tunnels to port 443 in full mode.
allow_upstream_proxy = true
# By default, non-loopback binds are clamped to loopback for safety.
# If you want to expose these listeners beyond localhost, you must opt in explicitly.
@@ -88,9 +88,9 @@ When a request is blocked, the proxy responds with `403` and includes:
- `blocked-by-method-policy`
- `blocked-by-policy`
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` requests require
MITM to enforce limited-mode method policy; otherwise they are blocked. SOCKS5 remains blocked in
limited mode.
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` requests to port
`443` require MITM to enforce limited-mode method policy; otherwise they are blocked. SOCKS5
remains blocked in limited mode.
Websocket clients typically tunnel `wss://` through HTTPS `CONNECT`; those CONNECT targets still go
through the same host allowlist/denylist checks.

View File

@@ -79,8 +79,8 @@ pub enum NetworkMode {
/// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests.
/// SOCKS5 remains blocked in limited mode.
Limited,
/// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without
/// MITM interception.
/// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs to port 443 are
/// tunneled without MITM interception.
#[default]
Full,
}

View File

@@ -12,6 +12,7 @@ use crate::network_policy::emit_allow_decision_audit_event;
use crate::network_policy::emit_block_decision_audit_event;
use crate::network_policy::evaluate_host_policy;
use crate::policy::normalize_host;
use crate::reasons::REASON_CONNECT_PORT_NOT_ALLOWED;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_REQUIRED;
use crate::reasons::REASON_NOT_ALLOWED;
@@ -78,6 +79,8 @@ use tracing::error;
use tracing::info;
use tracing::warn;
const HTTPS_CONNECT_PORT: u16 = 443;
pub async fn run_http_proxy(
state: Arc<NetworkProxyState>,
addr: SocketAddr,
@@ -185,6 +188,51 @@ async fn http_connect_accept(
.await);
}
if authority.port != HTTPS_CONNECT_PORT {
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_CONNECT_PORT_NOT_ALLOWED,
protocol: NetworkProtocol::HttpsConnect,
server_address: host.as_str(),
server_port: authority.port,
method: Some("CONNECT"),
client_addr: client.as_deref(),
},
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_CONNECT_PORT_NOT_ALLOWED,
source: NetworkDecisionSource::ModeGuard,
protocol: NetworkProtocol::HttpsConnect,
host: &host,
port: authority.port,
};
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_CONNECT_PORT_NOT_ALLOWED.to_string(),
client: client.clone(),
method: Some("CONNECT".to_string()),
mode: None,
protocol: "http-connect".to_string(),
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(authority.port),
}))
.await;
let client = client.as_deref().unwrap_or_default();
let port = authority.port;
warn!(
"CONNECT blocked; non-HTTPS port not allowed (client={client}, host={host}, port={port}, allowed_port={HTTPS_CONNECT_PORT})"
);
return Err(blocked_text_with_details(
REASON_CONNECT_PORT_NOT_ALLOWED,
&details,
));
}
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::HttpsConnect,
host: host.clone(),
@@ -1020,6 +1068,30 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn http_connect_accept_blocks_non_https_port_in_full_mode() {
let policy = NetworkProxySettings {
allowed_domains: vec!["github.com".to_string()],
..Default::default()
};
let state = Arc::new(network_proxy_state_for_policy(policy));
let mut req = Request::builder()
.method(Method::CONNECT)
.uri("https://github.com:22")
.header("host", "github.com:22")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(state);
let response = http_connect_accept(None, req).await.unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-connect-port-policy"
);
}
#[tokio::test(flavor = "current_thread")]
async fn http_plain_proxy_blocks_unix_socket_when_method_not_allowed() {
let state = Arc::new(network_proxy_state_for_policy(

View File

@@ -1,3 +1,4 @@
pub(crate) const REASON_CONNECT_PORT_NOT_ALLOWED: &str = "connect_port_not_allowed";
pub(crate) const REASON_DENIED: &str = "denied";
pub(crate) const REASON_METHOD_NOT_ALLOWED: &str = "method_not_allowed";
pub(crate) const REASON_MITM_REQUIRED: &str = "mitm_required";

View File

@@ -1,6 +1,7 @@
use crate::network_policy::NetworkDecisionSource;
use crate::network_policy::NetworkPolicyDecision;
use crate::network_policy::NetworkProtocol;
use crate::reasons::REASON_CONNECT_PORT_NOT_ALLOWED;
use crate::reasons::REASON_DENIED;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_REQUIRED;
@@ -49,6 +50,7 @@ pub fn json_response<T: Serialize>(value: &T) -> Response {
pub fn blocked_header_value(reason: &str) -> &'static str {
match reason {
REASON_CONNECT_PORT_NOT_ALLOWED => "blocked-by-connect-port-policy",
REASON_NOT_ALLOWED | REASON_NOT_ALLOWED_LOCAL => "blocked-by-allowlist",
REASON_DENIED => "blocked-by-denylist",
REASON_METHOD_NOT_ALLOWED => "blocked-by-method-policy",
@@ -59,6 +61,9 @@ pub fn blocked_header_value(reason: &str) -> &'static str {
pub fn blocked_message(reason: &str) -> &'static str {
match reason {
REASON_CONNECT_PORT_NOT_ALLOWED => {
"Codex blocked this request: CONNECT is only allowed to HTTPS port 443."
}
REASON_NOT_ALLOWED => {
"Codex blocked this request: domain not in allowlist (this is not a denylist block)."
}