feat(proxy): block CONNECT tunnels to credential upstreams and smart NO_PROXY#518
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements "Smart NO_PROXY" logic and refines credential management within the sandbox. Key changes include allowing non-HTTP protocols to bypass the proxy via direct Landlock TCP rules while ensuring credential-dependent traffic is routed through the proxy for L7 filtering and token injection. The profile configuration for credentials was also updated to support inheritance and explicit overrides during merging. Review feedback focuses on correcting a comment regarding credential merging logic, improving the robustness and performance of port stripping for NO_PROXY strings, and using a more reliable method for parsing network authorities to correctly handle IPv6 addresses.
| listen_port: dedup_append(&base.network.listen_port, &child.network.listen_port), | ||
| credentials: dedup_append(&base.network.credentials, &child.network.credentials), | ||
| // Child `Some([])` overrides parent credentials to empty (disables proxy). | ||
| // Child `None` inherits parent credentials. Child `Some([...])` replaces parent. |
There was a problem hiding this comment.
The comment here states that Some([...]) replaces parent credentials, but the implementation actually merges them with the parent's credentials. The test test_merge_profiles_credentials_some_merges_with_base also confirms the merge behavior. To avoid confusion, please update the comment to reflect the implementation.
| // Child `None` inherits parent credentials. Child `Some([...])` replaces parent. | |
| // Child None inherits parent credentials. Child Some([...]) merges with parent. |
| let mut no_proxy_parts = vec!["localhost".to_string(), "127.0.0.1".to_string()]; | ||
| for host in &self.no_proxy_hosts { | ||
| // Strip port for NO_PROXY (most HTTP clients match on hostname) | ||
| let hostname = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host); | ||
| if !no_proxy_parts.contains(&hostname.to_string()) { | ||
| no_proxy_parts.push(hostname.to_string()); | ||
| } | ||
| } | ||
| let no_proxy = no_proxy_parts.join(","); |
There was a problem hiding this comment.
This loop for building the NO_PROXY string can be made more robust and efficient.
- Port Stripping: The current logic host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host) is a bit too simplistic and could fail on inputs like IPv6 addresses. A safer approach, consistent with other parts of the codebase (e.g., network_policy.rs), is to verify that the part after the colon is a valid port before stripping it.
- Performance: The no_proxy_parts.contains() check inside the loop results in O(n^2) complexity. Using a HashSet to collect unique hostnames would be more efficient, especially if no_proxy_hosts could become large.
Here's a suggested refactoring that addresses both points:
| let mut no_proxy_parts = vec!["localhost".to_string(), "127.0.0.1".to_string()]; | |
| for host in &self.no_proxy_hosts { | |
| // Strip port for NO_PROXY (most HTTP clients match on hostname) | |
| let hostname = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host); | |
| if !no_proxy_parts.contains(&hostname.to_string()) { | |
| no_proxy_parts.push(hostname.to_string()); | |
| } | |
| } | |
| let no_proxy = no_proxy_parts.join(","); | |
| let mut no_proxy_parts: std::collections::HashSet<String> = | |
| ["localhost".to_string(), "127.0.0.1".to_string()].into(); | |
| for host in &self.no_proxy_hosts { | |
| // Strip port for NO_PROXY (most HTTP clients match on hostname) | |
| let hostname = host | |
| .rsplit_once(':') | |
| .and_then(|(h, p)| p.parse::<u16>().ok().map(|_| h)) | |
| .unwrap_or(host); | |
| no_proxy_parts.insert(hostname.to_string()); | |
| } | |
| let no_proxy = no_proxy_parts.into_iter().collect::<Vec<_>>().join(","); |
| let host_port = if authority.contains(':') { | ||
| authority.to_lowercase() | ||
| } else { | ||
| format!("{}:443", authority.to_lowercase()) | ||
| }; |
There was a problem hiding this comment.
The current logic for normalizing the authority to a host:port string is a bit simplistic and might not correctly handle all cases, especially with IPv6 addresses. For example, an IPv6 literal like [::1] contains colons but no port, and the current logic would not append the default port 443.
A more robust approach would be to use url::Url::parse to correctly handle various authority formats. This would ensure consistent and correct parsing of host and port.
let host_port = match url::Url::parse(&format!("http://{}", authority)) {
Ok(parsed) => {
let host = parsed.host_str().unwrap_or(authority);
let port = parsed.port().unwrap_or(443);
format!("{}:{}", host, port).to_lowercase()
}
Err(_) => authority.to_lowercase(),
};7d3ddfe to
0256177
Compare
|
Sorry for the wait — DCO sign-off added, rebased onto v0.26.1. Addressed Gemini review feedback:
fmt clean, clippy clean, all tests pass. |
0256177 to
d9bff37
Compare
Change credentials field from Vec<String> to Option<Vec<String>>: - None (absent): inherit parent credentials during merge - Some([]): explicitly override parent to disable credentials - Some([...]): merge with parent as before This fixes a design gap where a child profile extending a parent with credentials (e.g. opencode with google-ai/github) could not opt out. An empty credentials array was indistinguishable from 'not specified', causing proxy mode to activate unexpectedly. Add resolved_credentials() helper for downstream consumers. Add 5 tests covering inheritance, override, merge, proxy activation, and deserialization semantics. Signed-off-by: RobertWi <1311049+RobertWi@users.noreply.github.com>
When proxy mode is active, Landlock restricts outbound TCP to only the proxy port. This prevents non-HTTP services (NATS on 4222, PostgreSQL on 5432, etc.) from connecting even when they appear in allow_domain. Parse port numbers from allow_domain entries and add them to the ConnectTcp allowlist via add_tcp_connect_port(). Bare hostnames default to port 443. Applied in both the profile path (from_profile) and CLI path (apply_cli_network_mode). Signed-off-by: RobertWi <1311049+RobertWi@users.noreply.github.com>
… filter allow_domain entries like 'gitlab.example.com:443' include a port for Landlock TCP connect rules but the proxy HostFilter matches bare hostnames. Strip the :port suffix when building the proxy allowlist so upstream hosts are reachable through the proxy. Signed-off-by: RobertWi <1311049+RobertWi@users.noreply.github.com>
CONNECT tunnels to credential upstream hosts are now rejected with 403. This forces traffic through the reverse proxy path where L7 path filtering and credential injection are enforced. Without this, CONNECT establishes a raw TLS pipe that bypasses both. Also computes NO_PROXY dynamically: allowed_hosts minus credential upstream hosts. Non-credential hosts bypass the proxy entirely (direct connection, still Landlock-enforced), while credential upstreams are forced through the proxy for L7 filtering and credential injection. Adds is_credential_upstream() and credential_upstream_hosts() to CredentialStore, with extract_host_port() URL normalisation helper. Signed-off-by: RobertWi <1311049+RobertWi@users.noreply.github.com>
d9bff37 to
a01d7b6
Compare
Addresses discussion in #424
Summary
Two related security improvements for the credential proxy:
CONNECT tunnel blocking
When
allowed_pathsor endpoint rules restrict what an agent can do on a credential upstream, the agent can bypass those restrictions by opening a CONNECT tunnel — the proxy just relays encrypted bytes without inspecting the HTTP method or path. This blocks CONNECT to any host that has credential injection configured, forcing traffic through the reverse proxy where L7 filtering applies.Smart NO_PROXY
With
HTTP_PROXYset, all traffic goes through the proxy — including internal services that don't need credential injection. These requests hit the reverse proxy handler, fail withUnknownService, and drop the connection.Now
NO_PROXYis computed dynamically:allowed_hostsminus credential upstreams. Credential traffic goes through the proxy for injection, everything else goes direct. Landlock still enforces the network boundary.Edge cases discovered in production:
allow_domainentries need stripping forNO_PROXYmatchingallow_domainports need adding to LandlockConnectTcprulesNO_PROXYpermanently — documented as a known startup ordering constraintProduction context
Running for a week in a Kubernetes cluster with 9 agents. CONNECT bypass and NO_PROXY misconfiguration were both discovered and fixed during production testing.
Test plan
credential_upstream_hosts()correctly extracts host:portNO_PROXYincludes allowed hosts but excludes credential upstreamsallow_domainports added to Landlock ConnectTcp rules