Skip to content

Commit 6da1abe

Browse files
Sophclaude
andcommitted
gitproto: defer credential approval until response passes validation
Both credential-approval paths previously called CredentialHelper.Approve as soon as the retried request returned 2xx — before RequestInfoRefs validated the advertisement content-type and applied its size cap. A misleading 2xx (e.g. a captive-portal HTML body) would persist the credentials in the helper even though RequestInfoRefs still returned an error. Unify around the existing pendingHelperCreds slot: - tryHelperRetry no longer calls Approve directly on a 2xx retry. It sets c.Auth (so follow-ups on the same conn reuse the creds) and records the creds as pending, leaving approval to the caller. Rejection on 401/403/transport-error stays immediate — a definite auth failure is unambiguous. - resolvePendingHelperCreds gains a success bool. Approve only fires when the operation actually succeeded (status 2xx *and* body validation passed); Reject still fires on 401/403; everything else leaves the creds pending and c.Auth in place. - RequestInfoRefs now reads/validates first (httpError, content-type, redirect, size cap — extracted into readInfoRefsResponse) and only then settles helper state with success=(err==nil). - PostRPCStreamBody settles with success=(httpError==nil), which is the full success signal for the POST path. Adds TestRequestInfoRefs_OnUnauthorizedRetry2xxBadContentTypeDoesNotApprove to pin the new contract: a 2xx retry with a non-advertisement body must error out and must NOT call Approve (or Reject — it isn't an auth failure). Addresses Cursor Bugbot review comment on PR #65: "Premature credential helper approve". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 724c2df commit 6da1abe

2 files changed

Lines changed: 96 additions & 21 deletions

File tree

internal/gitproto/smarthttp.go

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,25 @@ func (c *HTTPConn) RequestInfoRefs(ctx context.Context, service string, gitProto
202202
if err != nil {
203203
return nil, err
204204
}
205-
c.resolvePendingHelperCreds(ctx, res)
206-
207205
defer res.Body.Close()
206+
207+
data, err := c.readInfoRefsResponse(res, service)
208+
// Settle helper credentials on the fully-validated outcome: approve only
209+
// once the advertisement parsed and read within limits, reject on 401/403.
210+
// Running this after validation stops a misleading 2xx (wrong content-type
211+
// or oversized body) from persisting credentials for an operation that
212+
// ultimately failed.
213+
c.resolvePendingHelperCreds(ctx, res, err == nil)
214+
if err != nil {
215+
return nil, err
216+
}
217+
return data, nil
218+
}
219+
220+
// readInfoRefsResponse validates and reads an /info/refs response: it checks
221+
// the HTTP status and advertisement content-type, applies any redirect to the
222+
// endpoint, and reads the body under a size cap. The caller closes res.Body.
223+
func (c *HTTPConn) readInfoRefsResponse(res *http.Response, service string) ([]byte, error) {
208224
if err := httpError(res); err != nil {
209225
return nil, err
210226
}
@@ -299,10 +315,14 @@ func (c *HTTPConn) PostRPCStreamBody(ctx context.Context, service string, body i
299315
return nil, err
300316
}
301317
}
302-
c.resolvePendingHelperCreds(ctx, res)
303-
if err := httpError(res); err != nil {
318+
httpErr := httpError(res)
319+
// Settle helper credentials on the validated status: approve on a 2xx,
320+
// reject on 401/403. For the POST path the HTTP status is the whole
321+
// success signal — there's no advertisement body to validate further.
322+
c.resolvePendingHelperCreds(ctx, res, httpErr == nil)
323+
if httpErr != nil {
304324
_ = res.Body.Close()
305-
return nil, err
325+
return nil, httpErr
306326
}
307327
return res.Body, nil
308328
}
@@ -360,9 +380,10 @@ func ApplyAuth(req *http.Request, auth AuthMethod) {
360380
// servers that 404/405 GET while requiring auth on POST.
361381
// 3. If the probe gets 401, attach the helper credentials tentatively.
362382
// The next real operation (PostRPCStreamBody or RequestInfoRefs)
363-
// calls resolvePendingHelperCreds, which Approves them on 2xx or
364-
// Rejects them on 401/403 — helper state only changes based on the
365-
// actual outcome, never on the probe response alone.
383+
// calls resolvePendingHelperCreds, which Approves them only once that
384+
// operation fully succeeds or Rejects them on 401/403 — helper state
385+
// only changes based on the actual outcome, never on the probe response
386+
// alone.
366387
//
367388
// If the probe doesn't 401 (200, 404, 405, etc.) we don't attach; the
368389
// server either accepts anonymous POSTs here or returns ambiguously,
@@ -388,26 +409,35 @@ func (c *HTTPConn) EnsureAuthForService(ctx context.Context, service string) {
388409
c.pendingHelperCreds = &helperCreds{user: user, pass: pass, url: challengeURL}
389410
}
390411

391-
// resolvePendingHelperCreds settles credentials that EnsureAuthForService
392-
// attached tentatively, based on the outcome of a real operation. Called
393-
// from RequestInfoRefs and PostRPCStreamBody. No-op if nothing pending.
394-
func (c *HTTPConn) resolvePendingHelperCreds(ctx context.Context, res *http.Response) {
412+
// resolvePendingHelperCreds settles credentials that were attached tentatively
413+
// — either by EnsureAuthForService's probe or by a tryHelperRetry that got a
414+
// 2xx — based on the fully-validated outcome of a real operation. Called from
415+
// RequestInfoRefs and PostRPCStreamBody. No-op if nothing is pending.
416+
//
417+
// success reports whether the operation actually succeeded (a 2xx whose body
418+
// also passed any service-specific validation), as opposed to merely returning
419+
// a 2xx status. We approve only on success, so a misleading 2xx — e.g. an
420+
// /info/refs response with the wrong content-type or an oversized body — can't
421+
// persist credentials for an operation the caller still reports as failed.
422+
func (c *HTTPConn) resolvePendingHelperCreds(ctx context.Context, res *http.Response, success bool) {
395423
if c.pendingHelperCreds == nil || c.CredentialHelper == nil {
396424
return
397425
}
398426
creds := c.pendingHelperCreds
399427
switch {
400-
case res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusMultipleChoices:
428+
case success:
401429
c.pendingHelperCreds = nil
402430
c.CredentialHelper.Approve(ctx, creds.url, creds.user, creds.pass)
403431
case res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden:
404432
c.pendingHelperCreds = nil
405433
c.Auth = nil
406434
c.CredentialHelper.Reject(ctx, creds.url, creds.user, creds.pass)
407435
}
408-
// Other status: leave pending. A later op on this conn may resolve;
409-
// the conn is short-lived (one sync), so leftover pending state at
410-
// end of life is harmless.
436+
// Otherwise (e.g. a 2xx with a malformed/oversized body): leave the creds
437+
// pending and c.Auth in place. We must not approve credentials for an
438+
// operation that failed validation, but a non-auth failure isn't proof
439+
// they're bad either. The conn is short-lived (one sync), so leftover
440+
// pending state at end of life is harmless.
411441
}
412442

413443
// flushPacket is the smart-HTTP pkt-line "flush" marker. A request body
@@ -437,10 +467,12 @@ func (c *HTTPConn) doServiceProbe(ctx context.Context, service string) (*http.Re
437467
// (explicit auth must surface its own failures rather than be quietly papered
438468
// over). retry attempts the same request with helper-supplied credentials.
439469
//
440-
// On retry success the credentials are stored on c.Auth so follow-up calls
441-
// on the same connection reuse them. On retry failure (401, 403, or transport
442-
// error) the helper is told to reject the credentials so a stale stored token
443-
// self-heals on the next run.
470+
// On a 2xx retry the credentials are stored on c.Auth (so follow-up calls on
471+
// the same connection reuse them) and recorded as pending — the caller then
472+
// approves them via resolvePendingHelperCreds once the response passes full
473+
// validation, never on the 2xx status alone. On retry failure (401, 403, or
474+
// transport error) the helper is told to reject the credentials immediately so
475+
// a stale stored token self-heals on the next run.
444476
//
445477
// Caller is responsible for closing the returned response body.
446478
func (c *HTTPConn) tryHelperRetry(ctx context.Context, res *http.Response, retry func(AuthMethod) (*http.Response, error)) (*http.Response, error) {
@@ -469,8 +501,11 @@ func (c *HTTPConn) tryHelperRetry(ctx context.Context, res *http.Response, retry
469501
// surface "Invalid or expired token" as 403 rather than 401.
470502
c.CredentialHelper.Reject(ctx, challengeURL, user, pass)
471503
case res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusMultipleChoices:
504+
// Attach tentatively and defer approval to resolvePendingHelperCreds,
505+
// which runs after the caller validates the response body — a 2xx
506+
// status alone isn't proof the operation succeeded.
472507
c.Auth = retryAuth
473-
c.CredentialHelper.Approve(ctx, challengeURL, user, pass)
508+
c.pendingHelperCreds = &helperCreds{user: user, pass: pass, url: challengeURL}
474509
}
475510
return res, nil
476511
}

internal/gitproto/smarthttp_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,46 @@ func TestRequestInfoRefs_OnUnauthorizedRetryStill401CallsReject(t *testing.T) {
642642
}
643643
}
644644

645+
// TestRequestInfoRefs_OnUnauthorizedRetry2xxBadContentTypeDoesNotApprove
646+
// guards the deferred-approval contract: a retry that authenticates (HTTP 200)
647+
// but returns a non-advertisement body must surface a content-type error and
648+
// must NOT persist credentials in the helper — the operation didn't actually
649+
// succeed, so a misleading 2xx shouldn't approve the creds. It's also not an
650+
// auth failure, so the helper isn't told to reject them either.
651+
func TestRequestInfoRefs_OnUnauthorizedRetry2xxBadContentTypeDoesNotApprove(t *testing.T) {
652+
helper := &fakeCredentialHelper{user: "alice", pass: "s3cret", ok: true}
653+
attempts := 0
654+
conn := newTestConn(t, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
655+
attempts++
656+
if attempts == 1 {
657+
return newUnauthorizedResponse(req), nil
658+
}
659+
res := &http.Response{
660+
StatusCode: http.StatusOK,
661+
Request: req,
662+
Header: make(http.Header),
663+
Body: io.NopCloser(strings.NewReader("<html>login</html>")),
664+
}
665+
res.Header.Set("Content-Type", "text/html")
666+
return res, nil
667+
}))
668+
conn.CredentialHelper = helper
669+
670+
_, err := conn.RequestInfoRefs(context.Background(), "git-upload-pack", "")
671+
if err == nil {
672+
t.Fatal("expected content-type error after a 2xx retry with a non-advertisement body")
673+
}
674+
if !strings.Contains(err.Error(), "unexpected info/refs content-type") {
675+
t.Fatalf("error = %v, want content-type error", err)
676+
}
677+
if got := helper.count("approve"); got != 0 {
678+
t.Errorf("must not approve credentials for an operation that failed validation, got %d approve calls", got)
679+
}
680+
if got := helper.count("reject"); got != 0 {
681+
t.Errorf("a 2xx-but-invalid response is not an auth failure, got %d reject calls", got)
682+
}
683+
}
684+
645685
// TestRequestInfoRefs_OnUnauthorizedRetry403CallsReject documents that some
646686
// token services (notably Cloudflare) return 403 "Invalid or expired token"
647687
// instead of 401 when stored credentials have expired.

0 commit comments

Comments
 (0)