Merge pull request #65 from entireio/soph/issue-63-deferred-credential-helper · Entire
Log in
Merge pull request #65 from entireio/soph/issue-63-deferred-credential-helper
f1780e0→main·
Soph·1mo ago·9 files·+1,974 added/-92 removed
auth: defer credential helper until 401, match git's behaviour
Changes
9
cmd/git-sync
Mmain_test.go+28/-2
internal
auth
Mauth.go+107/-25
Mauth_test.go+275/-28
gitproto
Mpush.go+7
Msmarthttp.go+436/-23
Msmarthttp_test.go+1097/-2
syncer
Mauth_test.go+8/-8
Mintegration_test.go+12/-4
Msyncer.go+4
3 unmodified lines
4
5
6
7
8
9
10
4 unmodified lines
15
16
17
18
19
20
21
9 unmodified lines
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
206 unmodified lines
264
265
266
244
245
267
268
269
270
271
272
273
274
3 unmodified lines
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
4 unmodified lines
"testing"
"time"
"entire.io/entire/git-sync/internal/auth"
"entire.io/entire/git-sync/internal/syncertest"
"entire.io/entire/git-sync/unstable"
billy "github.com/go-git/go-billy/v6"
9 unmodified lines
"github.com/go-git/go-git/v6/storage/memory"
)
// TestMain isolates the package's tests from the developer's local
// credential helper. EnsureAuthForService probes /git-receive-pack with a
// flush-packet POST unconditionally (required to discover cross-host
// auth challenges and auth-on-POST-only gates), so without stubbing the
// helper, `git credential fill` could find stored credentials for
// 127.0.0.1 (e.g. cached from an earlier test run) and attach them,
// changing the wire shape of the push the test under inspection.
//
// The probe itself still happens — receive-pack POST counts include it —
// but the stub guarantees no credentials are attached and the probe
// returns without further side effects on the helper.
//
// Tests that need to exercise helper behaviour explicitly should
// restore auth.GitCredentialCommand in their own setup.
func TestMain(m *testing.M) {
auth.GitCredentialCommand = func(_ context.Context, _ auth.CredentialOp, _ string) ([]byte, error) {
return nil, errors.New("no helper configured (test default)")
}
os.Exit(m.Run())
}
const testBranch = "master"
const modeReplicate = "replicate"
206 unmodified lines
t.Fatalf("expected relayReason=empty-target-managed-refs, got %#v", result["relayReason"])
}
if got := targetServer.Count("git-receive-pack"); got != 1 {
t.Fatalf("expected one receive-pack POST, got %d", got)
// Two receive-pack POSTs: the auth-probe (a flush-packet POST that
// EnsureAuthForService always sends to detect auth-on-POST-only gates and
// cross-host challenges) plus the real push.
if got := targetServer.Count("git-receive-pack"); got != 2 {
t.Fatalf("expected two receive-pack POSTs (auth-probe + real push), got %d", got)
}
sourceHead, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
if err != nil {
Mcmd/git-sync/main_test.go+28/-2
28 unmodified lines
29
30
31
32
32
33
34
35
36
37
37
38
39
40
38
39
40
41
1 unmodified line
43
44
45
48
49
50
46
47
48
11 unmodified lines
60
61
62
68
69
70
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
72
88
89
90
75
76
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
78
119
120
80
81
82
121
122
123
124
125
126
127
128
129
130
131
132
85
133
134
87
135
136
89
137
138
139
140
1 unmodified line
142
143
144
97
145
146
147
100
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
2 unmodified lines
181
182
183
109
110
184
185
186
187
188
189
190
191
192
193
194
195
28 unmodified lines
}
// Resolve resolves the auth method for the given endpoint configuration.
// Order: explicit flags → Entire DB token → git credential helper → anonymous.
// Order: explicit flags → Entire DB token → anonymous (with the git credential
// helper deferred until the server returns 401, matching git's own behaviour).
func Resolve(raw Endpoint, ep *url.URL) (Method, error) {
if auth := explicitAuth(raw); auth != nil {
return auth, nil
}
if ep == nil {
return nil, nil //nolint:nilnil // nil signals no auth method found at this stage
}
if ep.Scheme != "http" && ep.Scheme != "https" {
if !isHTTPEndpoint(ep) {
return nil, nil //nolint:nilnil // nil signals no auth method found at this stage
}
if username, password, ok, err := LookupEntireDBCredential(raw, ep); err != nil {
1 unmodified line
} else if ok {
return &transporthttp.BasicAuth{Username: username, Password: password}, nil
}
if username, password, ok := lookupGitCredential(ep); ok {
return &transporthttp.BasicAuth{Username: username, Password: password}, nil
}
return nil, nil //nolint:nilnil // nil signals no auth method found at this stage
}
11 unmodified lines
return nil
}
// GitCredentialFillCommand is replaceable for testing.
var GitCredentialFillCommand = func(ctx context.Context, input string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", "credential", "fill")
// CredentialOp identifies a `git credential` subcommand.
type CredentialOp string
const (
CredentialOpFill CredentialOp = "fill"
CredentialOpApprove CredentialOp = "approve"
CredentialOpReject CredentialOp = "reject"
)
// newGitCredentialCmd builds the `git credential <op>` invocation. Extracted
// so tests can inspect the command's environment without exec'ing git.
//
// We inherit the parent environment unchanged — in particular, we do NOT
// force GIT_TERMINAL_PROMPT=0. The original #63 symptom (interactive prompt
// on a public-and-anonymous repo) is already prevented by Resolve no longer
// invoking the helper proactively: with no 401 there's no Lookup, no
// `git credential fill`, and so no prompt. Once the server actually
// challenges with a 401, prompting is the right behaviour when there's a
// terminal and a helper that has no entry for the host yet — same as
// vanilla `git push`. Non-interactive callers (CI, daemons, the syncer
// background loop) set GIT_TERMINAL_PROMPT=0 in their own environment the
// same way they would for plain git, and we pass that through.
func newGitCredentialCmd(ctx context.Context, op CredentialOp, input string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", "credential", string(op))
cmd.Stdin = strings.NewReader(input)
return cmd.Output()
return cmd
}
func lookupGitCredential(ep *url.URL) (string, string, bool) {
input := credentialFillInput(ep)
// GitCredentialCommand invokes `git credential <op>` with the given input
// (git-credential text format). Replaceable for testing.
var GitCredentialCommand = func(ctx context.Context, op CredentialOp, input string) ([]byte, error) {
return newGitCredentialCmd(ctx, op, input).Output()
}
// GitCredentialHelper bridges Git's credential helper protocol to HTTP auth.
// Best-effort: a missing or misbehaving helper denies credentials rather
// than failing the surrounding sync.
type GitCredentialHelper struct{}
// Lookup queries the git credential helper for credentials for ep. Returns
// ok=false if no credentials are available so the caller can surface a
// clean 401. A non-nil error means the lookup itself couldn't complete
// (e.g. the context was cancelled) and the caller should surface that
// rather than fall back to the original 401.
//
// Lookup may block on user interaction when the helper falls through to a
// terminal prompt (vanilla `git credential fill` behaviour). Callers that
// must not block should set GIT_TERMINAL_PROMPT=0 in the process
// environment; the credential subprocess inherits it. See
// newGitCredentialCmd for the rationale on not forcing that ourselves.
func (GitCredentialHelper) Lookup(ctx context.Context, ep *url.URL) (username, password string, ok bool, err error) {
if !isHTTPEndpoint(ep) {
return "", "", false, nil
}
input := credentialInput(ep, "", "")
if input == "" {
return "", "", false
return "", "", false, nil
}
output, err := GitCredentialFillCommand(context.Background(), input)
if err != nil {
return "", "", false
output, helperErr := GitCredentialCommand(ctx, CredentialOpFill, input)
if helperErr != nil {
// A cancelled or timed-out context kills the `git credential fill`
// subprocess; surface that as the real cause instead of masking it
// as "no credentials available", which would report the original
// HTTP 401 rather than context.Canceled/DeadlineExceeded.
if ctxErr := ctx.Err(); ctxErr != nil {
return "", "", false, fmt.Errorf("git credential fill: %w", ctxErr)
}
return "", "", false, nil
}
values := parseCredentialOutput(output)
password := values["password"]
password = values["password"]
if password == "" {
return "", "", false
return "", "", false, nil
}
username := values["username"]
username = values["username"]
if username == "" {
if ep.User != nil && ep.User.Username() != "" {
username = ep.User.Username()
1 unmodified line
username = defaultGitUsername
}
}
return username, password, true
return username, password, true, nil
}
func credentialFillInput(ep *url.URL) string {
// Approve tells the helper the credentials worked.
func (h GitCredentialHelper) Approve(ctx context.Context, ep *url.URL, username, password string) {
h.signal(ctx, CredentialOpApprove, ep, username, password)
}
// Reject tells the helper the credentials failed.
func (h GitCredentialHelper) Reject(ctx context.Context, ep *url.URL, username, password string) {
h.signal(ctx, CredentialOpReject, ep, username, password)
}
func (GitCredentialHelper) signal(ctx context.Context, op CredentialOp, ep *url.URL, username, password string) {
input := credentialInput(ep, username, password)
if input == "" {
return
}
_, _ = GitCredentialCommand(ctx, op, input) //nolint:errcheck // advisory signal; helper failures swallowed
}
func isHTTPEndpoint(ep *url.URL) bool {
return ep != nil && (ep.Scheme == "http" || ep.Scheme == "https")
}
// credentialInput builds a git-credential format request body for the given
// endpoint. When username/password are set, they are appended (for use with
// `git credential approve`/`reject`). When both are empty, the result is a
// query body suitable for `git credential fill`. Explicit username overrides
// any user embedded in the endpoint URL.
func credentialInput(ep *url.URL, username, password string) string {
if ep == nil || ep.Hostname() == "" {
return ""
}
2 unmodified lines
if path := strings.TrimPrefix(ep.Path, "/"); path != "" {
fmt.Fprintf(&b, "path=%s\n", path)
}
if ep.User != nil && ep.User.Username() != "" {
fmt.Fprintf(&b, "username=%s\n", ep.User.Username())
user := username
if user == "" && ep.User != nil {
user = ep.User.Username()
}
if user != "" {
fmt.Fprintf(&b, "username=%s\n", user)
}
if password != "" {
fmt.Fprintf(&b, "password=%s\n", password)
}
b.WriteString("\n")
return b.String()
Minternal/auth/auth.go+107/-25
118 unmodified lines
119
120
121
122
122
123
124
125
1 unmodified line
127
128
129
130
130
131
132
133
133
134
135
136
137
138
137
138
139
140
141
142
143
144
144
145
146
146
147
148
149
150
151
152
152
153
154
155
156
157
158
158
159
160
161
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
69 unmodified lines
262
263
264
237
265
266
267
29 unmodified lines
297
298
299
273
274
275
276
277
278
300
301
302
303
304
305
306
307
308
285
286
287
288
289
290
291
292
293
294
295
309
310
311
312
313
314
315
316
317
318
319
108 unmodified lines
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
118 unmodified lines
}
}
func TestCredentialFillInput(t *testing.T) {
func TestCredentialInput_FillQueryWithEmbeddedUser(t *testing.T) {
ep := &url.URL{
Scheme: "https",
Host: "github.com",
1 unmodified line
User: url.User("myuser"),
}
got := credentialFillInput(ep)
got := credentialInput(ep, "", "")
want := "protocol=https\nhost=github.com\npath=owner/repo.git\nusername=myuser\n\n"
if got != want {
t.Errorf("credentialFillInput returned:\n%q\nwant:\n%q", got, want)
t.Errorf("credentialInput returned:\n%q\nwant:\n%q", got, want)
}
}
func TestCredentialFillInputNilEndpoint(t *testing.T) {
got := credentialFillInput(nil)
func TestCredentialInput_NilEndpoint(t *testing.T) {
got := credentialInput(nil, "", "")
if got != "" {
t.Errorf("expected empty string for nil endpoint, got %q", got)
}
}
func TestCredentialFillInputEmptyHost(t *testing.T) {
func TestCredentialInput_EmptyHost(t *testing.T) {
ep := &url.URL{Scheme: "https"}
got := credentialFillInput(ep)
got := credentialInput(ep, "", "")
if got != "" {
t.Errorf("expected empty string for empty host, got %q", got)
}
}
func TestCredentialFillInputNoUser(t *testing.T) {
func TestCredentialInput_FillQueryNoUser(t *testing.T) {
ep := &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/repo.git",
}
got := credentialFillInput(ep)
got := credentialInput(ep, "", "")
want := "protocol=https\nhost=example.com\npath=repo.git\n\n"
if got != want {
t.Errorf("credentialFillInput returned:\n%q\nwant:\n%q", got, want)
t.Errorf("credentialInput returned:\n%q\nwant:\n%q", got, want)
}
}
func TestCredentialInput_ApproveRejectFormatIncludesUserAndPassword(t *testing.T) {
ep := &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/owner/repo.git",
}
got := credentialInput(ep, "alice", "s3cret")
want := "protocol=https\nhost=example.com\npath=owner/repo.git\nusername=alice\npassword=s3cret\n\n"
if got != want {
t.Errorf("credentialInput returned:\n%q\nwant:\n%q", got, want)
}
}
func TestCredentialInput_ExplicitUserOverridesURLUser(t *testing.T) {
ep := &url.URL{
Scheme: "https",
Host: "example.com",
User: url.User("from-url"),
}
got := credentialInput(ep, "explicit", "")
if !strings.Contains(got, "username=explicit\n") {
t.Errorf("expected explicit username to win, got:\n%q", got)
}
if strings.Contains(got, "username=from-url") {
t.Errorf("URL-embedded username should be overridden, got:\n%q", got)
}
}
69 unmodified lines
name string
raw Endpoint
ep *url.URL
mockCred func(ctx context.Context, input string) ([]byte, error)
wantType string // "token", "basic", "nil"
wantUser string
wantPass string
29 unmodified lines
wantType: "nil",
},
{
name: "nothing set HTTP endpoint no credential helper returns nil",
raw: Endpoint{},
ep: ep,
mockCred: func(_ context.Context, _ string) ([]byte, error) {
return nil, errors.New("no helper")
},
name: "nothing set HTTP endpoint returns nil (defer to helper on 401)",
raw: Endpoint{},
ep: ep,
wantType: "nil",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save and restore GitCredentialFillCommand.
origCmd := GitCredentialFillCommand
defer func() { GitCredentialFillCommand = origCmd }()
if tt.mockCred != nil {
GitCredentialFillCommand = tt.mockCred
} else {
// Default mock: no credential helper.
GitCredentialFillCommand = func(_ context.Context, _ string) ([]byte, error) {
return nil, errors.New("no helper")
}
// Resolve must never consult the git credential helper —
// that lookup is deferred to a 401 response. If anything
// here invokes the helper, fail loudly.
origCmd := GitCredentialCommand
defer func() { GitCredentialCommand = origCmd }()
GitCredentialCommand = func(_ context.Context, op CredentialOp, input string) ([]byte, error) {
t.Fatalf("unexpected GitCredentialCommand(%q, %q) call during Resolve", op, input)
return nil, nil
}
// Also ensure ENTIRE_CONFIG_DIR points nowhere so EntireDB lookup
108 unmodified lines
}
}
type recordedCredCall struct {
op CredentialOp
input string
}
func withRecordingHelper(t *testing.T, calls *[]recordedCredCall, handler func(op CredentialOp, input string) ([]byte, error)) {
t.Helper()
orig := GitCredentialCommand
t.Cleanup(func() { GitCredentialCommand = orig })
GitCredentialCommand = func(_ context.Context, op CredentialOp, input string) ([]byte, error) {
*calls = append(*calls, recordedCredCall{op: op, input: input})
if handler == nil {
return nil, nil
}
return handler(op, input)
}
}
func TestGitCredentialHelper_Lookup_ReturnsCredentials(t *testing.T) {
ep := &url.URL{Scheme: "https", Host: "example.com", Path: "/owner/repo.git"}
var calls []recordedCredCall
withRecordingHelper(t, &calls, func(op CredentialOp, _ string) ([]byte, error) {
if op != CredentialOpFill {
t.Fatalf("expected fill, got %q", op)
}
return []byte("username=alice\npassword=s3cret\n"), nil
})
user, pass, ok, err := GitCredentialHelper{}.Lookup(context.Background(), ep)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatal("expected ok=true")
}
if user != "alice" || pass != "s3cret" {
t.Errorf("got user=%q pass=%q, want alice/s3cret", user, pass)
}
if len(calls) != 1 {
t.Fatalf("expected 1 helper call, got %d", len(calls))
}
if !strings.Contains(calls[0].input, "protocol=https\nhost=example.com\n") {
t.Errorf("fill input missing host/protocol:\n%q", calls[0].input)
}
}
func TestGitCredentialHelper_Lookup_HelperFailsReturnsNotFound(t *testing.T) {
ep := &url.URL{Scheme: "https", Host: "example.com"}
withRecordingHelper(t, new([]recordedCredCall), func(_ CredentialOp, _ string) ([]byte, error) {
return nil, errors.New("no helper")
})
_, _, ok, err := GitCredentialHelper{}.Lookup(context.Background(), ep)
if err != nil {
t.Errorf("expected no error when helper has no credentials, got %v", err)
}
if ok {
t.Error("expected ok=false when helper fails")
}
}
// TestGitCredentialHelper_Lookup_ContextCanceledSurfacesError ensures a
// cancelled context isn't masked as "no credentials available": when the
// `git credential fill` subprocess dies because the context is gone, Lookup
// must return the context error so callers report it instead of falling back
// to the original HTTP 401.
func TestGitCredentialHelper_Lookup_ContextCanceledSurfacesError(t *testing.T) {
ep := &url.URL{Scheme: "https", Host: "example.com"}
ctx, cancel := context.WithCancel(context.Background())
cancel()
withRecordingHelper(t, new([]recordedCredCall), func(_ CredentialOp, _ string) ([]byte, error) {
// exec.CommandContext kills the subprocess once the context is done,
// surfacing as a command error.
return nil, errors.New("signal: killed")
})
_, _, ok, err := GitCredentialHelper{}.Lookup(ctx, ep)
if ok {
t.Error("expected ok=false on a cancelled context")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}
func TestGitCredentialHelper_Lookup_EmptyPasswordReturnsNotFound(t *testing.T) {
ep := &url.URL{Scheme: "https", Host: "example.com"}
withRecordingHelper(t, new([]recordedCredCall), func(_ CredentialOp, _ string) ([]byte, error) {
return []byte("username=alice\n"), nil
})
_, _, ok, err := GitCredentialHelper{}.Lookup(context.Background(), ep)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if ok {
t.Error("expected ok=false when password is empty")
}
}
func TestGitCredentialHelper_Lookup_UsernameFallsBackToGit(t *testing.T) {
ep := &url.URL{Scheme: "https", Host: "example.com"}
withRecordingHelper(t, new([]recordedCredCall), func(_ CredentialOp, _ string) ([]byte, error) {
return []byte("password=tok\n"), nil
})
user, _, ok, err := GitCredentialHelper{}.Lookup(context.Background(), ep)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatal("expected ok=true")
}
if user != "git" {
t.Errorf("expected username fallback to 'git', got %q", user)
}
}
func TestGitCredentialHelper_Lookup_NonHTTPEndpointReturnsNotFound(t *testing.T) {
ep := &url.URL{Scheme: "ssh", Host: "example.com"}
calls := new([]recordedCredCall)
withRecordingHelper(t, calls, func(_ CredentialOp, _ string) ([]byte, error) {
return []byte("password=tok\n"), nil
})
_, _, ok, err := GitCredentialHelper{}.Lookup(context.Background(), ep)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Error("expected ok=false for SSH endpoint — credential helper protocol is HTTP-only")
}
if len(*calls) != 0 {
t.Errorf("expected no helper call for SSH endpoint, got %d", len(*calls))
}
}
func TestGitCredentialHelper_Approve_SendsCredentialsToHelper(t *testing.T) {
ep := &url.URL{Scheme: "https", Host: "example.com", Path: "/owner/repo.git"}
var calls []recordedCredCall
withRecordingHelper(t, &calls, nil)
GitCredentialHelper{}.Approve(context.Background(), ep, "alice", "s3cret")
if len(calls) != 1 || calls[0].op != CredentialOpApprove {
t.Fatalf("expected one 'approve' call, got %+v", calls)
}
want := "protocol=https\nhost=example.com\npath=owner/repo.git\nusername=alice\npassword=s3cret\n\n"
if calls[0].input != want {
t.Errorf("approve input:\n%q\nwant:\n%q", calls[0].input, want)
}
}
func TestGitCredentialHelper_Reject_SendsCredentialsToHelper(t *testing.T) {
ep := &url.URL{Scheme: "https", Host: "example.com"}
var calls []recordedCredCall
withRecordingHelper(t, &calls, nil)
GitCredentialHelper{}.Reject(context.Background(), ep, "alice", "bad")
if len(calls) != 1 || calls[0].op != CredentialOpReject {
t.Fatalf("expected one 'reject' call, got %+v", calls)
}
if !strings.Contains(calls[0].input, "username=alice\npassword=bad\n") {
t.Errorf("reject input missing creds:\n%q", calls[0].input)
}
}
func TestGitCredentialHelper_ApproveRejectSwallowHelperErrors(t *testing.T) {
ep := &url.URL{Scheme: "https", Host: "example.com"}
withRecordingHelper(t, new([]recordedCredCall), func(_ CredentialOp, _ string) ([]byte, error) {
return nil, errors.New("helper unavailable")
})
// Approve/Reject must not panic when the helper is broken.
GitCredentialHelper{}.Approve(context.Background(), ep, "u", "p")
GitCredentialHelper{}.Reject(context.Background(), ep, "u", "p")
}
// TestGitCredentialCmdInheritsEnvWithoutOverridingTerminalPrompt locks in
// the corrected behaviour from issue #63: the proactive-Lookup path is what
// caused the original spurious prompt on a public repo (already fixed by
// deferring Lookup to a real 401), and we deliberately do NOT also force
// GIT_TERMINAL_PROMPT=0. Forcing it would block legitimate first-time
// authentication to a new host. Non-interactive callers (CI, daemons) set
// the env var in their own environment, and we inherit it as-is.
func TestGitCredentialCmdInheritsEnvWithoutOverridingTerminalPrompt(t *testing.T) {
// When the parent process has no GIT_TERMINAL_PROMPT set, the
// subprocess must not have one either — letting git's default
// (prompting allowed) take effect.
t.Setenv("GIT_TERMINAL_PROMPT", "")
os.Unsetenv("GIT_TERMINAL_PROMPT")
cmd := newGitCredentialCmd(context.Background(), CredentialOpFill, "protocol=https\nhost=example.com\n\n")
// cmd.Env == nil means "inherit from parent" — equivalent to no override.
// If the implementation sets cmd.Env explicitly we still want no
// GIT_TERMINAL_PROMPT entry.
for _, kv := range cmd.Env {
if strings.HasPrefix(kv, "GIT_TERMINAL_PROMPT=") {
t.Errorf("subprocess must not force GIT_TERMINAL_PROMPT; got %q", kv)
}
}
// When the parent sets GIT_TERMINAL_PROMPT=0 (non-interactive callers),
// the subprocess sees the same value — we pass it through, we don't
// override or strip it.
t.Setenv("GIT_TERMINAL_PROMPT", "0")
cmd = newGitCredentialCmd(context.Background(), CredentialOpFill, "protocol=https\nhost=example.com\n\n")
// cmd.Env == nil also satisfies this case (subprocess inherits the
// parent env including the value we just Setenv'd). If cmd.Env is
// populated, it must contain exactly the parent value.
if cmd.Env != nil {
var found, count int
for _, kv := range cmd.Env {
if strings.HasPrefix(kv, "GIT_TERMINAL_PROMPT=") {
count++
if kv == "GIT_TERMINAL_PROMPT=0" {
found++
}
}
}
if count != 1 || found != 1 {
t.Errorf("expected exactly one GIT_TERMINAL_PROMPT=0 entry passed through, got %d total / %d matching: %v", count, found, cmd.Env)
}
}
}
func TestEndpointBaseURL(t *testing.T) {
tests := []struct {
name string
Minternal/auth/auth_test.go+275/-28
151 unmodified lines
152
153
154
155
156
157
158
159
160
161
162
163
164
151 unmodified lines
if err := req.Encode(&header); err != nil {
return fmt.Errorf("encode update-request: %w", err)
}
// The push body is io.MultiReader(header, packData); packData comes
// from a live upload-pack pipe and isn't rewindable, so a mid-stream
// 401 can't trigger PostRPCStreamBody's normal helper retry. Probe
// for auth requirements with a same-shape POST first.
if hc, ok := conn.(*HTTPConn); ok {
hc.EnsureAuthForService(ctx, transport.ReceivePackService)
}
body := io.Reader(bytes.NewReader(header.Bytes()))
if packData != nil {
body = io.MultiReader(body, packData)
Minternal/gitproto/push.go+7
14 unmodified lines
15
16
17
18
19
20
21
157 unmodified lines
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
1 unmodified line
203
204
205
206
207
208
209
210
211
212
213
214
215
4 unmodified lines
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
1 unmodified line
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
79 unmodified lines
363
364
365
290
291
292
366
367
294
368
369
296
297
298
299
300
370
371
372
373
374
375
302
376
377
304
378
379
380
381
382
383
384
385
306
386
387
308
388
389
390
391
392
393
394
395
396
397
10 unmodified lines
408
409
410
325
326
327
411
412
413
414
415
416
417
418
419
420
421
422
423
424
49 unmodified lines
474
475
476
477
478
479
480
481
482
483
384
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
7 unmodified lines
533
534
535
398
536
537
538
539
3 unmodified lines
543
544
545
408
409
410
411
412
546
547
548
549
6 unmodified lines
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
14 unmodified lines
"strings"
"github.com/go-git/go-git/v6/plumbing/protocol/capability"
transporthttp "github.com/go-git/go-git/v6/plumbing/transport/http"
)
const maxHTTPErrorBody = 64 * 1024
157 unmodified lines
Authorizer(req *http.Request) error
}
// CredentialHelper provides on-demand credentials when an HTTP request is
// rejected with 401. Lookup may block on user interaction if the underlying
// helper falls through to a terminal prompt — that's vanilla git's
// behaviour and intentional for interactive users. Callers that must not
// block (CI, daemons, the syncer's background loop) set
// GIT_TERMINAL_PROMPT=0 in their environment, which the credential
// subprocess inherits. Lookup returns ok=false when no credentials could
// be obtained, so the caller can surface a clean 401.
//
// Approve/Reject are advisory and intentionally have no error return:
// failures must not poison the outer request flow.
type CredentialHelper interface {
Lookup(ctx context.Context, ep *url.URL) (username, password string, ok bool, err error)
Approve(ctx context.Context, ep *url.URL, username, password string)
Reject(ctx context.Context, ep *url.URL, username, password string)
}
// HTTPConn represents a connection to a remote Git HTTP endpoint.
type HTTPConn struct {
Label string
1 unmodified line
HTTP *http.Client
Auth AuthMethod
// CredentialHelper, if set, is consulted on a 401 response when no
// initial Auth was configured. The retry happens once on /info/refs;
// on success the resolved credentials are stored in Auth for the
// remaining requests on this connection. Setting Auth up front
// disables the helper fallback — explicit auth wins.
CredentialHelper CredentialHelper
// FollowInfoRefsRedirect, when true, rewrites Endpoint.Scheme and
// Endpoint.Host to the final URL returned by RequestInfoRefs after
// HTTP redirects. Subsequent PostRPC* calls then target the
4 unmodified lines
// callers that rely on Endpoint being stable.
FollowInfoRefsRedirect bool
// InsecureSkipTLSVerify mirrors the same-named transport setting and
// must be set by callers whenever the HTTP client they pass in has
// TLS verification disabled. The credential-helper retry path uses it
// to refuse cross-host operations: with TLS verification off there's
// no way to know whether a redirect's destination is the host the
// user trusts or a MITM impersonating it, so sending the helper's
// stored credentials there is unsafe. Same-host 401s (no redirect)
// still retry — the user has accepted whatever host they configured.
InsecureSkipTLSVerify bool
// ProgressOut is the destination for verbose sideband progress
// messages ("Enumerating objects: ...", "Resolving deltas: ..."
// streamed by upload-pack and receive-pack). Nil falls back to
1 unmodified line
// coordinated writer here so server-side progress lines don't
// clobber the in-place ticker frame.
ProgressOut io.Writer
// pendingHelperCreds tracks credentials supplied by the helper via
// EnsureAuthForService but not yet validated against a real operation.
// The next RequestInfoRefs/PostRPCStreamBody approves on 2xx or rejects
// on 401/403, ensuring helper state reflects the actual outcome rather
// than an ambiguous probe response.
pendingHelperCreds *helperCreds
// resolvedEndpoint, when non-nil, supersedes EndpointURL.Scheme/Host
// for outgoing requests on this conn. Populated when one of two
// redirect-following paths fires:
//
// - The FollowInfoRefsRedirect block in RequestInfoRefs, after a
// successful /info/refs that landed on a different host.
// - adoptChallengeHost, after credential-helper auth resolves
// against a cross-host challenge.
//
// Both are gated on FollowInfoRefsRedirect, so resolvedEndpoint only
// diverges from EndpointURL when the user has opted into following
// redirects. EndpointURL itself is never mutated — display, logging,
// telemetry, and the user-typed-URL accessors (Endpoint()) keep
// returning what the caller passed in.
//
// Path/userinfo are copied from EndpointURL; only Scheme/Host differ.
resolvedEndpoint *url.URL
}
// requestURL returns the URL outgoing requests should build from on this
// conn. It is the resolved endpoint when one has been discovered (and the
// user opted into following redirects via FollowInfoRefsRedirect), and the
// user-typed EndpointURL otherwise.
func (c *HTTPConn) requestURL() *url.URL {
if c.resolvedEndpoint != nil {
return c.resolvedEndpoint
}
return c.EndpointURL
}
type helperCreds struct {
user, pass string
url *url.URL
}
// NewHTTPConn creates a new connection to the given endpoint.
79 unmodified lines
// RequestInfoRefs fetches /info/refs for the given service.
func (c *HTTPConn) RequestInfoRefs(ctx context.Context, service string, gitProtocol string) ([]byte, error) {
reqURL := fmt.Sprintf("%s/info/refs?service=%s", c.EndpointURL.String(), service)
ctx = withHTTPTrace(ctx, "GET "+service+"/info/refs")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
res, err := c.doInfoRefsRequest(ctx, service, gitProtocol, c.Auth, nil)
if err != nil {
return nil, fmt.Errorf("create info-refs request: %w", err)
return nil, err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", capability.DefaultAgent())
req.Header.Set(StatsPhaseHeader, service+" info-refs")
if gitProtocol != "" {
req.Header.Set("Git-Protocol", gitProtocol)
res, err = c.tryHelperRetry(ctx, res, func(auth AuthMethod, target *url.URL) (*http.Response, error) {
return c.doInfoRefsRequest(ctx, service, gitProtocol, auth, target)
})
if err != nil {
return nil, err
}
ApplyAuth(req, c.Auth)
defer res.Body.Close()
res, err := c.HTTP.Do(req)
data, err := c.readInfoRefsResponse(res, service)
// Settle helper credentials on the fully-validated outcome: approve only
// once the advertisement parsed and read within limits, reject on 401/403.
// Running this after validation stops a misleading 2xx (wrong content-type
// or oversized body) from persisting credentials for an operation that
// ultimately failed.
c.resolvePendingHelperCreds(ctx, res, err == nil)
if err != nil {
return nil, fmt.Errorf("request info-refs: %w", err)
return nil, err
}
defer res.Body.Close()
return data, nil
}
// readInfoRefsResponse validates and reads an /info/refs response: it checks
// the HTTP status and advertisement content-type, applies any redirect to the
// endpoint, and reads the body under a size cap. The caller closes res.Body.
func (c *HTTPConn) readInfoRefsResponse(res *http.Response, service string) ([]byte, error) {
if err := httpError(res); err != nil {
return nil, err
}
10 unmodified lines
}
if c.FollowInfoRefsRedirect && res.Request != nil && res.Request.URL != nil {
final := res.Request.URL
if final.Host != c.EndpointURL.Host || final.Scheme != c.EndpointURL.Scheme {
c.EndpointURL.Scheme = final.Scheme
c.EndpointURL.Host = final.Host
current := c.requestURL()
if final.Host != current.Host || final.Scheme != current.Scheme {
// Don't mutate EndpointURL — record the resolved endpoint in
// a separate field so the user-typed URL stays available for
// display/logging/telemetry. Path/userinfo carry over from
// EndpointURL (same assumption as before: the redirect target
// serves the same repo path).
resolved := *c.EndpointURL
resolved.Scheme = final.Scheme
resolved.Host = final.Host
c.resolvedEndpoint = &resolved
}
}
// Bound the read to prevent unbounded memory allocation (issue #9).
49 unmodified lines
// Caller must close the returned ReadCloser.
//
// The body is sent as-is — streaming readers produce a chunked request.
//
// On a 401 we consult the credential helper and retry, mirroring git's
// own behaviour for servers that allow anonymous /info/refs but gate the
// actual upload-pack/receive-pack POST behind auth. Retry is only possible
// when body is an io.Seeker (so we can rewind it); callers that pass a raw
// non-seekable Reader will see the 401 surface as-is.
func (c *HTTPConn) PostRPCStreamBody(ctx context.Context, service string, body io.Reader, v2 bool, phase string) (io.ReadCloser, error) {
reqURL := fmt.Sprintf("%s/%s", c.EndpointURL.String(), service)
res, err := c.doPostRPCRequest(ctx, service, body, v2, phase, c.Auth, nil)
if err != nil {
return nil, err
}
if seeker, ok := body.(io.Seeker); ok {
res, err = c.tryHelperRetry(ctx, res, func(auth AuthMethod, target *url.URL) (*http.Response, error) {
if _, seekErr := seeker.Seek(0, io.SeekStart); seekErr != nil {
return nil, fmt.Errorf("rewind RPC body for credential-helper retry: %w", seekErr)
}
return c.doPostRPCRequest(ctx, service, body, v2, phase, auth, target)
})
if err != nil {
return nil, err
}
}
httpErr := httpError(res)
// Settle helper credentials on the validated status: approve on a 2xx,
// reject on 401/403. For the POST path the HTTP status is the whole
// success signal — there's no advertisement body to validate further.
c.resolvePendingHelperCreds(ctx, res, httpErr == nil)
if httpErr != nil {
_ = res.Body.Close()
return nil, httpErr
}
return res.Body, nil
}
// doPostRPCRequest issues a single POST to /<service>. Caller closes res.Body.
//
// target is an optional override URL: when non-nil, the request is sent
// verbatim to that URL instead of building one from c.EndpointURL. See
// doInfoRefsRequest for why — same redirect-strip avoidance.
func (c *HTTPConn) doPostRPCRequest(ctx context.Context, service string, body io.Reader, v2 bool, phase string, auth AuthMethod, target *url.URL) (*http.Response, error) {
var reqURL string
if target != nil {
reqURL = target.String()
} else {
reqURL = fmt.Sprintf("%s/%s", c.requestURL().String(), service)
}
ctx = withHTTPTrace(ctx, "POST "+service)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, body)
7 unmodified lines
if v2 {
req.Header.Set("Git-Protocol", GitProtocolV2)
}
ApplyAuth(req, c.Auth)
ApplyAuth(req, auth)
if httpTraceEnabled() {
dumpOutgoingRequest(req, "POST "+service)
3 unmodified lines
if err != nil {
return nil, fmt.Errorf("post RPC: %w", err)
}
if err := httpError(res); err != nil {
_ = res.Body.Close()
return nil, err
}
return res.Body, nil
return res, nil
}
// ApplyAuth applies the given auth method to an HTTP request. Errors from
6 unmodified lines
}
_ = auth.Authorizer(req) //nolint:errcheck // BasicAuth and TokenAuth never error; future authorizers should surface 401s instead
}
// EnsureAuthForService tentatively attaches helper credentials before a
// non-rewindable request body is committed. It's a no-op when no helper
// is configured, when Auth is already set, or when the helper has no
// credentials to offer.
//
// Used from push.go (and other streaming-body POST paths) where the body
// is built from a live upstream stream (e.g. io.MultiReader over a pack
// reader) and can't be replayed on a mid-stream 401.
//
// The flow is:
// 1. Probe with a POST to /<service> using the smart-HTTP flush packet
// "0000" as body — a valid no-op (zero ref updates, zero pack data)
// by spec. We probe with POST rather than GET because the auth layer
// may only gate the POST handler; a GET probe would slip past on
// servers that 404/405 GET while requiring auth on POST.
// 2. If the probe gets 401, ask the helper for credentials keyed on the
// actually-challenged host (which may differ from c.EndpointURL after a
// cross-host redirect). Keying on the post-redirect host matters: that's
// where the user stored their creds, and that's the key we'll later
// Approve/Reject against.
// 3. Attach the credentials tentatively. The next real operation calls
// resolvePendingHelperCreds, which Approves them only once that
// operation fully succeeds or Rejects them on 401/403 — helper state
// only changes based on the actual outcome, never on the probe response
// alone.
// 4. If the challenge came from a cross-host redirect, rewrite
// c.EndpointURL's scheme/host to the challenger so the real op skips the
// redirect (which Go's http.Client would otherwise follow with the
// Authorization header stripped, turning every push into a fresh 401).
//
// If the probe doesn't 401 (200, 404, 405, etc.) we don't attach; the
// server either accepts anonymous POSTs here or returns ambiguously,
// and either way attaching unvalidated credentials could leak them.
func (c *HTTPConn) EnsureAuthForService(ctx context.Context, service string) {
if c.Auth != nil || c.CredentialHelper == nil {
return
}
res, err := c.doServiceProbe(ctx, service)
if err != nil {
return
}
defer res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
return
}
challengeURL := challengeURLFor(c.EndpointURL, res)
if c.InsecureSkipTLSVerify && challengeURL.Host != c.requestURL().Host {
// See tryHelperRetry: with TLS verification off we can't tell a
// real challenger from a MITM, so we won't attach helper creds
// after a cross-host probe redirect. The real op will surface
// the 401 and the user can resolve it.
return
}
user, pass, ok, lookupErr := c.CredentialHelper.Lookup(ctx, challengeURL)
if lookupErr != nil || !ok {
return
}
c.Auth = &transporthttp.BasicAuth{Username: user, Password: pass}
c.pendingHelperCreds = &helperCreds{user: user, pass: pass, url: challengeURL}
c.adoptChallengeHost(challengeURL)
}
// resolvePendingHelperCreds settles credentials that were attached tentatively
// — either by EnsureAuthForService's probe or by a tryHelperRetry that got a
// 2xx — based on the fully-validated outcome of a real operation. Called from
// RequestInfoRefs and PostRPCStreamBody. No-op if nothing is pending.
//
// success reports whether the operation actually succeeded (a 2xx whose body
// also passed any service-specific validation), as opposed to merely returning
// a 2xx status. We approve only on success, so a misleading 2xx — e.g. an
// /info/refs response with the wrong content-type or an oversized body — can't
// persist credentials for an operation the caller still reports as failed.
func (c *HTTPConn) resolvePendingHelperCreds(ctx context.Context, res *http.Response, success bool) {
if c.pendingHelperCreds == nil || c.CredentialHelper == nil {
return
}
creds := c.pendingHelperCreds
switch {
case success:
c.pendingHelperCreds = nil
c.CredentialHelper.Approve(ctx, creds.url, creds.user, creds.pass)
case res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden:
c.pendingHelperCreds = nil
c.Auth = nil
c.CredentialHelper.Reject(ctx, creds.url, creds.user, creds.pass)
}
// Otherwise (e.g. a 2xx with a malformed/oversized body): leave the creds
// pending and c.Auth in place. We must not approve credentials for an
// operation that failed validation, but a non-auth failure isn't proof
// they're bad either. The conn is short-lived (one sync), so leftover
// pending state at end of life is harmless.
}
// flushPacket is the smart-HTTP pkt-line "flush" marker. A request body
// containing only a flush packet is a valid no-op for both upload-pack
// (no wants/haves) and receive-pack (no ref updates, no pack data).
var flushPacket = []byte("0000")
func (c *HTTPConn) doServiceProbe(ctx context.Context, service string) (*http.Response, error) {
reqURL := fmt.Sprintf("%s/%s", c.requestURL().String(), service)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(flushPacket))
if err != nil {
return nil, fmt.Errorf("create auth-probe request: %w", err)
}
req.Header.Set("Content-Type", fmt.Sprintf("application/x-%s-request", service))
req.Header.Set("Accept", fmt.Sprintf("application/x-%s-result", service))
req.Header.Set("User-Agent", capability.DefaultAgent())
req.Header.Set(StatsPhaseHeader, service+" auth-probe")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("auth-probe request: %w", err)
}
return res, nil
}
// tryHelperRetry handles the 401 → lookup → retry → approve/reject lifecycle
// when a CredentialHelper is configured and no explicit Auth was set up front
// (explicit auth must surface its own failures rather than be quietly papered
// over). retry attempts the same request with helper-supplied credentials,
// targeted at the URL that actually returned the 401 (which may differ from
// c.EndpointURL if Go's http.Client followed a cross-host redirect).
//
// On a 2xx retry the credentials are stored on c.Auth (so follow-up calls on
// the same connection reuse them) and recorded as pending — the caller then
// approves them via resolvePendingHelperCreds once the response passes full
// validation, never on the 2xx status alone. If the challenge was on a host
// different from c.EndpointURL we also rewrite c.EndpointURL's scheme/host to
// the challenger so subsequent ops on this conn don't redirect again (Go's
// http.Client strips Authorization on cross-host redirects, which would
// otherwise turn every follow-up into a fresh 401 → Reject of valid creds).
//
// On retry failure (401, 403, or transport error) the helper is told to
// reject the credentials immediately so a stale stored token self-heals on
// the next run.
//
// Caller is responsible for closing the returned response body.
func (c *HTTPConn) tryHelperRetry(ctx context.Context, res *http.Response, retry func(AuthMethod, *url.URL) (*http.Response, error)) (*http.Response, error) {
if res.StatusCode != http.StatusUnauthorized || c.Auth != nil || c.CredentialHelper == nil {
return res, nil
}
challengeURL := challengeURLFor(c.EndpointURL, res)
if c.InsecureSkipTLSVerify && challengeURL.Host != c.requestURL().Host {
// Refuse to hand helper-stored credentials to a redirect target we
// can't authenticate. With TLS verification off, the post-redirect
// host could be anyone presenting a self-signed cert for the host
// our Lookup key would hand creds to. Let the 401 surface so the
// user fixes their setup (don't combine SkipTLSVerify with a
// credential helper on a redirecting endpoint).
return res, nil
}
user, pass, ok, lookupErr := c.CredentialHelper.Lookup(ctx, challengeURL)
if lookupErr != nil {
_ = res.Body.Close()
return nil, fmt.Errorf("look up credentials: %w", lookupErr)
}
if !ok {
return res, nil
}
// Capture the actually-challenged URL — what http.Client landed on after
// any redirects, including its path/query — so the retry hits it directly
// instead of replaying through c.EndpointURL and getting Authorization
// stripped on the cross-host hop.
retryTarget := res.Request.URL
_ = res.Body.Close()
retryAuth := &transporthttp.BasicAuth{Username: user, Password: pass}
res, err := retry(retryAuth, retryTarget)
if err != nil {
c.CredentialHelper.Reject(ctx, challengeURL, user, pass)
return nil, err
}
switch {
case res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden:
// 403 included because some token services (e.g. Cloudflare)
// surface "Invalid or expired token" as 403 rather than 401.
c.CredentialHelper.Reject(ctx, challengeURL, user, pass)
case res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusMultipleChoices:
// Attach tentatively and defer approval to resolvePendingHelperCreds,
// which runs after the caller validates the response body — a 2xx
// status alone isn't proof the operation succeeded.
c.Auth = retryAuth
c.pendingHelperCreds = &helperCreds{user: user, pass: pass, url: challengeURL}
c.adoptChallengeHost(challengeURL)
}
return res, nil
}
// adoptChallengeHost records the host that just successfully authenticated
// as this conn's resolved endpoint, so subsequent ops on the same conn go
// there directly instead of replaying through c.EndpointURL — which would
// redirect again and have its Authorization header stripped on the cross-
// host hop, turning every follow-up into a fresh 401.
//
// Gated on FollowInfoRefsRedirect: the user explicitly opting into redirect-
// following is the trigger for the conn's effective endpoint changing. With
// the flag off the immediate retry still hits the challenger directly (so
// the current op succeeds and creds get Approved on the right key), but the
// next op stays pointed at the user-typed URL. EndpointURL itself is never
// mutated; we set resolvedEndpoint instead, leaving EndpointURL as user
// input for display/logging/telemetry to read.
//
// Path/userinfo are copied from EndpointURL — same assumption
// FollowInfoRefsRedirect's /info/refs block makes about the redirect target
// serving the same repo path.
func (c *HTTPConn) adoptChallengeHost(challengeURL *url.URL) {
if !c.FollowInfoRefsRedirect || challengeURL == nil {
return
}
current := c.requestURL()
if challengeURL.Host == current.Host && challengeURL.Scheme == current.Scheme {
return
}
resolved := *c.EndpointURL
resolved.Scheme = challengeURL.Scheme
resolved.Host = challengeURL.Host
c.resolvedEndpoint = &resolved
}
// challengeURLFor returns the URL key used to query the credential helper
// for an auth challenge. After a 3xx the actually-challenged host is in
// res.Request.URL, which may differ from c.EndpointURL — using the wrong
// one would query (and possibly approve/reject) credentials under the
// wrong helper key. The original repo path is preserved so the key still
// matches what the user configured.
//
// We deliberately key on orig.Path rather than res.Request.URL.Path so
// credentials the user stored against the URL they typed remain findable
// when a redirect rewrites paths (e.g. github.com/owner/repo redirected
// to cdn.example/mirror/owner/repo). Trade-off: for path-aware helpers
// (credential.useHttpPath=true) the Approve/Reject key may be less
// precise than the actual challenge URL. Not a credential leak — creds
// only ever reach hosts the user already trusted enough to store them
// against — just a helper-audit-trail imprecision.
func challengeURLFor(orig *url.URL, res *http.Response) *url.URL {
if res == nil || res.Request == nil || res.Request.URL == nil {
return orig
}
final := res.Request.URL
if final.Host == orig.Host && final.Scheme == orig.Scheme {
return orig
}
out := *orig
out.Scheme = final.Scheme
out.Host = final.Host
return &out
}
// doInfoRefsRequest issues a single /info/refs GET. Caller closes res.Body.
//
// target is an optional override URL: when non-nil, the request is sent
// verbatim to that URL instead of building one from c.EndpointURL. Used by
// the credential-helper retry path to hit a redirected challenge host
// directly, skipping the redirect that would otherwise cause Go's
// http.Client to strip the Authorization header on the cross-host hop.
func (c *HTTPConn) doInfoRefsRequest(ctx context.Context, service, gitProtocol string, auth AuthMethod, target *url.URL) (*http.Response, error) {
var reqURL string
if target != nil {
reqURL = target.String()
} else {
reqURL = fmt.Sprintf("%s/info/refs?service=%s", c.requestURL().String(), service)
}
ctx = withHTTPTrace(ctx, "GET "+service+"/info/refs")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create info-refs request: %w", err)
}
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", capability.DefaultAgent())
req.Header.Set(StatsPhaseHeader, service+" info-refs")
if gitProtocol != "" {
req.Header.Set("Git-Protocol", gitProtocol)
}
ApplyAuth(req, auth)
res, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request info-refs: %w", err)
}
return res, nil
}
Minternal/gitproto/smarthttp.go+436/-23
2 unmodified lines
3
4
5
6
7
8
9
6 unmodified lines
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
307 unmodified lines
338
339
340
331
332
341
342
343
344
345
346
347
348
349
350
351
352
353
354
94 unmodified lines
449
450
451
452
453
454
455
456
457
24 unmodified lines
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
2 unmodified lines
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
6 unmodified lines
transporthttp "github.com/go-git/go-git/v6/plumbing/transport/http"
)
// testOriginHost is the hostname newTestConn points its endpoint at — the
// origin / user-configured host. testReplicaHost is the hostname tests use
// to model a cross-host redirect target landing somewhere else. Used by
// the redirect/cross-host fixtures across several tests.
const (
testOriginHost = "example.com"
testReplicaHost = "replica.example"
)
func TestNewHTTPConn(t *testing.T) {
ep, err := transport.ParseURL("https://github.com/user/repo.git")
if err != nil {
307 unmodified lines
}
nodeURL := strings.TrimPrefix(node.URL, "http://")
if conn.EndpointURL.Host != nodeURL {
t.Errorf("EndpointURL.Host = %q, want %q (endpoint should follow the 307)", conn.EndpointURL.Host, nodeURL)
entryHost := strings.TrimPrefix(entry.URL, "http://")
// EndpointURL stays as the user-typed input — display/logging keep
// the original. The resolved endpoint records where requests now go.
if conn.EndpointURL.Host != entryHost {
t.Errorf("EndpointURL.Host = %q, want %q (user-typed URL must not be mutated)", conn.EndpointURL.Host, entryHost)
}
if conn.resolvedEndpoint == nil {
t.Fatalf("expected resolvedEndpoint to be set after a cross-host /info/refs redirect")
}
if conn.resolvedEndpoint.Host != nodeURL {
t.Errorf("resolvedEndpoint.Host = %q, want %q (resolved endpoint should follow the 307)", conn.resolvedEndpoint.Host, nodeURL)
}
}
94 unmodified lines
if conn.EndpointURL.Host != entryHost {
t.Errorf("EndpointURL.Host = %q, want %q (endpoint should be unchanged by default)", conn.EndpointURL.Host, entryHost)
}
if conn.resolvedEndpoint != nil {
t.Errorf("resolvedEndpoint must remain nil when FollowInfoRefsRedirect is off, got %v", conn.resolvedEndpoint)
}
}
func TestHTTPErrorBoundsBodyRead(t *testing.T) {
24 unmodified lines
return f(req)
}
func newAdvertisementResponse(req *http.Request) *http.Response {
res := &http.Response{
StatusCode: http.StatusOK,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("0000")),
}
res.Header.Set("Content-Type", "application/x-git-upload-pack-advertisement")
return res
}
func newUnauthorizedResponse(req *http.Request) *http.Response {
res := &http.Response{
StatusCode: http.StatusUnauthorized,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("authentication required")),
}
res.Header.Set("WWW-Authenticate", `Basic realm="git"`)
return res
}
func newTestConn(_ *testing.T, rt http.RoundTripper) *HTTPConn {
return NewHTTPConn(
&url.URL{Scheme: "https", Host: testOriginHost, Path: "/repo.git"},
"src", nil, rt,
)
}
func TestRequestInfoRefs_AnonymousSucceedsWithoutConsultingHelper(t *testing.T) {
helper := &fakeCredentialHelper{user: "x", pass: "y", ok: true}
var authHeaders []string
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, req.Header.Get("Authorization"))
return newAdvertisementResponse(req), nil
}))
conn.CredentialHelper = helper
if _, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", ""); err != nil {
t.Fatalf("RequestInfoRefs: %v", err)
}
if helper.count("lookup") != 0 {
t.Errorf("expected 0 helper lookups on anonymous success, got %d", helper.count("lookup"))
}
if len(authHeaders) != 1 || authHeaders[0] != "" {
t.Errorf("expected exactly one anonymous request, got headers %v", authHeaders)
}
}
func TestRequestInfoRefs_OnUnauthorizedRetriesWithHelperCredentials(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
var authHeaders []string
attempts := 0
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, req.Header.Get("Authorization"))
attempts++
if attempts == 1 {
return newUnauthorizedResponse(req), nil
}
return newAdvertisementResponse(req), nil
}))
conn.CredentialHelper = helper
if _, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", ""); err != nil {
t.Fatalf("RequestInfoRefs: %v", err)
}
if got := helper.count("lookup"); got != 1 {
t.Errorf("expected 1 helper lookup, got %d", got)
}
if got := helper.count("approve"); got != 1 {
t.Errorf("expected 1 approve call, got %d", got)
}
if got := helper.count("reject"); got != 0 {
t.Errorf("expected 0 reject calls, got %d", got)
}
if last := helper.last("approve"); last == nil || last.user != "alice" || last.pass != "s3cret" {
t.Errorf("approve called with wrong creds: %+v", last)
}
if len(authHeaders) != 2 {
t.Fatalf("expected 2 requests (anon then auth retry), got %d: %v", len(authHeaders), authHeaders)
}
if authHeaders[0] != "" {
t.Errorf("first request should be anonymous, got Authorization=%q", authHeaders[0])
}
if !strings.HasPrefix(authHeaders[1], "Basic ") {
t.Errorf("retry should have Basic auth header, got %q", authHeaders[1])
}
if conn.Auth == nil {
t.Error("expected conn.Auth to be stored after successful auth retry")
}
}
func TestRequestInfoRefs_OnUnauthorizedReusesStoredAuthOnNextCall(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
var authHeaders []string
attempts := 0
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, req.Header.Get("Authorization"))
attempts++
if attempts == 1 {
return newUnauthorizedResponse(req), nil
}
if req.Method == http.MethodGet {
return newAdvertisementResponse(req), nil
}
res := &http.Response{
StatusCode: http.StatusOK,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
res.Header.Set("Content-Type", "application/x-git-upload-pack-result")
return res, nil
}))
conn.CredentialHelper = helper
if _, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", ""); err != nil {
t.Fatalf("RequestInfoRefs: %v", err)
}
if _, err := PostRPC(context.Background(), conn, "git-upload-pack", []byte("0000"), false, "phase"); err != nil {
t.Fatalf("PostRPC: %v", err)
}
if got := helper.count("lookup"); got != 1 {
t.Errorf("expected only 1 helper lookup across both requests, got %d", got)
}
if len(authHeaders) != 3 {
t.Fatalf("expected 3 requests, got %d: %v", len(authHeaders), authHeaders)
}
if authHeaders[1] == "" || authHeaders[2] == "" {
t.Errorf("retry GET and follow-up POST should both carry auth: %v", authHeaders)
}
}
func TestRequestInfoRefs_OnUnauthorizedSurfaces401WithoutHelper(t *testing.T) {
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return newUnauthorizedResponse(req), nil
}))
_, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", "")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("expected 401 error, got %v", err)
}
}
func TestRequestInfoRefs_OnUnauthorizedSurfaces401WhenHelperHasNoCredentials(t *testing.T) {
helper := &fakeCredentialHelper{ok: false}
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return newUnauthorizedResponse(req), nil
}))
conn.CredentialHelper = helper
_, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", "")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("expected 401 error, got %v", err)
}
if got := helper.count("lookup"); got != 1 {
t.Errorf("expected 1 lookup attempt, got %d", got)
}
if got := helper.count("approve") + helper.count("reject"); got != 0 {
t.Errorf("expected no approve/reject when helper had no creds, got %d", got)
}
}
func TestRequestInfoRefs_OnUnauthorizedRetryStill401CallsReject(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "bad", ok: true}
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return newUnauthorizedResponse(req), nil
}))
conn.CredentialHelper = helper
_, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", "")
if err == nil {
t.Fatal("expected error, got nil")
}
if got := helper.count("reject"); got != 1 {
t.Errorf("expected 1 reject call, got %d", got)
}
if got := helper.count("approve"); got != 0 {
t.Errorf("expected 0 approve calls, got %d", got)
}
if last := helper.last("reject"); last == nil || last.user != "alice" || last.pass != "bad" {
t.Errorf("reject called with wrong creds: %+v", last)
}
}
// TestRequestInfoRefs_OnUnauthorizedRetry2xxBadContentTypeDoesNotApprove
// guards the deferred-approval contract: a retry that authenticates (HTTP 200)
// but returns a non-advertisement body must surface a content-type error and
// must NOT persist credentials in the helper — the operation didn't actually
// succeed, so a misleading 2xx shouldn't approve the creds. It's also not an
// auth failure, so the helper isn't told to reject them either.
func TestRequestInfoRefs_OnUnauthorizedRetry2xxBadContentTypeDoesNotApprove(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
attempts := 0
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
attempts++
if attempts == 1 {
return newUnauthorizedResponse(req), nil
}
res := &http.Response{
StatusCode: http.StatusOK,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("<html>login</html>")),
}
res.Header.Set("Content-Type", "text/html")
return res, nil
}))
conn.CredentialHelper = helper
_, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", "")
if err == nil {
t.Fatal("expected content-type error after a 2xx retry with a non-advertisement body")
}
if !strings.Contains(err.Error(), "unexpected info/refs content-type") {
t.Fatalf("error = %v, want content-type error", err)
}
if got := helper.count("approve"); got != 0 {
t.Errorf("must not approve credentials for an operation that failed validation, got %d approve calls", got)
}
if got := helper.count("reject"); got != 0 {
t.Errorf("a 2xx-but-invalid response is not an auth failure, got %d reject calls", got)
}
}
// TestRequestInfoRefs_OnUnauthorizedRetry403CallsReject documents that some
// token services (notably Cloudflare) return 403 "Invalid or expired token"
// instead of 401 when stored credentials have expired.
func TestRequestInfoRefs_OnUnauthorizedRetry403CallsReject(t *testing.T) {
helper := &fakeCredentialHelper{user: "user", pass: "expired-token", ok: true}
attempts := 0
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
attempts++
if attempts == 1 {
return newUnauthorizedResponse(req), nil
}
res := &http.Response{
StatusCode: http.StatusForbidden,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("Invalid or expired token")),
}
return res, nil
}))
conn.CredentialHelper = helper
_, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", "")
if err == nil {
t.Fatal("expected error, got nil")
}
if got := helper.count("reject"); got != 1 {
t.Errorf("expected 1 reject call on retry 403, got %d", got)
}
if got := helper.count("approve"); got != 0 {
t.Errorf("expected 0 approve calls, got %d", got)
}
if last := helper.last("reject"); last == nil || last.user != "user" || last.pass != "expired-token" {
t.Errorf("reject called with wrong creds: %+v", last)
}
}
// TestRequestInfoRefs_DoesNotRetryWhenConnAlreadyAuthenticated: explicit auth
// must win over the helper, so users debugging a bad token they passed see
// the real error rather than a silent fallback.
func TestRequestInfoRefs_DoesNotRetryWhenConnAlreadyAuthenticated(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
initialAuth := &transporthttp.BasicAuth{Username: "explicit", Password: "tok"}
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return newUnauthorizedResponse(req), nil
}))
conn.Auth = initialAuth
conn.CredentialHelper = helper
_, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", "")
if err == nil {
t.Fatal("expected error, got nil")
}
if got := helper.count("lookup"); got != 0 {
t.Errorf("expected 0 helper lookups when auth was preconfigured, got %d", got)
}
}
// TestRequestInfoRefs_OnUnauthorizedAfterRedirectKeysHelperOnFinalHost
// covers the case where /info/refs is 307'd to a different host and the
// replica returns 401: the helper must be queried for the host that
// actually challenged us, not the original endpoint.
func TestRequestInfoRefs_OnUnauthorizedAfterRedirectKeysHelperOnFinalHost(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
attempts := 0
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
attempts++
if attempts == 1 {
// Simulate that Go's HTTP client followed a 3xx to replica.example
// before getting the 401 — res.Request.URL is the post-redirect URL.
res := newUnauthorizedResponse(req)
res.Request = &http.Request{URL: &url.URL{
Scheme: "https", Host: testReplicaHost, Path: "/repo.git/info/refs",
}}
return res, nil
}
return newAdvertisementResponse(req), nil
}))
conn.CredentialHelper = helper
if _, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", ""); err != nil {
t.Fatalf("RequestInfoRefs: %v", err)
}
lookup := helper.last("lookup")
if lookup == nil {
t.Fatal("expected helper lookup")
}
if !strings.Contains(lookup.url, testReplicaHost) {
t.Errorf("helper Lookup keyed on %q, want replica.example", lookup.url)
}
if strings.Contains(lookup.url, "/info/refs") {
t.Errorf("helper Lookup URL should carry the repo path, not /info/refs: %q", lookup.url)
}
approve := helper.last("approve")
if approve == nil || !strings.Contains(approve.url, testReplicaHost) {
t.Errorf("helper Approve keyed on wrong URL: %+v", approve)
}
}
// TestRequestInfoRefs_OnUnauthorizedAfterCrossHostRedirectRetriesAgainstChallenger
// is the production-impact regression: when origin redirects cross-host to a
// challenger (origin → replica), Go's http.Client strips the Authorization
// header on the cross-host hop. Without the fix, the retry replays through
// the origin URL, gets stripped again, and we Reject the user's valid
// replica.example credentials — locking them out on the next sync.
//
// With the fix the retry goes directly to the actually-challenged URL with
// auth intact, succeeds, and we Approve the right key. We also rewrite
// c.EndpointURL so follow-up ops on the same conn skip the redirect too
// (otherwise they'd 401 the same way and the pending creds would still get
// rejected during resolvePendingHelperCreds).
func TestRequestInfoRefs_OnUnauthorizedAfterCrossHostRedirectRetriesAgainstChallenger(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
type call struct{ host, auth string }
var calls []call
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
calls = append(calls, call{host: req.URL.Host, auth: req.Header.Get("Authorization")})
switch req.URL.Host {
case testOriginHost:
// Cross-host 307. Go's http.Client follows and strips Authorization
// (which is moot here — the first attempt is anonymous).
res := &http.Response{
StatusCode: http.StatusTemporaryRedirect,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
res.Header.Set("Location", "https://replica.example/repo.git/info/refs?service=git-upload-pack")
return res, nil
case testReplicaHost:
if req.Header.Get("Authorization") == "" {
return newUnauthorizedResponse(req), nil
}
return newAdvertisementResponse(req), nil
}
return nil, fmt.Errorf("unexpected host %s", req.URL.Host)
}))
conn.CredentialHelper = helper
// FollowInfoRefsRedirect is the user's opt-in to redirect-following.
// Without it, the immediate retry still hits the challenger directly
// (so the current op succeeds), but the conn's resolved endpoint stays
// unset and the next op surfaces a 401 — see the separate
// _WithoutFollowFlag test below.
conn.FollowInfoRefsRedirect = true
if _, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", ""); err != nil {
t.Fatalf("RequestInfoRefs: %v", err)
}
// Sequence:
// 1. example.com anonymous → 307
// 2. replica.example anonymous → 401 (Go followed the redirect)
// 3. replica.example with Basic → advertisement (retry direct, no replay through origin)
if len(calls) != 3 {
t.Fatalf("expected 3 RoundTripper calls (origin redirect, anonymous replica, authed replica), got %d: %+v", len(calls), calls)
}
if calls[2].host != testReplicaHost {
t.Errorf("retry must go to replica.example directly, got host %q (regression: retry replayed through origin and got stripped)", calls[2].host)
}
if !strings.HasPrefix(calls[2].auth, "Basic ") {
t.Errorf("retry must carry Basic auth header, got %q", calls[2].auth)
}
if got := helper.count("approve"); got != 1 {
t.Fatalf("expected exactly 1 approve after a successful retry, got %d (regression: valid creds got Reject'd)", got)
}
if approve := helper.last("approve"); approve == nil || !strings.Contains(approve.url, testReplicaHost) {
t.Errorf("approve must key on replica.example, got %+v", approve)
}
if got := helper.count("reject"); got != 0 {
t.Errorf("expected 0 rejects after a successful auth retry, got %d", got)
}
// EndpointURL is the user's typed input and must not be mutated — but
// resolvedEndpoint records where follow-up ops on this conn should go,
// so the next request hits replica.example directly with auth (rather
// than redirecting through origin and getting stripped again).
if conn.EndpointURL.Host != testOriginHost {
t.Errorf("EndpointURL must not be mutated (stays as user input %q), got %q", testOriginHost, conn.EndpointURL.Host)
}
if conn.resolvedEndpoint == nil {
t.Fatal("expected resolvedEndpoint to be set after a cross-host auth resolution")
}
if conn.resolvedEndpoint.Host != testReplicaHost {
t.Errorf("resolvedEndpoint.Host = %q, want %q", conn.resolvedEndpoint.Host, testReplicaHost)
}
}
// TestRequestInfoRefs_CrossHostRetryWithoutFollowFlagSucceedsButDoesNotAdopt
// covers the gated half of the cross-host fix: when the user has not set
// FollowInfoRefsRedirect, the immediate retry still hits the challenger
// directly (so the current op succeeds and the helper Approves valid creds
// on the right key — the production bug stays fixed), but the conn's
// effective endpoint is NOT silently moved. Follow-up ops on the same conn
// stay pointed at the user-typed URL, and the user can set the flag if they
// want full redirect-following.
func TestRequestInfoRefs_CrossHostRetryWithoutFollowFlagSucceedsButDoesNotAdopt(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
type call struct{ host, auth string }
var calls []call
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
calls = append(calls, call{host: req.URL.Host, auth: req.Header.Get("Authorization")})
switch req.URL.Host {
case testOriginHost:
res := &http.Response{
StatusCode: http.StatusTemporaryRedirect,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
res.Header.Set("Location", "https://"+testReplicaHost+"/repo.git/info/refs?service=git-upload-pack")
return res, nil
case testReplicaHost:
if req.Header.Get("Authorization") == "" {
return newUnauthorizedResponse(req), nil
}
return newAdvertisementResponse(req), nil
}
return nil, fmt.Errorf("unexpected host %s", req.URL.Host)
}))
conn.CredentialHelper = helper
// FollowInfoRefsRedirect intentionally OFF.
if _, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", ""); err != nil {
t.Fatalf("RequestInfoRefs: %v", err)
}
// Immediate retry still goes to the challenger with auth — the cross-host
// fix doesn't depend on the flag for the current op, only for adoption.
if calls[2].host != testReplicaHost || !strings.HasPrefix(calls[2].auth, "Basic ") {
t.Errorf("retry must still hit replica with auth: %+v", calls[2])
}
if got := helper.count("approve"); got != 1 {
t.Errorf("expected 1 approve (the immediate op succeeded), got %d", got)
}
// But the conn must NOT have adopted the challenger: the user said no to
// following redirects, so follow-up ops stay pointed at the original.
if conn.EndpointURL.Host != testOriginHost {
t.Errorf("EndpointURL must remain on user-typed host, got %q", conn.EndpointURL.Host)
}
if conn.resolvedEndpoint != nil {
t.Errorf("resolvedEndpoint must remain nil without FollowInfoRefsRedirect, got %v", conn.resolvedEndpoint)
}
}
// TestRequestInfoRefs_CrossHostRedirectWithSkipTLSVerifyRefusesToSendCreds
// covers the safety gate: when TLS verification is off, a cross-host
// redirect's destination can't be authenticated (any MITM presenting a
// self-signed cert would do), so we MUST NOT hand the helper's stored
// credentials over. We bail out of the retry and let the 401 surface;
// the user has to either turn TLS verification back on or stop relying
// on a redirecting endpoint.
func TestRequestInfoRefs_CrossHostRedirectWithSkipTLSVerifyRefusesToSendCreds(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
type call struct{ host, auth string }
var calls []call
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
calls = append(calls, call{host: req.URL.Host, auth: req.Header.Get("Authorization")})
switch req.URL.Host {
case testOriginHost:
res := &http.Response{
StatusCode: http.StatusTemporaryRedirect,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
res.Header.Set("Location", "https://"+testReplicaHost+"/repo.git/info/refs?service=git-upload-pack")
return res, nil
case testReplicaHost:
return newUnauthorizedResponse(req), nil
}
return nil, fmt.Errorf("unexpected host %s", req.URL.Host)
}))
conn.CredentialHelper = helper
conn.InsecureSkipTLSVerify = true
_, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", "")
if err == nil {
t.Fatal("expected 401 to surface when TLS verification is off and the challenge crosses hosts")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("expected a 401 error, got %v", err)
}
// No helper traffic at all: we refused before Lookup.
if got := helper.count("lookup"); got != 0 {
t.Errorf("expected 0 lookups when TLS-off blocks the cross-host retry, got %d", got)
}
if got := helper.count("approve") + helper.count("reject"); got != 0 {
t.Errorf("expected 0 approve/reject calls (nothing was attached), got %d", got)
}
// And no authenticated request ever reached the challenger.
for i, c := range calls {
if c.auth != "" {
t.Errorf("call %d to %s carried an Authorization header — creds leaked despite the gate: %+v", i, c.host, c)
}
}
// Neither endpoint field changes: EndpointURL stays as user input and
// resolvedEndpoint stays nil (no adoption when TLS-off blocks the
// cross-host retry).
if conn.EndpointURL.Host != testOriginHost {
t.Errorf("EndpointURL must stay on the user-configured host when TLS-off blocks adoption, got %q", conn.EndpointURL.Host)
}
if conn.resolvedEndpoint != nil {
t.Errorf("resolvedEndpoint must remain nil when the cross-host retry is blocked, got %v", conn.resolvedEndpoint)
}
}
// TestRequestInfoRefs_SameHostUnauthorizedWithSkipTLSVerifyStillRetries:
// the gate is targeted, not blanket. A 401 from the *same* host the user
// pointed at carries no MITM-via-redirect risk — the user already accepted
// that host when they configured the sync — so the helper retry still
// runs as normal even with TLS verification off.
func TestRequestInfoRefs_SameHostUnauthorizedWithSkipTLSVerifyStillRetries(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
attempts := 0
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
attempts++
if attempts == 1 {
return newUnauthorizedResponse(req), nil
}
return newAdvertisementResponse(req), nil
}))
conn.CredentialHelper = helper
conn.InsecureSkipTLSVerify = true
if _, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", ""); err != nil {
t.Fatalf("RequestInfoRefs: %v", err)
}
if got := helper.count("approve"); got != 1 {
t.Errorf("expected 1 approve on a successful same-host retry even with TLS-off, got %d", got)
}
}
func TestPostRPC_OnUnauthorizedRetriesWithHelperCredentials(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
var authHeaders []string
attempts := 0
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, req.Header.Get("Authorization"))
attempts++
if attempts == 1 {
return newUnauthorizedResponse(req), nil
}
res := &http.Response{
StatusCode: http.StatusOK,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
res.Header.Set("Content-Type", "application/x-git-upload-pack-result")
return res, nil
}))
conn.CredentialHelper = helper
if _, err := PostRPC(context.Background(), conn, "git-upload-pack", []byte("0000"), false, "phase"); err != nil {
t.Fatalf("PostRPC: %v", err)
}
if got := helper.count("lookup"); got != 1 {
t.Errorf("expected 1 helper lookup, got %d", got)
}
if got := helper.count("approve"); got != 1 {
t.Errorf("expected 1 approve call, got %d", got)
}
if len(authHeaders) != 2 {
t.Fatalf("expected 2 requests (anon then auth), got %d: %v", len(authHeaders), authHeaders)
}
if authHeaders[0] != "" {
t.Errorf("first POST should be anonymous, got %q", authHeaders[0])
}
if !strings.HasPrefix(authHeaders[1], "Basic ") {
t.Errorf("retry POST should have Basic auth, got %q", authHeaders[1])
}
if conn.Auth == nil {
t.Error("expected conn.Auth to be stored after successful POST retry")
}
}
func TestPostRPC_OnUnauthorizedSurfaces401WithoutHelper(t *testing.T) {
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return newUnauthorizedResponse(req), nil
}))
_, err := PostRPC(context.Background(), conn, "git-upload-pack", []byte("0000"), false, "phase")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("expected 401 error, got %v", err)
}
}
func TestPostRPC_OnUnauthorizedRetryStill401CallsReject(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "bad", ok: true}
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return newUnauthorizedResponse(req), nil
}))
conn.CredentialHelper = helper
_, err := PostRPC(context.Background(), conn, "git-upload-pack", []byte("0000"), false, "phase")
if err == nil {
t.Fatal("expected error, got nil")
}
if got := helper.count("reject"); got != 1 {
t.Errorf("expected 1 reject call, got %d", got)
}
if got := helper.count("approve"); got != 0 {
t.Errorf("expected 0 approve calls, got %d", got)
}
}
// TestEnsureAuthForService_TentativelyAttachesHelperCredsOnAnonymous401:
// the anonymous probe gets 401, the helper supplies credentials, and they
// are attached to the conn for the upcoming streaming POST — but NOT
// approved yet. Approval is deferred until the real operation validates
// them; otherwise a server that returns 405 to GET /git-receive-pack
// without checking auth would bless stale creds.
func TestEnsureAuthForService_TentativelyAttachesHelperCredsOnAnonymous401(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
probes := 0
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
probes++
return newUnauthorizedResponse(req), nil
}))
conn.CredentialHelper = helper
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if conn.Auth == nil {
t.Fatal("expected conn.Auth to be tentatively set from helper")
}
if got := helper.count("lookup"); got != 1 {
t.Errorf("expected 1 helper lookup, got %d", got)
}
if got := helper.count("approve"); got != 0 {
t.Errorf("probe must not Approve — let the real operation validate. got %d", got)
}
if got := helper.count("reject"); got != 0 {
t.Errorf("probe must not Reject either. got %d", got)
}
if probes != 1 {
t.Errorf("expected exactly 1 probe (no retry), got %d", probes)
}
}
func TestEnsureAuthForService_NoHelperIsNoOp(t *testing.T) {
called := false
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
called = true
return newAdvertisementResponse(req), nil
}))
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if called {
t.Error("expected EnsureAuthForService to be a no-op without a helper")
}
if conn.Auth != nil {
t.Error("expected conn.Auth to remain nil")
}
}
func TestEnsureAuthForService_AnonymousServiceLeavesAuthNil(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusMethodNotAllowed,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}, nil
}))
conn.CredentialHelper = helper
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if conn.Auth != nil {
t.Error("expected conn.Auth to remain nil when probe gets non-401")
}
if got := helper.count("approve"); got != 0 {
t.Errorf("must not Approve when probe didn't 401, got %d", got)
}
}
// TestEnsureAuthForService_HelperWithNoCredentialsLeavesAuthNil: the probe
// runs unconditionally so that a cross-host redirect can reveal the actual
// challenge host before the helper is asked (Lookup against the wrong host
// would miss the user's stored creds). When the helper still has nothing
// for the post-probe host, we leave c.Auth nil and don't attach anything —
// the surrounding op will surface a clean 401.
func TestEnsureAuthForService_HelperWithNoCredentialsLeavesAuthNil(t *testing.T) {
helper := &fakeCredentialHelper{ok: false}
probeCalls := 0
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
probeCalls++
return newUnauthorizedResponse(req), nil
}))
conn.CredentialHelper = helper
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if probeCalls != 1 {
t.Errorf("expected exactly one probe POST, got %d", probeCalls)
}
if got := helper.count("lookup"); got != 1 {
t.Errorf("expected one helper lookup after the probe 401, got %d", got)
}
if conn.Auth != nil {
t.Error("expected conn.Auth to remain nil when the helper has no creds")
}
if got := helper.count("approve") + helper.count("reject"); got != 0 {
t.Errorf("must not approve/reject when no creds were attached, got %d", got)
}
}
// TestEnsureAuthForService_CrossHostProbeLooksUpAndAdoptsChallenger:
// when the probe follows a cross-host redirect to a 401, the helper must
// be queried for the *challenge* host (not the origin the user named) — that
// is where the user's creds are stored and the key Approve/Reject will later
// settle against. c.EndpointURL must also adopt the challenger so the real
// op hits it directly with auth instead of bouncing through the redirect,
// which Go's http.Client would follow with the Authorization header stripped.
func TestEnsureAuthForService_CrossHostProbeLooksUpAndAdoptsChallenger(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case testOriginHost:
res := &http.Response{
StatusCode: http.StatusTemporaryRedirect,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
res.Header.Set("Location", "https://replica.example/repo.git/git-receive-pack")
return res, nil
case testReplicaHost:
return newUnauthorizedResponse(req), nil
}
return nil, fmt.Errorf("unexpected host %s", req.URL.Host)
}))
conn.CredentialHelper = helper
// User opts in to redirect-following — required for the cross-host
// endpoint adoption that lets follow-up ops skip the redirect.
conn.FollowInfoRefsRedirect = true
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if conn.Auth == nil {
t.Fatal("expected helper creds to attach after a cross-host probe 401")
}
if got := helper.count("lookup"); got != 1 {
t.Errorf("expected exactly 1 lookup, got %d", got)
}
if last := helper.last("lookup"); last == nil || !strings.Contains(last.url, testReplicaHost) {
t.Errorf("lookup must key on replica.example (the actually-challenged host), got %q", last.url)
}
if conn.EndpointURL.Host != testOriginHost {
t.Errorf("EndpointURL must not be mutated (stays as user input %q), got %q", testOriginHost, conn.EndpointURL.Host)
}
if conn.resolvedEndpoint == nil {
t.Fatal("expected resolvedEndpoint to be set after a cross-host probe 401")
}
if conn.resolvedEndpoint.Host != testReplicaHost {
t.Errorf("resolvedEndpoint.Host = %q, want %q", conn.resolvedEndpoint.Host, testReplicaHost)
}
}
// TestEnsureAuthForService_CrossHostProbeWithSkipTLSVerifyDoesNotAttach
// is the EnsureAuthForService variant of the SkipTLSVerify safety gate:
// the probe is allowed to follow the redirect (it's anonymous, no creds
// at risk), but once we see the cross-host 401 we refuse to query the
// helper or attach anything. The next real op surfaces the 401 cleanly.
func TestEnsureAuthForService_CrossHostProbeWithSkipTLSVerifyDoesNotAttach(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case testOriginHost:
res := &http.Response{
StatusCode: http.StatusTemporaryRedirect,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
res.Header.Set("Location", "https://"+testReplicaHost+"/repo.git/git-receive-pack")
return res, nil
case testReplicaHost:
return newUnauthorizedResponse(req), nil
}
return nil, fmt.Errorf("unexpected host %s", req.URL.Host)
}))
conn.CredentialHelper = helper
conn.InsecureSkipTLSVerify = true
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if conn.Auth != nil {
t.Error("expected no auth to attach after a cross-host probe with TLS verification off")
}
if got := helper.count("lookup"); got != 0 {
t.Errorf("expected 0 lookups (gate runs before Lookup), got %d", got)
}
if conn.EndpointURL.Host != testOriginHost {
t.Errorf("EndpointURL must not be adopted to %q with TLS-off; got %q", testReplicaHost, conn.EndpointURL.Host)
}
if conn.resolvedEndpoint != nil {
t.Errorf("resolvedEndpoint must remain nil when TLS-off blocks the cross-host probe, got %v", conn.resolvedEndpoint)
}
}
// TestEnsureAuthForService_RealPostApprovesTentativeCreds covers the
// production push shape: probe attaches helper creds tentatively; the
// real POST succeeds, which is the actual proof creds are valid. Only
// then do we Approve in the helper.
func TestEnsureAuthForService_RealPostApprovesTentativeCreds(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
var authHeaders []string
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, req.Header.Get("Authorization"))
if req.Header.Get("Authorization") == "" {
return newUnauthorizedResponse(req), nil
}
res := &http.Response{
StatusCode: http.StatusOK, Request: req, Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
res.Header.Set("Content-Type", "application/x-git-receive-pack-result")
return res, nil
}))
conn.CredentialHelper = helper
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
body := io.MultiReader(strings.NewReader("0000"), strings.NewReader(""))
reader, err := PostRPCStreamBody(context.Background(), conn, "git-receive-pack", body, false, "phase")
if err != nil {
t.Fatalf("PostRPCStreamBody: %v", err)
}
_ = reader.Close()
// 2 requests: probe-anon (401) + POST-authed (200). No second probe.
if len(authHeaders) != 2 {
t.Fatalf("expected 2 requests, got %d: %v", len(authHeaders), authHeaders)
}
if authHeaders[0] != "" {
t.Errorf("probe should be anonymous, got %q", authHeaders[0])
}
if !strings.HasPrefix(authHeaders[1], "Basic ") {
t.Errorf("real POST should carry the helper creds, got %q", authHeaders[1])
}
if got := helper.count("approve"); got != 1 {
t.Errorf("expected 1 Approve after the real POST succeeded, got %d", got)
}
}
// TestEnsureAuthForService_RealPostRejectsTentativeCreds: helper supplied
// stale credentials, the real POST 401s, which is the definitive signal
// to reject them and clear c.Auth.
func TestEnsureAuthForService_RealPostRejectsTentativeCreds(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "stale", ok: true}
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
// Every request 401s (helper creds are stale).
return newUnauthorizedResponse(req), nil
}))
conn.CredentialHelper = helper
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if conn.Auth == nil {
t.Fatal("expected conn.Auth to be tentatively set after probe 401")
}
body := io.MultiReader(strings.NewReader("0000"), strings.NewReader(""))
_, err := PostRPCStreamBody(context.Background(), conn, "git-receive-pack", body, false, "phase")
if err == nil {
t.Fatal("expected error from POST with stale creds")
}
if got := helper.count("reject"); got != 1 {
t.Errorf("expected 1 Reject after POST 401, got %d", got)
}
if got := helper.count("approve"); got != 0 {
t.Errorf("expected 0 Approve calls, got %d", got)
}
if conn.Auth != nil {
t.Error("expected conn.Auth to be cleared after rejecting bad creds")
}
}
// TestEnsureAuthForService_ProbesWithPOSTAndFlushPacketBody verifies the
// probe uses the same HTTP method as the real operation (POST), with a
// minimal "0000" flush packet body — a valid no-op receive-pack push by
// the smart-HTTP spec. Probing with POST is essential: servers that
// gate only the POST handler (not GET) would otherwise slip past us.
func TestEnsureAuthForService_ProbesWithPOSTAndFlushPacketBody(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
var probeMethod string
var probeBody []byte
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
probeMethod = req.Method
if req.Body != nil {
b, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("read probe body: %v", err)
}
probeBody = b
}
return newUnauthorizedResponse(req), nil
}))
conn.CredentialHelper = helper
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if probeMethod != http.MethodPost {
t.Errorf("expected probe method POST, got %q", probeMethod)
}
if string(probeBody) != "0000" {
t.Errorf("expected probe body to be the flush packet '0000', got %q", probeBody)
}
}
// TestEnsureAuthForService_DetectsAuthGatedPostEvenWhenGetIsAnonymous:
// the gap a GET-based probe would miss — server returns 404 to GET
// (the receive-pack endpoint isn't a GET resource) but 401 to POST.
// A POST probe correctly detects the auth requirement.
func TestEnsureAuthForService_DetectsAuthGatedPostEvenWhenGetIsAnonymous(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
var methods []string
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
methods = append(methods, req.Method)
if req.Method == http.MethodGet {
return &http.Response{
StatusCode: http.StatusNotFound, Request: req, Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}, nil
}
// POST: server requires auth.
if req.Header.Get("Authorization") == "" {
return newUnauthorizedResponse(req), nil
}
return &http.Response{
StatusCode: http.StatusOK, Request: req, Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}, nil
}))
conn.CredentialHelper = helper
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if conn.Auth == nil {
t.Fatal("expected probe to detect POST auth requirement and attach helper creds")
}
if got := helper.count("lookup"); got != 1 {
t.Errorf("expected 1 helper lookup, got %d", got)
}
for _, m := range methods {
if m == http.MethodGet {
t.Errorf("probe should not use GET — server may serve GET differently than POST")
}
}
}
// TestEnsureAuthForService_405ProbeWithCredsDoesNotPoisonHelper is the
// specific regression: previously, a 405 to the authenticated probe was
// interpreted as "creds accepted" and Approve was called — even though
// the server may have rejected the method before reading Authorization.
// The new contract never approves from a probe response at all.
func TestEnsureAuthForService_405ProbeWithCredsDoesNotPoisonHelper(t *testing.T) {
helper := &fakeCredentialHelper{user: "alice", pass: "stale", ok: true}
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("Authorization") == "" {
return newUnauthorizedResponse(req), nil
}
// Server returns 405 without checking auth.
return &http.Response{
StatusCode: http.StatusMethodNotAllowed,
Request: req, Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}, nil
}))
conn.CredentialHelper = helper
conn.EnsureAuthForService(context.Background(), "git-receive-pack")
if got := helper.count("approve"); got != 0 {
t.Errorf("405 probe response must not Approve stale creds (got %d Approve calls)", got)
}
}
type credCall struct {
op string // "lookup", "approve", "reject"
user string
pass string
url string // the *url.URL passed to the helper, stringified
}
// fakeCredentialHelper is a test CredentialHelper. Set user/pass/ok/err to
// configure Lookup; inspect calls (via count/last) to assert lifecycle.
type fakeCredentialHelper struct {
user, pass string
ok bool
err error
calls []credCall
}
func (h *fakeCredentialHelper) Lookup(_ context.Context, ep *url.URL) (string, string, bool, error) {
h.calls = append(h.calls, credCall{op: "lookup", url: ep.String()})
return h.user, h.pass, h.ok, h.err
}
func (h *fakeCredentialHelper) Approve(_ context.Context, ep *url.URL, user, pass string) {
h.calls = append(h.calls, credCall{op: "approve", user: user, pass: pass, url: ep.String()})
}
func (h *fakeCredentialHelper) Reject(_ context.Context, ep *url.URL, user, pass string) {
h.calls = append(h.calls, credCall{op: "reject", user: user, pass: pass, url: ep.String()})
}
func (h *fakeCredentialHelper) count(op string) int {
n := 0
for _, c := range h.calls {
if c.op == op {
n++
}
}
return n
}
func (h *fakeCredentialHelper) last(op string) *credCall {
for i := len(h.calls) - 1; i >= 0; i-- {
if h.calls[i].op == op {
return &h.calls[i]
}
}
return nil
}
type roundTripReader struct {
remaining int
}
Minternal/gitproto/smarthttp_test.go+1097/-2
23 unmodified lines
24
25
26
27
28
29
30
27
28
29
30
31
32
33
84 unmodified lines
118
119
120
121
122
123
124
121
122
123
124
125
126
127
23 unmodified lines
t.Fatalf("new endpoint: %v", err)
}
originalFill := auth.GitCredentialFillCommand
t.Cleanup(func() { auth.GitCredentialFillCommand = originalFill })
auth.GitCredentialFillCommand = func(_ context.Context, input string) ([]byte, error) {
t.Fatalf("unexpected git credential fill call with input %q", input)
originalCred := auth.GitCredentialCommand
t.Cleanup(func() { auth.GitCredentialCommand = originalCred })
auth.GitCredentialCommand = func(_ context.Context, op auth.CredentialOp, input string) ([]byte, error) {
t.Fatalf("unexpected git credential %s call with input %q", op, input)
return nil, nil
}
84 unmodified lines
t.Fatalf("write token: %v", err)
}
Minternal/syncer/auth\_test.go+8/-8
1699 unmodified lines
1700 1701 1702 1703 1703 1704 1705 1705 1706 1707 1707 1708 1709 1710 3 unmodified lines
1714 1715 1716 1717 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728
1699 unmodified lines
defer sourceServer.Close() defer targetServer.Close()
originalFill := auth.GitCredentialFillCommand originalCred := auth.GitCredentialCommand t.Cleanup(func() { auth.GitCredentialFillCommand = originalFill auth.GitCredentialCommand = originalCred }) auth.GitCredentialFillCommand = func(_ context.Context, input string) ([]byte, error) { auth.GitCredentialCommand = func(_ context.Context, op auth.CredentialOp, input string) ([]byte, error) { if !strings.Contains(input, "protocol=http\n") { t.Fatalf("expected protocol in credential input, got %q", input) } 3 unmodified lines
if !strings.Contains(input, "path=repo.git\n") { t.Fatalf("expected repo path in credential input, got %q", input) } return []byte("username=" + username + "\npassword=" + password + "\n\n"), nil switch op { case auth.CredentialOpFill: return []byte("username=" + username + "\npassword=" + password + "\n\n"), nil case auth.CredentialOpApprove, auth.CredentialOpReject: return nil, nil default: t.Fatalf("unexpected git credential op %q", op) return nil, nil } }
result, err := Run(context.Background(), Config{
Minternal/syncer/integration\_test.go+12/-4
376 unmodified lines
377 378 379 380 381 382 383 384 385 386
376 unmodified lines
client := instrumentHTTPClient(httpClient, raw.SkipTLSVerify, label, stats) conn := gitproto.NewHTTPConnWithClient(ep, label, authMethod, client) conn.FollowInfoRefsRedirect = raw.FollowInfoRefsRedirect conn.InsecureSkipTLSVerify = raw.SkipTLSVerify if authMethod == nil { conn.CredentialHelper = auth.GitCredentialHelper{} } return conn, nil }
Minternal/syncer/syncer.go+4