Skip to content

feat(proxy): block CONNECT tunnels to credential upstreams and smart NO_PROXY#518

Merged
lukehinds merged 4 commits into
always-further:mainfrom
RobertWi:upstream/connect-blocking-noproxy
Apr 1, 2026
Merged

feat(proxy): block CONNECT tunnels to credential upstreams and smart NO_PROXY#518
lukehinds merged 4 commits into
always-further:mainfrom
RobertWi:upstream/connect-blocking-noproxy

Conversation

@RobertWi

Copy link
Copy Markdown
Contributor

Addresses discussion in #424

Summary

Two related security improvements for the credential proxy:

CONNECT tunnel blocking

When allowed_paths or 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_PROXY set, all traffic goes through the proxy — including internal services that don't need credential injection. These requests hit the reverse proxy handler, fail with UnknownService, and drop the connection.

Now NO_PROXY is computed dynamically: allowed_hosts minus 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:

  • Port suffixes in allow_domain entries need stripping for NO_PROXY matching
  • allow_domain ports need adding to Landlock ConnectTcp rules
  • If a credential file isn't available at startup (Vault sidecar race), the host incorrectly lands in NO_PROXY permanently — documented as a known startup ordering constraint

Production 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

  • CONNECT to credential upstream returns 403
  • CONNECT to non-credential host passes through
  • credential_upstream_hosts() correctly extracts host:port
  • NO_PROXY includes allowed hosts but excludes credential upstreams
  • allow_domain ports added to Landlock ConnectTcp rules
  • All existing tests pass

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/nono-cli/src/profile/mod.rs Outdated
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
// Child `None` inherits parent credentials. Child `Some([...])` replaces parent.
// Child None inherits parent credentials. Child Some([...]) merges with parent.

Comment on lines +80 to +88
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(",");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This loop for building the NO_PROXY string can be made more robust and efficient.

  1. 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.
  2. 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:

Suggested change
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(",");

Comment thread crates/nono-proxy/src/server.rs Outdated
Comment on lines +384 to +388
let host_port = if authority.contains(':') {
authority.to_lowercase()
} else {
format!("{}:443", authority.to_lowercase())
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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(),
                };

@RobertWi RobertWi force-pushed the upstream/connect-blocking-noproxy branch from 7d3ddfe to 0256177 Compare March 31, 2026 14:58
@RobertWi

Copy link
Copy Markdown
Contributor Author

Sorry for the wait — DCO sign-off added, rebased onto v0.26.1.

Addressed Gemini review feedback:

  • Fixed misleading comment: credentials Some([...]) merges with parent, not replaces
  • IPv6 handling in NO_PROXY port stripping ([::1]:443[::1])
  • IPv6 handling in CONNECT authority normalization

fmt clean, clippy clean, all tests pass.

@RobertWi RobertWi force-pushed the upstream/connect-blocking-noproxy branch from 0256177 to d9bff37 Compare March 31, 2026 17:18
doemijdienaammaar added 4 commits April 1, 2026 11:51
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>
@RobertWi RobertWi force-pushed the upstream/connect-blocking-noproxy branch from d9bff37 to a01d7b6 Compare April 1, 2026 16:55
@lukehinds lukehinds merged commit 18cb6a2 into always-further:main Apr 1, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants