Skip to content

feat(httpproxy): add MITM HTTP proxy for credential injection#3

Merged
dedene merged 8 commits intomainfrom
feat/http-proxy
Feb 8, 2026
Merged

feat(httpproxy): add MITM HTTP proxy for credential injection#3
dedene merged 8 commits intomainfrom
feat/http-proxy

Conversation

@dedene
Copy link
Owner

@dedene dedene commented Feb 8, 2026

Summary

Add an optional HTTP/HTTPS proxy that injects credentials into requests based on route matching. This allows tools to make authenticated API calls through the proxy without explicit credential configuration.

Key features:

  • MITM HTTPS interception with auto-generated CA certificate
  • Route-based credential injection using {{name}} references to named credentials
  • Host patterns (exact match and *.suffix wildcards)
  • Allow/deny rules for method/path filtering
  • Proxy authentication with auto-generated token
  • Tool integration via use_proxy: true config option
  • Optional require_auth: false for localhost-only setups

Security hardening:

  • Localhost-only binding enforced
  • SSRF protection (private/reserved IP ranges blocked)
  • Request smuggling detection
  • Host header mismatch protection
  • CA key stored with 0600 permissions
  • Proxy auth token with strict file permissions

Test plan

  • Unit tests for proxy, CA, injection, security
  • go test ./... passes
  • Manual test with use_proxy: true tool
  • Verify CA generated at /etc/openclaw/ca/
  • Verify credential injection works for HTTPS routes

Add an optional HTTP/HTTPS proxy that injects credentials into requests
based on route matching. This allows tools to make authenticated API
calls through the proxy without explicit credential configuration.

Features:
- MITM HTTPS interception with auto-generated CA
- Route-based credential injection with {{name}} references
- Host patterns (exact and *.suffix wildcards)
- Allow/deny rules for method/path filtering
- Proxy authentication with auto-generated token
- Tool integration via use_proxy config option
- require_auth option for localhost-only setups

Security:
- Localhost-only binding enforced
- SSRF protection (private IP ranges blocked)
- Request smuggling detection
- Host header mismatch protection
- CA key stored with 0600 permissions
- Proxy auth token with strict file permissions

Config: http_proxy section in wrappers.yaml
Docs: See docs/CONFIG.md HTTP Proxy Settings section
Tests were failing on CI because they tried to create /etc/openclaw/ca
directory without root permissions. All HTTP proxy reload tests now use
temporary directories for CA storage.
- Update tagline to mention HTTP APIs and MITM proxy
- Add "Two Modes" comparison table (CLI Wrapper vs HTTP Proxy)
- Add "HTTP Proxy Mode" section with example YAML config
- Add HTTP Proxy Setup link to documentation section
Move SSRF protection from request-time DNS check to connection-time
IP validation using net.Dialer.Control. This eliminates the TOCTOU
window that allowed DNS rebinding attacks.

- Add safeDialer() with Control callback that validates IPs after
  DNS resolution but before connection
- Configure goproxy Tr and ConnectDial to use safe dialer
- Remove redundant validateHostForSSRF() from handleRequest
- Add comprehensive tests for dial-time SSRF blocking
govulncheck failing due to GO-2026-4337 in crypto/tls@go1.24.12.
Fixed in go1.24.13 (released Feb 4, 2026).
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an optional localhost-only MITM HTTP/HTTPS proxy to the claw-wrap daemon to inject credentials into outbound API requests based on host/path routing, and wires tool execution to optionally use that proxy via environment variables.

Changes:

  • Introduces internal/httpproxy (MITM proxy, CA management, injection templating, and SSRF/request-smuggling hardening) with substantial unit tests.
  • Extends config/protocol/docs to support http_proxy configuration and tool opt-in via use_proxy: true.
  • Updates the daemon and CLI check output to start/manage the proxy and display connection/CA/token details.

Reviewed changes

