probe for auth before streaming pack pushes · Entire

probe for auth before streaming pack pushes

19e7582→main· Soph·1mo ago·3 files·+206 added/-0 removed

The previous commit's io.Seeker gate didn't help the production push path. internal/gitproto/push.go builds the receive-pack body as io.MultiReader(header, packData), where packData is the live pipe from upload-pack. That body satisfies neither io.Seeker nor any other replayable contract, so PostRPCStreamBody's on-the-fly 401 retry was a no-op for real pushes.

Fix: add HTTPConn.EnsureAuthForService(ctx, service) and call it from sendReceivePack before the body is constructed. It issues an anonymous GET to /; if the server 401s, the helper is consulted, retried with credentials, and (on a non-401/403 response) c.Auth is stored so the streaming POST that follows is pre-authenticated.

The probe handles 2xx, 404, 405 etc. all as "credentials accepted" — any non-401/403 means the server didn't reject the creds we sent. 405 is the typical response for GET /git-receive-pack on smart-HTTP servers, so this is the common shape.

Limitation made explicit in the doc comment: servers that allow GET anonymously but only 401 on POST will slip past this probe. For those, callers must pass explicit credentials (--target-token).

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

Sessions

f6b8e1586278View transcript

?\ can you take a look at https://github.com/entireio/git-sync/issues/63Claude Code·Opus 4.7[1m]·1 step

Changes

3

151 unmodified lines

152
153
154
155
156
157
158
159
160
161
162
163
164
165

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 up front instead — for HTTP conns that have
    // a CredentialHelper configured but no Auth resolved yet.
    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+8

461 unmodified lines

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

461 unmodified lines

_ = auth.Authorizer(req) //nolint:errcheck // BasicAuth and TokenAuth never error; future authorizers should surface 401s instead
}

// EnsureAuthForService resolves credentials via the helper before a non-
// rewindable request body is committed. It's a no-op when no helper is
// configured or Auth is already set.
//
// Use this 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 so can't be replayed on a mid-stream 401. The probe
// is a GET to /<service>; servers that gate the service on auth return
// 401 here, letting us resolve credentials before the real POST starts.
//
// Probe failures are non-fatal — the subsequent real request will
// surface any actual problem. Servers that allow GET anonymously but
// 401 on POST will still slip past this probe; for those, callers must
// pass explicit credentials.
func (c *HTTPConn) EnsureAuthForService(ctx context.Context, service string) {
    if c.Auth != nil || c.CredentialHelper == nil {
        return
    }
    res, err := c.doServiceProbe(ctx, service, nil)
    if err != nil {
        return
    }
    if res.StatusCode != http.StatusUnauthorized {
        _ = res.Body.Close()
        return
    }
    challengeURL := challengeURLFor(c.EndpointURL, res)
    user, pass, ok, lookupErr := c.CredentialHelper.Lookup(ctx, challengeURL)
    _ = res.Body.Close()
    if lookupErr != nil || !ok {
        return
    }
    retryAuth := &transporthttp.BasicAuth{Username: user, Password: pass}
    res, err = c.doServiceProbe(ctx, service, retryAuth)
    if err != nil {
        c.CredentialHelper.Reject(ctx, challengeURL, user, pass)
        return
    }
    defer res.Body.Close()
    switch res.StatusCode {
    case http.StatusUnauthorized, http.StatusForbidden:
        c.CredentialHelper.Reject(ctx, challengeURL, user, pass)
    default:
        // Anything else with credentials attached — 2xx, 405 Method Not
        // Allowed (the common shape for GET /git-receive-pack), 404, etc.
        // — means the server didn't reject the credentials. Trust them
        // for the upcoming real POST; if they actually fail there, the
        // caller surfaces that 401/403 directly.
        c.Auth = retryAuth
        c.CredentialHelper.Approve(ctx, challengeURL, user, pass)
    }
}

func (c *HTTPConn) doServiceProbe(ctx context.Context, service string, auth AuthMethod) (*http.Response, error) {
    reqURL := fmt.Sprintf("%s/%s", c.EndpointURL.String(), service)
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
    if err != nil {
        return nil, fmt.Errorf("create auth-probe request: %w", err)
    }
    req.Header.Set("User-Agent", capability.DefaultAgent())
    req.Header.Set(StatsPhaseHeader, service+" auth-probe")
    ApplyAuth(req, auth)
    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