Skip to content

Commit 9f1a99d

Browse files
committed
test: cover cross-host auth forwarding for submodule clones
Add a TestCloneRepoSubmoduleHostAuth case that observes whether the submodule HTTP server received an Authorization header. The same-host subtest expects auth to be forwarded; the cross-host subtest uses 'localhost' for the .gitmodules URL (so SameHost compares as mismatched) while both hosts still resolve to the loopback interface, so the submodule server is reachable but should never see the parent credentials. Covers the security half of mafredri thread PRRT_kwDOJPSNMM5vAH0h.
1 parent afe8c56 commit 9f1a99d

1 file changed

Lines changed: 110 additions & 0 deletions

File tree

git/git_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import (
55
"crypto/ed25519"
66
"encoding/base64"
77
"io"
8+
"net/http"
89
"net/http/httptest"
910
"net/url"
1011
"os"
1112
"path/filepath"
1213
"strings"
14+
"sync"
1315
"testing"
1416

1517
"github.com/coder/envbuilder/git"
@@ -1033,3 +1035,111 @@ func writeTestPrivateKey(t *testing.T) string {
10331035
func base64EncodeTestPrivateKey() string {
10341036
return base64.StdEncoding.EncodeToString([]byte(testKey))
10351037
}
1038+
1039+
// TestCloneRepoSubmoduleHostAuth verifies that parent repository credentials
1040+
// are forwarded to a submodule only when the submodule resides on the same
1041+
// host as the parent. The submodule server records every request that arrived
1042+
// with an Authorization header.
1043+
func TestCloneRepoSubmoduleHostAuth(t *testing.T) {
1044+
t.Parallel()
1045+
1046+
const (
1047+
parentUser = "parent-user"
1048+
parentPass = "parent-pass"
1049+
)
1050+
1051+
cases := []struct {
1052+
name string
1053+
rewriteSubURL func(string) string
1054+
wantAuthSeen bool
1055+
}{
1056+
{
1057+
name: "SameHostForwardsAuth",
1058+
rewriteSubURL: func(u string) string { return u },
1059+
wantAuthSeen: true,
1060+
},
1061+
{
1062+
name: "CrossHostWithholdsAuth",
1063+
rewriteSubURL: func(u string) string {
1064+
// 127.0.0.1 and localhost both resolve to the loopback
1065+
// interface, so the submodule server is still reachable,
1066+
// but SameHost compares the hostname strings and reports
1067+
// a mismatch.
1068+
return strings.Replace(u, "127.0.0.1", "localhost", 1)
1069+
},
1070+
wantAuthSeen: false,
1071+
},
1072+
}
1073+
1074+
for _, tc := range cases {
1075+
tc := tc
1076+
t.Run(tc.name, func(t *testing.T) {
1077+
t.Parallel()
1078+
1079+
// Build the submodule repository in memory.
1080+
subFS := memfs.New()
1081+
subRepo := gittest.NewRepo(t, subFS,
1082+
gittest.Commit(t, "subfile.txt", "submodule content", "submodule init"),
1083+
)
1084+
subHead, err := subRepo.Head()
1085+
require.NoError(t, err)
1086+
subHash := subHead.Hash()
1087+
1088+
// Wrap the submodule server with an observer that records any
1089+
// request that carried an Authorization header. The submodule
1090+
// server itself does not require auth; we only care about whether
1091+
// the client offered the parent credentials.
1092+
var (
1093+
authMu sync.Mutex
1094+
authSeen bool
1095+
)
1096+
observer := func(next http.Handler) http.Handler {
1097+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1098+
if r.Header.Get("Authorization") != "" {
1099+
authMu.Lock()
1100+
authSeen = true
1101+
authMu.Unlock()
1102+
}
1103+
next.ServeHTTP(w, r)
1104+
})
1105+
}
1106+
subSrv := httptest.NewServer(observer(gittest.NewServer(subFS)))
1107+
t.Cleanup(subSrv.Close)
1108+
1109+
// Build the parent repository pointing to the submodule. The
1110+
// .gitmodules URL may be munged to use a different hostname,
1111+
// which is what the cross-host case exercises.
1112+
subURLInGitmodules := tc.rewriteSubURL(subSrv.URL)
1113+
parentFS := memfs.New()
1114+
_ = gittest.NewRepo(t, parentFS,
1115+
gittest.Commit(t, "README.md", "parent readme", "parent init"),
1116+
gittest.CommitSubmodule(t, "submod", subURLInGitmodules, subHash),
1117+
)
1118+
parentSrv := httptest.NewServer(
1119+
mwtest.BasicAuthMW(parentUser, parentPass)(gittest.NewServer(parentFS)),
1120+
)
1121+
t.Cleanup(parentSrv.Close)
1122+
1123+
// Clone the parent with credentials and submodule recursion enabled.
1124+
clientFS := memfs.New()
1125+
_, err = git.CloneRepo(context.Background(), t.Logf, git.CloneRepoOptions{
1126+
Path: "/workspace",
1127+
RepoURL: parentSrv.URL,
1128+
Storage: clientFS,
1129+
RepoAuth: &githttp.BasicAuth{
1130+
Username: parentUser,
1131+
Password: parentPass,
1132+
},
1133+
SubmoduleDepth: 5,
1134+
})
1135+
require.NoError(t, err)
1136+
1137+
authMu.Lock()
1138+
got := authSeen
1139+
authMu.Unlock()
1140+
require.Equal(t, tc.wantAuthSeen, got,
1141+
"submodule server saw Authorization header (cross-host should withhold, same-host should forward)")
1142+
})
1143+
}
1144+
}
1145+

0 commit comments

Comments
 (0)