Copilot reviewed 25 out of 27 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
internal/protocol/protocol.go Exposes HTTP proxy status/paths in admin check response.
internal/paths/paths.go Adds proxy auth token path and platform-specific CA directory.
internal/paths/paths_test.go Adds CADir platform test coverage.
internal/httpproxy/proxy.go Implements proxy server, auth, route matching, injection, header stripping.
internal/httpproxy/inject.go Implements {{cred-name}} template resolution for injected headers.
internal/httpproxy/security.go Adds request-smuggling checks, log sanitization, SSRF host validation helpers.
internal/httpproxy/ssrf.go Adds dial-time SSRF protection via net.Dialer.Control.
internal/httpproxy/*_test.go Adds unit tests for CA, injection, SSRF, security, and proxy behavior.
internal/daemon/executor.go Injects proxy env vars/CA trust into tool environment when use_proxy is enabled.
internal/daemon/executor_env_test.go Adds tests for authenticated proxy URL/env injection.
internal/daemon/daemon.go Starts/stops/reloads proxy and manages proxy auth token file.
internal/daemon/daemon_test.go Adds reload + proxy auth token persistence/permission tests.
internal/config/config.go Adds http_proxy config schema + validation/compilation logic.
internal/config/config_test.go Adds tests for HTTP proxy config validation/compilation.
cmd/claw-wrap/main.go Enhances check output to include proxy details and usage hints.
docs/CONFIG.md Documents HTTP proxy config, defaults, and security model.
README.md Introduces “HTTP Proxy mode” alongside existing CLI wrapper mode.
go.mod / go.sum Adds github.com/elazarl/goproxy and related indirect deps.
.gitignore Ignores the /claw-wrap binary artifact.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 198 to 206
// Stop gracefully shuts down the proxy.
func (p *Proxy) Stop() error {
close(p.shutdownCh)

if p.listener != nil {
if err := p.listener.Close(); err != nil {
return fmt.Errorf("close listener: %w", err)
}
}
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

Proxy.Stop() unconditionally closes shutdownCh, so calling Stop twice will panic. Making Stop idempotent (e.g., via sync.Once or a non-blocking closed-channel guard) would make shutdown paths and tests safer.

Copilot uses AI. Check for mistakes.

// Inject credentials
if route.Inject.Header != "" && route.Inject.Value != "" {
value, err := resolveHeaderValue(route.Inject.Value, p.creds, p.credOpts)
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

handleRequest reads p.creds without synchronization, while ReloadConfig updates p.creds under cfgMu. Even though ReloadConfig replaces the map rather than mutating it, the unsynchronized read/write of the p.creds field is still a data race. Consider guarding creds access with the same RWMutex (or using an immutable snapshot/atomic.Value) so config reloads can’t race with request handling.

Suggested change
value, err := resolveHeaderValue(route.Inject.Value, p.creds, p.credOpts)
p.cfgMu.RLock()
creds := p.creds
credOpts := p.credOpts
p.cfgMu.RUnlock()
value, err := resolveHeaderValue(route.Inject.Value, creds, credOpts)

Copilot uses AI. Check for mistakes.
Comment on lines 355 to 370
if pattern == "" {
return nil, fmt.Errorf("empty pattern")
}

// Escape special regex chars except *
escaped := regexp.QuoteMeta(pattern)

// Handle wildcard prefix *.
if strings.HasPrefix(pattern, "*.") {
// *.example.com -> matches sub.example.com but NOT example.com
// Suffix-anchored: must end with .example.com
suffix := escaped[4:] // Remove escaped \*\.
return regexp.Compile(`^[^.]+\.` + suffix + `$`)
}

// Exact match
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

compileHostPattern produces a case-sensitive regex from the config pattern, but request hosts are normalized to lowercase before matching. A pattern containing uppercase letters will never match. Also, patterns like "*." (empty suffix) compile but can never match and should likely be rejected as invalid. Consider normalizing patterns (lowercase/trim trailing dot) and validating wildcard suffix is non-empty.

Suggested change
if pattern == "" {
return nil, fmt.Errorf("empty pattern")
}
// Escape special regex chars except *
escaped := regexp.QuoteMeta(pattern)
// Handle wildcard prefix *.
if strings.HasPrefix(pattern, "*.") {
// *.example.com -> matches sub.example.com but NOT example.com
// Suffix-anchored: must end with .example.com
suffix := escaped[4:] // Remove escaped \*\.
return regexp.Compile(`^[^.]+\.` + suffix + `$`)
}
// Exact match
// Normalize and validate input pattern.
raw := pattern
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return nil, fmt.Errorf("empty pattern")
}
// Reject wildcard patterns with an empty suffix (e.g. "*.")
if pattern == "*." {
return nil, fmt.Errorf("invalid wildcard host pattern %q: empty suffix", raw)
}
// Hostnames are case-insensitive; normalize to lowercase and trim trailing dot.
pattern = strings.ToLower(pattern)
pattern = strings.TrimSuffix(pattern, ".")
if pattern == "" {
return nil, fmt.Errorf("invalid host pattern %q", raw)
}
// Handle wildcard prefix *.
if strings.HasPrefix(pattern, "*.") {
// *.example.com -> matches sub.example.com but NOT example.com
// Suffix-anchored: must end with .example.com
if len(pattern) <= 2 {
return nil, fmt.Errorf("invalid wildcard host pattern %q: empty suffix", raw)
}
suffix := regexp.QuoteMeta(pattern[2:]) // escape suffix after "*."
return regexp.Compile(`^[^.]+\.` + suffix + `$`)
}
// Exact match
escaped := regexp.QuoteMeta(pattern)

Copilot uses AI. Check for mistakes.
Comment on lines 80 to 82
// macOS should use ~/.claw-wrap/ca
home, _ := os.UserHomeDir()
want := filepath.Join(home, ".claw-wrap", "ca")
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

In the darwin branch, the test ignores the error from os.UserHomeDir(). If UserHomeDir fails (e.g., HOME not set in CI), CADir() falls back to "/tmp/claw-wrap/ca", but this test will still expect an empty-home join path and fail. Consider handling the error and asserting the same fallback behavior as CADir().

Suggested change
// macOS should use ~/.claw-wrap/ca
home, _ := os.UserHomeDir()
want := filepath.Join(home, ".claw-wrap", "ca")
// macOS should use ~/.claw-wrap/ca; if home lookup fails, CADir falls back to /tmp/claw-wrap/ca
home, err := os.UserHomeDir()
var want string
if err != nil {
want = "/tmp/claw-wrap/ca"
} else {
want = filepath.Join(home, ".claw-wrap", "ca")
}

Copilot uses AI. Check for mistakes.
case "", "none", "errors", "info", "debug":
default:
return fmt.Errorf("invalid log_level %q (must be none, errors, info, or debug)", cfg.LogLevel)
}
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

validateHTTPProxy accepts an empty log_level as valid, but the rest of the proxy code treats log_level=="errors" specially (and docs state errors is the default). With the current implementation, omitted log_level (empty string) will suppress error-level proxy logs. Consider normalizing "" to "errors" during validation (or via a getter) so the documented default matches runtime behavior.

Suggested change
}
}
// Normalize empty log level to the documented default.
if cfg.LogLevel == "" {
c.HTTPProxy.LogLevel = "errors"
}

Copilot uses AI. Check for mistakes.
Comment on lines 141 to 142
// Validate proxy auth token for tools that require proxy
if tool.UseProxy && cfg.GetHTTPProxyEnabled() && proxyAuthToken == "" {
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

NewToolExecutor requires a proxy auth token whenever use_proxy is enabled, but this ignores http_proxy.require_auth=false. In that mode the proxy intentionally runs without auth, so tool execution should not fail due to a missing token and buildEnvironment should be able to set proxy env vars without userinfo.

Suggested change
// Validate proxy auth token for tools that require proxy
if tool.UseProxy && cfg.GetHTTPProxyEnabled() && proxyAuthToken == "" {
// Validate proxy auth token for tools that require proxy authentication
if tool.UseProxy && cfg.GetHTTPProxyEnabled() && cfg.GetHTTPProxyRequireAuth() && proxyAuthToken == "" {

Copilot uses AI. Check for mistakes.
Comment on lines 304 to 311
// Inject CA cert paths for various clients
if caPath := e.cfg.GetHTTPProxyCAPath(); caPath != "" {
certFile := filepath.Join(caPath, "ca.crt")
envMap["SSL_CERT_FILE"] = certFile
envMap["NODE_EXTRA_CA_CERTS"] = certFile
envMap["REQUESTS_CA_BUNDLE"] = certFile
envMap["CURL_CA_BUNDLE"] = certFile
}
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

CA trust env vars are only injected when http_proxy.ca.path is explicitly set. When ca.path is omitted, the proxy uses a platform default CA directory, but tools won’t get SSL_CERT_FILE/NODE_EXTRA_CA_CERTS/etc and HTTPS MITM requests will likely fail. Consider falling back to the same default CA dir used by the proxy when ca.path is empty.

Copilot uses AI. Check for mistakes.
Comment on lines 263 to 275
switch {
case d.httpProxy != nil && !newCfg.GetHTTPProxyEnabled():
if err := d.httpProxy.Stop(); err != nil {
return fmt.Errorf("stop HTTP proxy: %w", err)
}
d.httpProxy = nil
case d.httpProxy == nil && newCfg.GetHTTPProxyEnabled():
if err := d.startHTTPProxy(newCfg); err != nil {
return fmt.Errorf("start HTTP proxy: %w", err)
}
case d.httpProxy != nil && newCfg.GetHTTPProxyEnabled():
d.httpProxy.ReloadConfig(newCfg.GetHTTPProxyConfig(), newCfg.Credentials)
}
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

On SIGHUP reload, the enabled→enabled path only calls httpProxy.ReloadConfig (routes/creds/loglevel), but does not handle changes to listen address, require_auth, or CA settings. That can leave the proxy listening on the old address and enforcing stale auth/CA behavior until a full disable/enable or daemon restart. Consider detecting these config diffs and restarting the proxy, or extending ReloadConfig to update them safely.

Copilot uses AI. Check for mistakes.
- Make Proxy.Stop() idempotent using sync.Once to prevent double-close panic
- Fix data race on p.creds by snapshotting under RLock in handleRequest
- Normalize host patterns to lowercase and reject invalid "*.~" wildcard
- Normalize empty log_level to "errors" (documented default)
- Respect require_auth=false when validating proxy auth token
- Fallback to paths.CADir() when CA path not configured
- Restart proxy on SIGHUP when listen/auth/CA settings change
- Fix paths_test.go to handle UserHomeDir error in CI

Adds tests for double-stop, case-insensitive patterns, and invalid wildcards.
@dedene dedene merged commit f03a1ed into main Feb 8, 2026
2 checks passed
@dedene dedene deleted the feat/http-proxy branch February 8, 2026 19:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant