mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-01 15:54:49 +00:00
1b1274486c
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>
445 lines
14 KiB
Go
445 lines
14 KiB
Go
// 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")
|
|
}
|