Skip to content

Commit 0526cf8

Browse files
feat(httpclient): add proxy/TLS-aware http client factory (OD-30)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ee9e6af commit 0526cf8

4 files changed

Lines changed: 248 additions & 0 deletions

File tree

utils/httpclient/client.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package httpclient
2+
3+
import "net/http"
4+
5+
// New returns an *http.Client whose transport honors proxy environment
6+
// variables (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) and applies CA/TLS configuration
7+
// from SSL_CERT_FILE and CODACY_CLI_INSECURE.
8+
//
9+
// It returns an error if a configured CA bundle cannot be read or parsed, so
10+
// callers fail loudly on misconfiguration rather than silently falling back to
11+
// the system trust store.
12+
func New(opts ...Option) (*http.Client, error) {
13+
o := &Options{}
14+
for _, fn := range opts {
15+
fn(o)
16+
}
17+
18+
tlsCfg, err := buildTLSConfig()
19+
if err != nil {
20+
return nil, err
21+
}
22+
23+
transport := &http.Transport{
24+
Proxy: http.ProxyFromEnvironment,
25+
TLSClientConfig: tlsCfg,
26+
}
27+
28+
return &http.Client{
29+
Timeout: o.Timeout,
30+
Transport: transport,
31+
}, nil
32+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package httpclient
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// certToPEM encodes the test server's leaf certificate as PEM.
16+
func certToPEM(t *testing.T, srv *httptest.Server) []byte {
17+
t.Helper()
18+
return encodeCertPEM(srv.Certificate().Raw)
19+
}
20+
21+
func TestBuildTLSConfig_DefaultRejectsSelfSigned(t *testing.T) {
22+
os.Unsetenv(EnvInsecure)
23+
os.Unsetenv(EnvCABundle)
24+
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
25+
defer srv.Close()
26+
27+
cfg, err := buildTLSConfig()
28+
require.NoError(t, err)
29+
c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
30+
_, err = c.Get(srv.URL)
31+
assert.Error(t, err, "self-signed server must be rejected without a custom CA")
32+
}
33+
34+
func TestBuildTLSConfig_CustomCASucceeds(t *testing.T) {
35+
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
36+
w.WriteHeader(http.StatusOK)
37+
}))
38+
defer srv.Close()
39+
40+
caPath := filepath.Join(t.TempDir(), "ca.pem")
41+
require.NoError(t, os.WriteFile(caPath, certToPEM(t, srv), 0o600))
42+
t.Setenv(EnvCABundle, caPath)
43+
os.Unsetenv(EnvInsecure)
44+
45+
cfg, err := buildTLSConfig()
46+
require.NoError(t, err)
47+
require.NotNil(t, cfg.RootCAs)
48+
c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
49+
resp, err := c.Get(srv.URL)
50+
require.NoError(t, err)
51+
defer resp.Body.Close()
52+
assert.Equal(t, http.StatusOK, resp.StatusCode)
53+
}
54+
55+
func TestBuildTLSConfig_InsecureSkipsVerify(t *testing.T) {
56+
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
57+
defer srv.Close()
58+
t.Setenv(EnvInsecure, "1")
59+
os.Unsetenv(EnvCABundle)
60+
61+
cfg, err := buildTLSConfig()
62+
require.NoError(t, err)
63+
assert.True(t, cfg.InsecureSkipVerify)
64+
c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
65+
resp, err := c.Get(srv.URL)
66+
require.NoError(t, err)
67+
resp.Body.Close()
68+
}
69+
70+
func TestBuildTLSConfig_MissingBundleErrors(t *testing.T) {
71+
t.Setenv(EnvCABundle, filepath.Join(t.TempDir(), "does-not-exist.pem"))
72+
os.Unsetenv(EnvInsecure)
73+
_, err := buildTLSConfig()
74+
assert.Error(t, err)
75+
}
76+
77+
func TestBuildTLSConfig_BadBundleErrors(t *testing.T) {
78+
bad := filepath.Join(t.TempDir(), "bad.pem")
79+
require.NoError(t, os.WriteFile(bad, []byte("not a certificate"), 0o600))
80+
t.Setenv(EnvCABundle, bad)
81+
os.Unsetenv(EnvInsecure)
82+
_, err := buildTLSConfig()
83+
assert.Error(t, err)
84+
}
85+
86+
func TestNew_SetsProxyAndTimeout(t *testing.T) {
87+
os.Unsetenv(EnvInsecure)
88+
os.Unsetenv(EnvCABundle)
89+
c, err := New(WithTimeout(7 * time.Second))
90+
require.NoError(t, err)
91+
assert.Equal(t, 7*time.Second, c.Timeout)
92+
93+
tr, ok := c.Transport.(*http.Transport)
94+
require.True(t, ok)
95+
// Proxy resolver must be wired. Env resolution itself is covered by the
96+
// real-life harness; ProxyFromEnvironment caches and is unsafe to unit-test.
97+
assert.NotNil(t, tr.Proxy)
98+
assert.NotNil(t, tr.TLSClientConfig)
99+
}
100+
101+
func TestNew_PropagatesCABundleError(t *testing.T) {
102+
t.Setenv(EnvCABundle, filepath.Join(t.TempDir(), "missing.pem"))
103+
os.Unsetenv(EnvInsecure)
104+
_, err := New()
105+
assert.Error(t, err)
106+
}
107+
108+
func TestNew_CustomCAEndToEnd(t *testing.T) {
109+
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110+
w.WriteHeader(http.StatusOK)
111+
}))
112+
defer srv.Close()
113+
caPath := filepath.Join(t.TempDir(), "ca.pem")
114+
require.NoError(t, os.WriteFile(caPath, certToPEM(t, srv), 0o600))
115+
t.Setenv(EnvCABundle, caPath)
116+
os.Unsetenv(EnvInsecure)
117+
118+
c, err := New()
119+
require.NoError(t, err)
120+
resp, err := c.Get(srv.URL)
121+
require.NoError(t, err)
122+
defer resp.Body.Close()
123+
assert.Equal(t, http.StatusOK, resp.StatusCode)
124+
}

