Artifact 588b72911604070ac0a7436a2240b73f2502cec9511d3d5ecfa1504787149b6c:
- File
internal/sync/redirect.go
— part of check-in
[9d35c01fb3]
at
2026-10-13 00:12:32
on branch main
— fix(sync): log in through the x-f-l-c cookie and accept cleartext passwords (#304) (#362)
fossil's client moves its login card from the body into the x-f-l-c cookie once the server's server-version passes xfer_xflc_check (2.27, 2025-07-27 11:05 or later), and fossil's check_login, when a stored password is not 40 characters, tries the signature again against the shared secret that cleartext password derives (sections 6.4, 6.5). The client always logged in in the body, and the server checked only the stored value.
A request marks itself LoginCookieOK once the gate passes; Message.SplitLoginCookie moves its login card out of the body, and the HTTP transports (sync.HTTPTransport and the public NewHTTPTransport through the adapter) send it as the cookie, on every redirect too. The nonce is unchanged: it already covers what follows the login line, which in cookie form is the whole body. Transports that cannot set a cookie send the login in the body as before. The server side of the cookie landed with the server-version exchange.
HandleOpts gains LoginCookie, so an embedder serving HandleSyncWithOpts over its own HTTP handler can pass the cookie through; XferHandler and ServeHTTP read it themselves. (user: [email protected], size: 3716) [annotate] [blame] [check-ins using]
package sync import ( "bytes" "context" "errors" "fmt" "io" "net/http" "net/url" "strings" "github.com/danmestas/go-libfossil/internal/xfer" ) // maxRedirects is fossil's MAX_REDIRECTS (src/xfer.c). http_exchange // decrements it on each redirect and fails when it reaches zero, so 19 // redirects are followed and the twentieth is fatal (draft §4). const maxRedirects = 20 // ErrRedirectDowngrade reports a redirect from HTTPS to plain HTTP. Following // it would resend the request, login card included, in cleartext. var ErrRedirectDowngrade = errors.New("sync: cannot redirect from HTTPS to HTTP") // ErrRedirectLimit reports a twentieth redirect. var ErrRedirectLimit = errors.New("sync: redirect limit exceeded") // PostXfer POSTs body to target and follows redirects as fossil's // http_exchange does (src/http.c), not as net/http does. net/http turns a 301 // or 302 into a GET without the body, follows a downgrade from HTTPS to HTTP, // and stops after 10. Fossil re-POSTs the same body for 301, 302, 307 and // 308, strips a trailing /xfer from the new location, refuses a downgrade, // and fails on the twentieth redirect. // // The client's own redirect policy is replaced for these requests only, so // a caller's client is used as given but cannot loosen these rules. A redirect // without a Location is returned as is, for the caller to report its status. // // loginCookie, when not empty, goes in the x-f-l-c cookie of every request, // as fossil rebuilds a cookie login for each redirect target (§4, §6.5). func PostXfer(ctx context.Context, client *http.Client, target, contentType, loginCookie string, body []byte) (*http.Response, error) { if client == nil { panic("sync.PostXfer: client must not be nil") } c := *client c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } for left := maxRedirects; ; { req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", contentType) if loginCookie != "" { req.Header.Set("Cookie", xfer.LoginCookieName+"="+loginCookie) } resp, err := c.Do(req) if err != nil { return nil, err } loc := resp.Header.Get("Location") if !isFollowedRedirect(resp.StatusCode) || loc == "" { return resp, nil } // Drain a little so the connection can be reused, then let it go. io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) resp.Body.Close() left-- if left == 0 { return nil, ErrRedirectLimit } if target, err = redirectTarget(target, loc); err != nil { return nil, err } } } func isFollowedRedirect(code int) bool { switch code { case http.StatusMovedPermanently, http.StatusFound, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: return true } return false } // redirectTarget resolves loc against from and applies fossil's rules to it. // Fossil strips one trailing /xfer, though draft §4 says "repeatedly": its // loop truncates the string at the match, after which the substring it // compares is four characters long and can never equal "/xfer" again. func redirectTarget(from, loc string) (string, error) { base, err := url.Parse(from) if err != nil { return "", fmt.Errorf("sync: redirect from %q: %w", from, err) } next, err := base.Parse(loc) if err != nil { return "", fmt.Errorf("sync: malformed redirect %q: %w", loc, err) } if next.Scheme != "http" && next.Scheme != "https" { return "", fmt.Errorf("sync: cannot redirect to %q", loc) } if base.Scheme == "https" && next.Scheme == "http" { return "", ErrRedirectDowngrade } return strings.TrimSuffix(next.String(), "/xfer"), nil }