fix(git)!: use internal proxy for all git operations (#39426)

Introduces gitproxy module which spawns a small forward proxy as scanner
for git calls
Replaces hostmatcher with matchlist which supports port rules
Deprecates ALLOWED_DOMAINS/BLOCKED_DOMAINS and ALLOW_LOCALNETWORKS
settings in migration in favor of full names we have in security
configs.
Removes `external` preset in favor of lax/strict modes, strict mode
requiring explicit ports if they aren't standard http/s ones.

Breaking changes:
- `external` preset no longer works as deny rule. To enforce that, use
`strict` mode and allow ranges to connect to
- Wildcards are no longer accepted in IP addresses
- `*` is no longer allowed as entry in lists
- domain rules now use curl like syntax `*.example.com` matching
subdomains but not `example.com`, `example.com` matching itself and all
subdomains. `example.*` is not a valid rule
- In the default `lax` mode, `[security] ALLOWED_HOST_LIST` no longer
restricts public hosts, set `EGRESS_MODE = strict` to keep an exclusive
list. A startup warning flags this
- Invalid list entries are logged at startup, invalid
`BLOCKED_HOST_LIST`/`BLOCKED_DOMAINS` entries stop it

Docs: https://gitea.com/gitea/docs/pulls/557
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
TheFox0x7
2026-09-29 14:43:36 +02:00
committed by GitHub
parent ebcb0150d1
commit 1b1274486c
57 changed files with 2356 additions and 1106 deletions
+397
View File
@@ -0,0 +1,397 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitproxy
import (
"bufio"
"cmp"
"context"
"crypto/rand"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"os"
"strings"
"time"
"gitea.dev/modules/egress"
"gitea.dev/modules/egress/policy"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"github.com/Azure/go-ntlmssp"
"golang.org/x/net/proxy"
)
const (
proxyURLEnv = "GITEA_GIT_PROXY" // tells this binary it runs as git's GIT_PROXY_COMMAND
directHeader = "X-Gitea-Direct" // asks for a CONNECT tunnel that skips the operator's proxy, as git:// remotes never used one
)
// proxyDialer reaches the operator's proxies, which are configuration rather than user input
var proxyDialer = &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
// copyBuffers beats the reverse proxy's 32 KiB default as it flushes every write, the transport's ReadBufferSize lets a read span chunks
type copyBuffers struct{}
func (copyBuffers) Get() []byte { return make([]byte, 256*1024) }
func (copyBuffers) Put([]byte) {}
// server is a forward proxy for git's remotes that enforces an egress policy on its direct dials.
type server struct {
auth string // the Proxy-Authorization header git must send
policy *policy.Policy
dial func(ctx context.Context, network, addr string) (net.Conn, error)
reverseProxy *httputil.ReverseProxy
proxyTLS *tls.Config
proxyNTLM bool // CONNECT only, the transport can't pin the connection NTLM authenticates
}
func newServer(p *policy.Policy, auth string, proxyTLS *tls.Config) *server {
s := &server{auth: auth, policy: p, dial: p.NewDialContext(), proxyTLS: cmp.Or(proxyTLS, &tls.Config{})}
transport := p.NewHTTPTransport()
transport.Proxy = s.upstreamProxy
transport.TLSClientConfig = s.proxyTLS.Clone() // the transport adds its ALPN protocols to the config it gets
transport.ReadBufferSize = 64 * 1024
s.reverseProxy = &httputil.ReverseProxy{
Rewrite: func(*httputil.ProxyRequest) {},
Transport: transport,
FlushInterval: -1,
BufferPool: copyBuffers{},
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { writeUpstreamError(w, err) },
}
return s
}
// Run routes git's network remotes through a proxy on a random loopback port until ctx is done.
func Run(ctx context.Context) error {
gitPolicy, err := egress.NewGitPolicy()
if err != nil {
return fmt.Errorf("git proxy: %w", err)
}
gitOption := func(key, env string) string { return cmp.Or(os.Getenv(env), setting.GitConfig.GetOption(key)) }
proxyTLS, err := proxyTLSConfig(gitOption("http.proxySSLCAInfo", "GIT_PROXY_SSL_CAINFO"),
gitOption("http.proxySSLCert", "GIT_PROXY_SSL_CERT"), gitOption("http.proxySSLKey", "GIT_PROXY_SSL_KEY"))
if err != nil {
return fmt.Errorf("git proxy: %w", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("git proxy: %w", err)
}
user := url.UserPassword("gitea", rand.Text())
handler := newServer(gitPolicy, basicAuth(user), proxyTLS)
handler.proxyNTLM = strings.EqualFold(gitOption("http.proxyAuthMethod", "GIT_HTTP_PROXY_AUTHMETHOD"), "ntlm")
srv := &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second}
context.AfterFunc(ctx, func() { _ = srv.Close() })
go func() {
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
log.Error("git proxy: %v", err)
}
}()
gitcmd.SetExtraEnvs(gitEnvs((&url.URL{Scheme: "http", User: user, Host: ln.Addr().String()}).String()))
return nil
}
// gitEnvs route git's http(s) remotes through proxyURL and its git:// remotes through MaybeTunnel, command scope config beats every config file and keeps the credentials out of process listings
func gitEnvs(proxyURL string) []string {
envs := []string{
"GIT_CONFIG_PARAMETERS=" + strings.TrimSpace(os.Getenv("GIT_CONFIG_PARAMETERS")+" 'http.proxy="+proxyURL+"'"),
"GIT_HTTP_PROXY_AUTHMETHOD=basic",
"no_proxy=", "NO_PROXY=", // git honors no_proxy even for a configured proxy
}
if setting.GitConfig.GetOption("core.gitProxy") == "" { // the operator's own git:// proxy command stays in charge
envs = append(envs, "GIT_PROXY_COMMAND="+setting.AppPath, proxyURLEnv+"="+proxyURL)
}
return envs
}
// MaybeTunnel serves as git's GIT_PROXY_COMMAND for git:// remotes when git runs this binary with host and port, it returns otherwise
func MaybeTunnel() {
proxyURL := os.Getenv(proxyURLEnv)
if proxyURL == "" || len(os.Args) != 3 {
return
}
if err := tunnel(proxyURL, net.JoinHostPort(os.Args[1], os.Args[2])); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Exit(0)
}
func tunnel(proxyURL, target string) error {
gitProxy, err := url.Parse(proxyURL)
if err != nil {
return err
}
conn, err := net.Dial("tcp", gitProxy.Host)
if err != nil {
return err
}
if conn, err = openTunnel(context.Background(), conn, gitProxy, target, http.Header{directHeader: {"1"}}, false); err != nil {
return err
}
go func() {
_, _ = io.Copy(conn, os.Stdin)
closeWrite(conn)
}()
_, err = io.Copy(os.Stdout, conn)
return err
}
// proxyTLSConfig loads the files of git's http.proxySSL* options, like curl the CA file replaces the system roots
func proxyTLSConfig(caFile, certFile, keyFile string) (*tls.Config, error) {
cfg := &tls.Config{}
if caFile != "" {
pemData, err := os.ReadFile(caFile)
if err != nil {
return nil, err
}
cfg.RootCAs = x509.NewCertPool()
if !cfg.RootCAs.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("no certificates in %s", caFile)
}
}
if certFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, cmp.Or(keyFile, certFile))
if err != nil {
return nil, err
}
cfg.Certificates = []tls.Certificate{cert}
}
return cfg, nil
}
func basicAuth(user *url.Userinfo) string {
password, _ := user.Password()
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user.Username()+":"+password))
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Proxy-Authorization")), []byte(s.auth)) != 1 {
w.Header().Set("Proxy-Authenticate", `Basic realm="gitea egress proxy"`)
http.Error(w, "egress: proxy authentication required", http.StatusProxyAuthRequired)
return
}
switch {
case r.Method == http.MethodConnect:
s.handleConnect(w, r)
case r.URL.Scheme == "http" && r.URL.Host != "":
s.reverseProxy.ServeHTTP(w, r)
default:
http.Error(w, "egress: CONNECT or an absolute http URI required", http.StatusBadRequest)
}
}
func (s *server) handleConnect(w http.ResponseWriter, r *http.Request) {
if host, _, err := net.SplitHostPort(r.URL.Host); err != nil || host == "" {
http.Error(w, "egress: invalid CONNECT target", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
dial := s.dialUpstream
if r.Header.Get(directHeader) != "" {
dial = func(ctx context.Context, target string) (net.Conn, error) { return s.dial(ctx, "tcp", target) }
}
upstream, err := dial(ctx, r.URL.Host)
if err != nil {
writeUpstreamError(w, err)
return
}
client, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
_ = upstream.Close()
http.Error(w, "egress: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
_ = client.Close()
_ = upstream.Close()
return
}
relay(withBuffered(client, buffered.Reader), upstream)
}
// upstreamProxy selects the operator's proxy for req, local targets are dialed directly as they would name the proxy's own host
func (s *server) upstreamProxy(req *http.Request) (proxyURL *url.URL, err error) {
if !isLocalHost(req.URL.Hostname()) {
proxyURL, err = s.policy.Proxy(req)
}
return proxyURL, err
}
func isLocalHost(host string) bool {
host = strings.TrimSuffix(strings.ToLower(host), ".")
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
ip, err := netip.ParseAddr(host)
return err == nil && (ip.Unmap().IsLoopback() || ip.IsUnspecified())
}
func (s *server) dialUpstream(ctx context.Context, target string) (net.Conn, error) {
proxyURL, err := s.upstreamProxy(&http.Request{URL: &url.URL{Scheme: "https", Host: strings.TrimSuffix(target, ":443")}})
switch {
case err != nil:
return nil, err
case proxyURL == nil:
return s.dial(ctx, "tcp", target)
case proxyURL.Scheme == "socks5" || proxyURL.Scheme == "socks5h":
dialer, err := proxy.FromURL(proxyURL, proxyDialer)
if err != nil {
return nil, err
}
ctxDialer, ok := dialer.(proxy.ContextDialer)
if !ok {
return nil, errors.New("egress: socks dialer lacks context support")
}
return ctxDialer.DialContext(ctx, "tcp", target)
default:
return s.connectVia(ctx, proxyURL, target)
}
}
func (s *server) connectVia(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
conn, err := proxyDialer.DialContext(ctx, "tcp", policy.ProxyDialAddr(proxyURL))
if err != nil {
return nil, err
}
if proxyURL.Scheme == "https" {
cfg := s.proxyTLS.Clone()
cfg.ServerName = proxyURL.Hostname()
conn = tls.Client(conn, cfg)
}
return openTunnel(ctx, conn, proxyURL, target, http.Header{}, s.proxyNTLM)
}
// openTunnel opens a CONNECT tunnel to target over conn to the proxy at proxyURL, closing conn on failure
func openTunnel(ctx context.Context, conn net.Conn, proxyURL *url.URL, target string, header http.Header, ntlm bool) (_ net.Conn, err error) {
defer func() {
if err != nil {
_ = conn.Close()
}
}()
defer context.AfterFunc(ctx, func() { _ = conn.Close() })()
switch {
case ntlm:
negotiate, _ := ntlmssp.NewNegotiateMessage("", "")
header.Set("Proxy-Authorization", "NTLM "+base64.StdEncoding.EncodeToString(negotiate))
case proxyURL.User != nil:
header.Set("Proxy-Authorization", basicAuth(proxyURL.User))
}
req := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: target}, Host: target, Header: header}
reader := bufio.NewReader(conn)
resp, err := roundTrip(conn, reader, req)
if err != nil {
return nil, err
}
if ntlm && resp.StatusCode == http.StatusProxyAuthRequired {
_ = resp.Body.Close() // drains it for the next request on the connection
authenticate, err := ntlmAuthenticate(resp.Header, proxyURL.User)
if err != nil {
return nil, err
}
header.Set("Proxy-Authorization", authenticate)
if resp, err = roundTrip(conn, reader, req); err != nil {
return nil, err
}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("egress: proxy refused CONNECT: %s", strings.TrimSpace(resp.Status+" "+string(body)))
}
return withBuffered(conn, reader), nil
}
func roundTrip(conn net.Conn, reader *bufio.Reader, req *http.Request) (*http.Response, error) {
if err := req.Write(conn); err != nil {
return nil, err
}
return http.ReadResponse(reader, req)
}
func ntlmAuthenticate(header http.Header, user *url.Userinfo) (string, error) {
for _, value := range header.Values("Proxy-Authenticate") {
if encoded, ok := strings.CutPrefix(value, "NTLM "); ok {
challenge, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
password, _ := user.Password()
authenticate, err := ntlmssp.NewAuthenticateMessage(challenge, user.Username(), password, nil)
if err != nil {
return "", err
}
return "NTLM " + base64.StdEncoding.EncodeToString(authenticate), nil
}
}
return "", errors.New("egress: proxy sent no NTLM challenge")
}
func writeUpstreamError(w http.ResponseWriter, err error) {
var netErr net.Error
switch {
case errors.Is(err, policy.ErrDenied):
http.Error(w, "egress: target denied by policy", http.StatusForbidden)
case errors.As(err, &netErr) && netErr.Timeout():
http.Error(w, "egress: upstream timeout: "+err.Error(), http.StatusGatewayTimeout)
default:
http.Error(w, "egress: upstream failed: "+err.Error(), http.StatusBadGateway)
}
}
// bufferedConn reads the bytes a handshake left buffered before the rest of the connection
type bufferedConn struct {
net.Conn
r io.Reader
}
func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
func (c *bufferedConn) CloseWrite() error {
closeWrite(c.Conn)
return nil
}
func withBuffered(conn net.Conn, r *bufio.Reader) net.Conn {
if r.Buffered() == 0 {
return conn // a bare socket lets io.Copy splice
}
return &bufferedConn{Conn: conn, r: r}
}
// relay copies both ways and passes each end of stream on, the git:// protocol needs the half-close
func relay(client, upstream net.Conn) {
done := make(chan struct{})
go func() {
_, _ = io.Copy(upstream, client)
closeWrite(upstream)
close(done)
}()
_, _ = io.Copy(client, upstream)
closeWrite(client)
<-done
_ = client.Close()
_ = upstream.Close()
}
func closeWrite(conn net.Conn) {
if cw, ok := conn.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
} else {
_ = conn.Close()
}
}
+444
View File
@@ -0,0 +1,444 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitproxy
import (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"testing"
"gitea.dev/modules/egress/policy"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testAuth = "Basic dGVzdDp0ZXN0"
var (
allowLoopback = policy.NewPolicy("test", policy.Lax, policy.WithAllow("loopback", ""))
blockLoopback = policy.NewPolicy("test", policy.Lax, policy.WithBlock("loopback", ""))
)
func listen(t *testing.T) net.Listener {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
return ln
}
func serveConns(t *testing.T, handle func(net.Conn)) string {
ln := listen(t)
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
handle(conn)
}()
}
}()
return ln.Addr().String()
}
func startEcho(t *testing.T) string {
return serveConns(t, func(conn net.Conn) { _, _ = io.Copy(conn, conn) })
}
func startProxy(t *testing.T, srv *server) string {
proxySrv := httptest.NewServer(srv)
t.Cleanup(proxySrv.Close)
return proxySrv.Listener.Addr().String()
}
func viaProxy(u *url.URL) *policy.Policy {
return policy.NewPolicy("test", policy.Lax, policy.WithAllow("loopback", ""), policy.WithProxy(http.ProxyURL(u)))
}
func serve(srv *server, method, target, auth string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
req.Header.Set("Proxy-Authorization", auth)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}
func connect(t *testing.T, proxyAddr, target, auth string) (net.Conn, *bufio.Reader, int) {
conn, err := net.Dial("tcp", proxyAddr)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
_, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: %s\r\n\r\n", target, target, auth)
require.NoError(t, err)
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodConnect})
require.NoError(t, err)
defer resp.Body.Close()
return conn, br, resp.StatusCode
}
func assertEcho(t *testing.T, conn net.Conn, br *bufio.Reader) {
_, err := conn.Write([]byte("ping\n"))
require.NoError(t, err)
reply, err := br.ReadString('\n')
require.NoError(t, err)
assert.Equal(t, "ping\n", reply)
}
func startConnectOperator(t *testing.T, useTLS bool, reply string, seen chan<- *http.Request) *httptest.Server {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen <- r
conn, buf, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer conn.Close()
_, _ = buf.WriteString(reply)
_ = buf.Flush()
_, _ = io.Copy(conn, buf)
})
operator := httptest.NewUnstartedServer(handler)
if useTLS {
operator.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
operator.StartTLS()
} else {
operator.Start()
}
t.Cleanup(operator.Close)
return operator
}
func startSOCKS5(t *testing.T) string {
return serveConns(t, func(conn net.Conn) {
br := bufio.NewReader(conn)
read := func(n int) []byte {
buf := make([]byte, n)
_, _ = io.ReadFull(br, buf)
return buf
}
_, _ = conn.Write([]byte{5, 2})
_ = read(int(read(2)[1]))
gotUser := string(read(int(read(2)[1])))
if gotPass := string(read(int(read(1)[0]))); gotUser != "user" || gotPass != "secret" {
_, _ = conn.Write([]byte{1, 1})
return
}
_, _ = conn.Write([]byte{1, 0})
_ = read(int(read(5)[4]) + 2)
_, _ = conn.Write([]byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0})
_, _ = io.Copy(conn, br)
})
}
func TestUpstreamProxy(t *testing.T) {
t.Parallel()
var seen []string
errStop := errors.New("stop")
s := &server{policy: policy.NewPolicy("test", policy.Lax, policy.WithProxy(func(r *http.Request) (*url.URL, error) {
seen = append(seen, r.URL.Host)
return nil, errStop
}))}
for _, target := range []string{"github.com:443", "github.com:8443", "[2001:db8::1]:443"} {
_, err := s.dialUpstream(t.Context(), target)
assert.ErrorIs(t, err, errStop)
}
assert.Equal(t, []string{"github.com", "github.com:8443", "[2001:db8::1]"}, seen)
for _, host := range []string{"localhost", "git.localhost.", "127.0.0.2", "::1", "::ffff:127.0.0.1", "0.0.0.0", "::"} {
u, err := s.upstreamProxy(&http.Request{URL: &url.URL{Host: net.JoinHostPort(host, "80")}})
assert.NoError(t, err, host)
assert.Nil(t, u, host)
}
}
func TestWriteUpstreamError(t *testing.T) {
t.Parallel()
for err, want := range map[error]int{
fmt.Errorf("dial: %w", policy.ErrDenied): http.StatusForbidden,
context.DeadlineExceeded: http.StatusGatewayTimeout,
&net.OpError{Op: "dial", Net: "tcp", Err: os.ErrDeadlineExceeded}: http.StatusGatewayTimeout,
errors.New("refused"): http.StatusBadGateway,
} {
rec := httptest.NewRecorder()
writeUpstreamError(rec, err)
assert.Equal(t, want, rec.Code, err.Error())
}
}
func TestRelay(t *testing.T) {
t.Parallel()
client, clientEnd := net.Pipe()
upstreamEnd, upstream := net.Pipe()
done := make(chan struct{})
go func() {
relay(clientEnd, upstreamEnd)
close(done)
}()
require.NoError(t, upstream.Close())
_, err := client.Read(make([]byte, 1))
assert.ErrorIs(t, err, io.EOF)
<-done
}
func TestProxyCONNECT(t *testing.T) {
t.Parallel()
echo := startEcho(t)
srv := newServer(allowLoopback, testAuth, nil)
assert.Equal(t, http.StatusProxyAuthRequired, serve(srv, http.MethodConnect, echo, "").Code)
assert.Equal(t, http.StatusForbidden, serve(newServer(blockLoopback, "", nil), http.MethodConnect, echo, "").Code)
assert.Equal(t, http.StatusBadRequest, serve(srv, http.MethodConnect, "127.0.0.1", testAuth).Code)
conn, br, status := connect(t, startProxy(t, srv), echo, testAuth)
require.Equal(t, http.StatusOK, status)
assertEcho(t, conn, br)
}
func TestProxyCONNECTOperator(t *testing.T) {
t.Parallel()
const target = "git.example.com:443"
t.Run("HTTP", func(t *testing.T) {
t.Parallel()
seen := make(chan *http.Request, 1)
operator := startConnectOperator(t, false, "HTTP/1.1 204 No Content\r\n\r\nEARLY", seen)
opURL, err := url.Parse(operator.URL)
require.NoError(t, err)
opURL.User = url.UserPassword("user", "secret")
conn, br, status := connect(t, startProxy(t, newServer(viaProxy(opURL), "", nil)), target, "")
require.Equal(t, http.StatusOK, status)
early := make([]byte, 5)
_, err = io.ReadFull(br, early)
require.NoError(t, err)
assert.Equal(t, "EARLY", string(early))
assertEcho(t, conn, br)
req := <-seen
assert.Equal(t, target, req.Host)
assert.Equal(t, basicAuth(opURL.User), req.Header.Get("Proxy-Authorization"))
})
t.Run("HTTPS", func(t *testing.T) {
t.Parallel()
operator := startConnectOperator(t, true, "HTTP/1.1 200 OK\r\n\r\n", make(chan *http.Request, 1))
opURL, err := url.Parse(operator.URL)
require.NoError(t, err)
key, err := x509.MarshalPKCS8PrivateKey(operator.TLS.Certificates[0].PrivateKey)
require.NoError(t, err)
pemFile := filepath.Join(t.TempDir(), "proxy.pem")
pemData := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: operator.Certificate().Raw}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key})...)
require.NoError(t, os.WriteFile(pemFile, pemData, 0o600))
proxyTLS, err := proxyTLSConfig(pemFile, pemFile, "")
require.NoError(t, err)
conn, br, status := connect(t, startProxy(t, newServer(viaProxy(opURL), "", proxyTLS)), target, "")
require.Equal(t, http.StatusOK, status)
assertEcho(t, conn, br)
})
t.Run("SOCKS5", func(t *testing.T) {
t.Parallel()
opURL := &url.URL{Scheme: "socks5", User: url.UserPassword("user", "secret"), Host: startSOCKS5(t)}
conn, br, status := connect(t, startProxy(t, newServer(viaProxy(opURL), "", nil)), target, "")
require.Equal(t, http.StatusOK, status)
assertEcho(t, conn, br)
})
t.Run("NTLM", func(t *testing.T) {
t.Parallel()
challenge := make([]byte, 48)
copy(challenge, "NTLMSSP\x00")
challenge[8] = 2
binary.LittleEndian.PutUint32(challenge[20:], 0x201)
auths := make(chan string, 2)
addr := serveConns(t, func(conn net.Conn) {
br := bufio.NewReader(conn)
for _, reply := range []string{"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: NTLM " + base64.StdEncoding.EncodeToString(challenge) + "\r\nContent-Length: 4\r\n\r\ndeny", "HTTP/1.1 200 OK\r\n\r\n"} {
req, err := http.ReadRequest(br)
if err != nil {
return
}
auths <- req.Header.Get("Proxy-Authorization")
_, _ = io.WriteString(conn, reply)
}
})
srv := newServer(viaProxy(&url.URL{Scheme: "http", User: url.UserPassword(`CORP\alice`, "secret"), Host: addr}), "", nil)
srv.proxyNTLM = true
_, _, status := connect(t, startProxy(t, srv), target, "")
require.Equal(t, http.StatusOK, status)
assert.Regexp(t, "^NTLM TlRMTVNTUAAB", <-auths)
assert.Regexp(t, "^NTLM TlRMTVNTUAAD", <-auths)
})
t.Run("Refused", func(t *testing.T) {
t.Parallel()
operator := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "blocked by policy", http.StatusForbidden)
}))
t.Cleanup(operator.Close)
opURL, err := url.Parse(operator.URL)
require.NoError(t, err)
rec := serve(newServer(viaProxy(opURL), "", nil), http.MethodConnect, target, "")
assert.Equal(t, http.StatusBadGateway, rec.Code)
assert.Contains(t, rec.Body.String(), "blocked by policy")
})
t.Run("ClientGone", func(t *testing.T) {
t.Parallel()
ln := listen(t)
operatorClosed := make(chan struct{})
requestSeen := make(chan struct{})
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer close(operatorClosed)
defer conn.Close()
if _, err := http.ReadRequest(bufio.NewReader(conn)); err != nil {
return
}
close(requestSeen)
_, _ = io.Copy(io.Discard, conn)
}()
conn, err := net.Dial("tcp", startProxy(t, newServer(viaProxy(&url.URL{Scheme: "http", Host: ln.Addr().String()}), "", nil)))
require.NoError(t, err)
_, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
require.NoError(t, err)
responded := make(chan struct{})
go func() {
_, _ = conn.Read(make([]byte, 1))
close(responded)
}()
select {
case <-requestSeen:
case <-responded:
t.Fatal("proxy answered before reaching the operator")
}
require.NoError(t, conn.Close())
<-operatorClosed
})
}
func TestProxyHTTP(t *testing.T) {
t.Parallel()
t.Run("Forward", func(t *testing.T) {
t.Parallel()
firstRead := make(chan struct{})
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Empty(t, r.Header.Get("X-Drop-Me"))
assert.False(t, r.Close)
w.Header().Set("Connection", "X-Origin-Drop")
w.Header().Set("X-Origin-Drop", "dropped")
_, _ = w.Write([]byte("0008NAK\n"))
http.NewResponseController(w).Flush()
<-firstRead
_, _ = w.Write([]byte("0000"))
}))
t.Cleanup(origin.Close)
req, err := http.NewRequest(http.MethodGet, origin.URL+"/git-upload-pack", nil)
require.NoError(t, err)
req.Header.Set("Connection", "close, X-Drop-Me")
req.Header.Set("X-Drop-Me", "dropped")
proxyURL := &url.URL{Scheme: "http", Host: startProxy(t, newServer(allowLoopback, "", nil))}
resp, err := (&http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Empty(t, resp.Header.Get("X-Origin-Drop"))
buf := make([]byte, 8)
_, err = io.ReadFull(resp.Body, buf)
require.NoError(t, err)
assert.Equal(t, "0008NAK\n", string(buf))
close(firstRead)
rest, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "0000", string(rest))
})
t.Run("ViaOperator", func(t *testing.T) {
t.Parallel()
operator := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(r.URL.String()))
}))
t.Cleanup(operator.Close)
opURL, err := url.Parse(operator.URL)
require.NoError(t, err)
rec := serve(newServer(viaProxy(opURL), "", nil), http.MethodGet, "http://git.example.com/repo.git", "")
assert.Equal(t, "http://git.example.com/repo.git", rec.Body.String())
})
t.Run("Denied", func(t *testing.T) {
t.Parallel()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("the blocked origin must not be reached")
}))
t.Cleanup(origin.Close)
assert.Equal(t, http.StatusForbidden, serve(newServer(blockLoopback, "", nil), http.MethodGet, origin.URL, "").Code)
})
t.Run("OriginForm", func(t *testing.T) {
t.Parallel()
assert.Equal(t, http.StatusBadRequest, serve(newServer(allowLoopback, "", nil), http.MethodGet, "/", "").Code)
})
}
func TestMain(m *testing.M) {
MaybeTunnel()
m.Run()
}
func TestRun(t *testing.T) {
base := t.TempDir()
exe, err := os.Executable()
require.NoError(t, err)
repos, err := filepath.Abs("../../modules/git/tests/repos")
require.NoError(t, err)
defer test.MockVariableValue(&setting.Git.HomePath, base)()
defer test.MockVariableValue(&setting.AppPath, exe)()
defer test.MockVariableValue(&setting.Migrations.AllowedHostList, "127.0.0.1/32")()
t.Cleanup(func() { gitcmd.SetExtraEnvs(nil) })
require.NoError(t, Run(t.Context()))
stdout, _, runErr := gitcmd.NewCommand("config", "--get", "http.proxy").RunStdString(t.Context())
require.NoError(t, runErr)
assert.Contains(t, stdout, "http://gitea:")
_, port, err := net.SplitHostPort(serveConns(t, func(conn net.Conn) {
daemon := exec.Command("git", "daemon", "--inetd", "--export-all", "--base-path="+repos)
daemon.Stdin, daemon.Stdout = conn, conn
_ = daemon.Run()
}))
require.NoError(t, err)
require.NoError(t, gitcmd.NewCommand("clone", "-q", "--bare").AddDynamicArguments("git://127.0.0.1:"+port+"/repo1_bare", filepath.Join(base, "allowed")).Run(t.Context()))
_, stderr, runErr := gitcmd.NewCommand("clone", "-q", "--bare").AddDynamicArguments("git://127.0.0.2:"+port+"/repo1_bare", filepath.Join(base, "denied")).RunStdString(t.Context())
require.Error(t, runErr)
assert.Contains(t, stderr, "target denied by policy")
}