utils/httpclient/options.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package httpclient
2+
3+
import (
4+
"os"
5+
"strings"
6+
"time"
7+
)
8+
9+
// Env vars controlling TLS/CA behavior.
10+
const (
11+
// EnvInsecure, when truthy, disables TLS certificate verification.
12+
EnvInsecure = "CODACY_CLI_INSECURE"
13+
// EnvCABundle points to a PEM bundle appended to the system trust pool.
14+
// SSL_CERT_FILE is the OpenSSL-standard name corporate tooling already sets.
15+
EnvCABundle = "SSL_CERT_FILE"
16+
)
17+
18+
// Options configure a client built by New.
19+
type Options struct {
20+
// Timeout is the http.Client timeout. Zero means no timeout.
21+
Timeout time.Duration
22+
}
23+
24+
// Option mutates Options.
25+
type Option func(*Options)
26+
27+
// WithTimeout sets the client timeout. Pass 0 for no timeout (large downloads).
28+
func WithTimeout(d time.Duration) Option {
29+
return func(o *Options) { o.Timeout = d }
30+
}
31+
32+
// insecureEnv reports whether TLS verification is disabled via EnvInsecure.
33+
func insecureEnv() bool {
34+
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvInsecure))) {
35+
case "1", "true", "yes":
36+
return true
37+
default:
38+
return false
39+
}
40+
}
41+
42+
// caBundlePath returns the configured CA bundle path, or "" if unset.
43+
func caBundlePath() string {
44+
return strings.TrimSpace(os.Getenv(EnvCABundle))
45+
}

utils/httpclient/tls.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package httpclient
2+
3+
import (
4+
"crypto/tls"
5+
"crypto/x509"
6+
"encoding/pem"
7+
"fmt"
8+
"os"
9+
)
10+
11+
// buildTLSConfig assembles the TLS config from env:
12+
// - CODACY_CLI_INSECURE truthy -> InsecureSkipVerify (with a stderr warning)
13+
// - SSL_CERT_FILE set -> its PEM certs appended to the system pool
14+
func buildTLSConfig() (*tls.Config, error) {
15+
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
16+
17+
if insecureEnv() {
18+
cfg.InsecureSkipVerify = true
19+
fmt.Fprintln(os.Stderr,
20+
"WARNING: TLS certificate verification is DISABLED (CODACY_CLI_INSECURE set). "+
21+
"Traffic can be intercepted. Prefer setting SSL_CERT_FILE to your proxy's CA instead.")
22+
return cfg, nil
23+
}
24+
25+
pool, err := x509.SystemCertPool()
26+
if err != nil || pool == nil {
27+
pool = x509.NewCertPool()
28+
}
29+
30+
if path := caBundlePath(); path != "" {
31+
pemBytes, err := os.ReadFile(path)
32+
if err != nil {
33+
return nil, fmt.Errorf("failed to read CA bundle from %s (%s): %w", EnvCABundle, path, err)
34+
}
35+
if !pool.AppendCertsFromPEM(pemBytes) {
36+
return nil, fmt.Errorf("no valid certificates found in CA bundle %s (%s)", EnvCABundle, path)
37+
}
38+
}
39+
40+
cfg.RootCAs = pool
41+
return cfg, nil
42+
}
43+
44+
// encodeCertPEM encodes DER certificate bytes as PEM. Used by tests.
45+
func encodeCertPEM(der []byte) []byte {
46+
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
47+
}

0 commit comments

Comments
 (0)