diff --git a/pkg/appstore/appstore_bag.go b/pkg/appstore/appstore_bag.go index 9d4d09de..d4c4b640 100644 --- a/pkg/appstore/appstore_bag.go +++ b/pkg/appstore/appstore_bag.go @@ -1,6 +1,7 @@ package appstore import ( + "errors" "fmt" gohttp "net/http" "net/url" @@ -111,18 +112,19 @@ func validateSAPConfig(config SAPConfig) error { } func validateAuthenticationEndpoint(endpoint string) error { - parsed, err := url.ParseRequestURI(endpoint) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" { - return fmt.Errorf("invalid authentication endpoint %q", endpoint) + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || + parsed.Fragment != "" || parsed.RawPath != "" || (parsed.Port() != "" && parsed.Port() != "443") { + return errors.New("invalid authentication endpoint") } host := strings.ToLower(parsed.Hostname()) if host != PrivateAppStoreAPIDomain && !strings.HasSuffix(host, "-buy.itunes.apple.com") { - return fmt.Errorf("unsupported authentication endpoint %q", endpoint) + return errors.New("unsupported authentication endpoint") } - if parsed.Path != PrivateAppStoreAPIPathAuth { - return fmt.Errorf("unsupported authentication endpoint %q", endpoint) + if parsed.Path != PrivateAppStoreAPIPathAuth && parsed.Path != PrivateAppStoreAPIPathAuth+"/" { + return errors.New("unsupported authentication endpoint") } return nil diff --git a/pkg/appstore/appstore_bag_test.go b/pkg/appstore/appstore_bag_test.go index b98e8c31..a00adc56 100644 --- a/pkg/appstore/appstore_bag_test.go +++ b/pkg/appstore/appstore_bag_test.go @@ -33,6 +33,21 @@ var _ = Describe("AppStore (Bag)", func() { ctrl.Finish() }) + DescribeTable("preserves supported authentication URLs from the bag", func(endpoint string) { + mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil) + result := validBagResult() + result.URLBag.AuthEndpoint = endpoint + mockBagClient.EXPECT().Send(gomock.Any()).Return(http.Result[bagResult]{StatusCode: gohttp.StatusOK, Data: result}, nil) + out, err := as.Bag(BagInput{}) + Expect(err).NotTo(HaveOccurred()) + Expect(out.AuthEndpoint).To(Equal(endpoint)) + Expect(out.SAPConfig.AuthEndpoint).To(Equal(endpoint)) + }, + Entry("bare path", testAuthEndpoint), + Entry("trailing slash", testAuthEndpoint+"/"), + Entry("pod URL with routing query", "https://p7-buy.itunes.apple.com"+PrivateAppStoreAPIPathAuth+"/?routing=opaque"), + ) + When("fails to read machine MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). diff --git a/pkg/appstore/appstore_login.go b/pkg/appstore/appstore_login.go index c89913ca..03f89dbb 100644 --- a/pkg/appstore/appstore_login.go +++ b/pkg/appstore/appstore_login.go @@ -5,13 +5,13 @@ import ( "errors" "fmt" gohttp "net/http" + "net/url" "strconv" "strings" "time" "unicode" "github.com/majd/ipatool/v2/pkg/http" - "github.com/majd/ipatool/v2/pkg/util" ) var ( @@ -20,6 +20,7 @@ var ( const ( maxAuthenticationRequestAttempts = 3 + maxAuthenticationRedirects = 4 authenticationRetryDelay = 10 * time.Second maxAuthenticationRetryDelay = 30 * time.Second ) @@ -139,25 +140,15 @@ type loginResult struct { } func (t *appstore) login(email, password, authCode, guid, endpoint string, signer ActionSigner) (Account, error) { - redirect := "" + request := t.loginRequest(email, password, authCode, guid, endpoint, 1, signer) + redirects := 0 var ( err error res http.Result[loginResult] ) - retry := true - - for attempt := 1; retry && attempt <= 4; attempt++ { - requestAttempt := attempt - if redirect != "" { - // The pod redirect is part of the same authentication attempt. Apple - // expects the original XML plist body, including its attempt value. - requestAttempt = 1 - } - - request := t.loginRequest(email, password, authCode, guid, endpoint, requestAttempt, signer) - request.URL, _ = util.IfEmpty(redirect, request.URL), "" + for attempt := 1; ; { res, err = t.sendAuthenticationRequest(request) if err != nil { @@ -166,20 +157,37 @@ func (t *appstore) login(email, password, authCode, guid, endpoint string, signe stage = "2FA verification" } - if redirect != "" { + if redirects > 0 { stage += " at Store pod" } return Account{}, fmt.Errorf("%s request failed: %w", stage, err) } - if retry, redirect, err = t.parseLoginResponse(&res, attempt, authCode); err != nil { + retry, redirect, err := t.parseLoginResponse(&res, attempt, authCode, request.URL) + if err != nil { return Account{}, err } - } - if retry { - return Account{}, NewErrorWithMetadata(errors.New("too many attempts"), res) + if !retry { + break + } + + if redirect != "" { + if redirects == maxAuthenticationRedirects { + return Account{}, authenticationRedirectError(errors.New("too many authentication redirects"), res.StatusCode, redirect) + } + + redirects++ + // Only the destination changes: redirects belong to the same login + // attempt and must retain its payload, including the attempt value. + request.URL = redirect + + continue + } + + attempt++ + request = t.loginRequest(email, password, authCode, guid, request.URL, attempt, signer) } sf, err := res.GetHeader(HTTPHeaderStoreFront) @@ -278,21 +286,29 @@ func retryableAuthenticationError(err error) (int, bool) { return status, retry } -func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (bool, string, error) { +func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode, endpoint string) (bool, string, error) { var ( retry bool redirect string err error ) - if res.StatusCode == gohttp.StatusFound { + if res.StatusCode >= gohttp.StatusMultipleChoices && res.StatusCode < gohttp.StatusBadRequest { if redirect, err = res.GetHeader("location"); err != nil { err = fmt.Errorf("failed to retrieve redirect location: %w", err) - } else if err = validateAuthenticationEndpoint(redirect); err != nil { - err = fmt.Errorf("invalid authentication redirect: %w", err) + } else if !http.IsAuthenticationRedirect(res.StatusCode) { + err = errors.New("unsupported authentication redirect status") } else { - retry = true + redirect, err = resolveAuthenticationRedirect(endpoint, redirect) + } + + if err != nil { + location, _ := res.GetHeader("location") + + return false, "", authenticationRedirectError(err, res.StatusCode, location) } + + retry = true } else if attempt == 1 && res.Data.FailureType == FailureTypeInvalidCredentials { retry = true } else if res.Data.FailureType == "" && res.Data.CustomerMessage == CustomerMessageBadLogin { @@ -316,6 +332,39 @@ func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int return retry, redirect, err } +func resolveAuthenticationRedirect(endpoint, location string) (string, error) { + base, err := url.Parse(endpoint) + if err != nil { + return "", errors.New("invalid authentication redirect base URL") + } + + reference, err := url.Parse(strings.TrimSpace(location)) + if err != nil || strings.TrimSpace(location) == "" { + return "", errors.New("invalid authentication redirect location") + } + + destination := base.ResolveReference(reference).String() + if err := validateAuthenticationEndpoint(destination); err != nil { + return "", fmt.Errorf("invalid authentication redirect: %w", err) + } + + return destination, nil +} + +func authenticationRedirectError(err error, status int, location string) error { + // Keep useful routing diagnostics without retaining URL credentials, query + // parameters, fragments, response bodies or arbitrary response headers. + destination := "invalid URL" + if parsed, parseErr := url.Parse(location); parseErr == nil { + destination = (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host, Path: parsed.Path}).String() + } + + return NewErrorWithMetadata(fmt.Errorf("%w (HTTP %d)", err, status), map[string]interface{}{ + "statusCode": status, + "destination": destination, + }) +} + func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string, attempt int, signer ActionSigner) http.Request { return http.Request{ Method: http.MethodPOST, diff --git a/pkg/appstore/appstore_login_test.go b/pkg/appstore/appstore_login_test.go index 40b83933..a41227ff 100644 --- a/pkg/appstore/appstore_login_test.go +++ b/pkg/appstore/appstore_login_test.go @@ -453,9 +453,12 @@ var _ = Describe("AppStore (Login)", func() { type stubActionSigner struct { closeCalls int closeErr error + signCalls int } -func (*stubActionSigner) Sign(data []byte) ([]byte, error) { +func (s *stubActionSigner) Sign(data []byte) ([]byte, error) { + s.signCalls++ + return append([]byte(nil), data...), nil } diff --git a/pkg/appstore/authentication_redirect_test.go b/pkg/appstore/authentication_redirect_test.go new file mode 100644 index 00000000..ac6feb13 --- /dev/null +++ b/pkg/appstore/authentication_redirect_test.go @@ -0,0 +1,208 @@ +package appstore + +import ( + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "time" + + apphttp "github.com/majd/ipatool/v2/pkg/http" + "github.com/majd/ipatool/v2/pkg/keychain" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + "howett.net/plist" +) + +var _ = Describe("Authentication redirects", func() { + DescribeTable("preserves a signed POST, cookies and 2FA after a credential retry", + func(status int, suffix string) { + ctrl := gomock.NewController(GinkgoT()) + jar, err := cookiejar.New(nil) + Expect(err).NotTo(HaveOccurred()) + client := apphttp.NewClient[loginResult](apphttp.Args{CookieJar: loginSessionJar{jar}, Authentication: true}) + adapter := apphttp.NewMockClient[loginResult](ctrl) + chain := keychain.NewMockKeychain(ctrl) + chain.EXPECT().Set("account", gomock.Any()).Return(nil) + sut := &appstore{loginClient: adapter, keychain: chain, authRetrySleep: func(time.Duration) { Fail("must not sleep") }} + podURL := "https://p7-buy.itunes.apple.com" + PrivateAppStoreAPIPathAuth + suffix + "?routing=opaque" + calls := 0 + var originalBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + calls++ + Expect(r.Method).To(Equal(http.MethodPost)) + body, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + signature, err := base64.StdEncoding.DecodeString(r.Header.Get(apphttp.HeaderAppleActionSignature)) + Expect(err).NotTo(HaveOccurred()) + Expect(signature).To(Equal(body)) + var payload map[string]string + _, err = plist.Unmarshal(body, &payload) + Expect(err).NotTo(HaveOccurred()) + Expect(payload["password"]).To(Equal("password123456")) + if calls > 1 { + cookie, err := r.Cookie("session") + Expect(err).NotTo(HaveOccurred()) + Expect(cookie.Value).To(Equal("retained")) + Expect(payload["attempt"]).To(Equal("2")) + } + switch calls { + case 1: + Expect(payload["attempt"]).To(Equal("1")) + http.SetCookie(w, &http.Cookie{Name: "session", Value: "retained", Path: "/"}) + _, err = w.Write([]byte("failureType-5000")) + Expect(err).NotTo(HaveOccurred()) + case 2: + originalBody = body + w.Header().Set("Location", podURL) + w.WriteHeader(status) + case 3: + Expect(body).To(Equal(originalBody)) + Expect(r.URL.Path).To(Equal(PrivateAppStoreAPIPathAuth + suffix)) + Expect(r.URL.RawQuery).To(Equal("routing=opaque")) + w.Header().Set(HTTPHeaderStoreFront, "143441-1,29") + _, err = w.Write([]byte("passwordTokentokendsPersonId123")) + Expect(err).NotTo(HaveOccurred()) + default: + Fail("unexpected request") + } + })) + DeferCleanup(srv.Close) + var destinations []string + adapter.EXPECT().Send(gomock.Any()).DoAndReturn(func(request apphttp.Request) (apphttp.Result[loginResult], error) { + destinations = append(destinations, request.URL) + parsed, err := url.Parse(request.URL) + Expect(err).NotTo(HaveOccurred()) + request.URL = srv.URL + parsed.RequestURI() + + return client.Send(request) + }).Times(3) + signer := &stubActionSigner{} + account, err := sut.login("email", "password", "123456", "guid", testAuthEndpoint+suffix, signer) + Expect(err).NotTo(HaveOccurred()) + Expect(account.PasswordToken).To(Equal("token")) + Expect(destinations).To(Equal([]string{testAuthEndpoint + suffix, testAuthEndpoint + suffix, podURL})) + Expect(signer.signCalls).To(Equal(3)) + }, + Entry("301, bare path", 301, ""), + Entry("301, trailing slash", 301, "/"), + Entry("302, bare path", 302, ""), + Entry("302, trailing slash", 302, "/"), + Entry("307, bare path", 307, ""), + Entry("307, trailing slash", 307, "/"), + Entry("308, bare path", 308, ""), + Entry("308, trailing slash", 308, "/"), + ) + + It("follows relative and pod redirects within the redirect budget", func() { + ctrl := gomock.NewController(GinkgoT()) + client := apphttp.NewMockClient[loginResult](ctrl) + chain := keychain.NewMockKeychain(ctrl) + chain.EXPECT().Set("account", gomock.Any()).Return(nil) + sut := &appstore{loginClient: client, keychain: chain} + pod := "https://p7-buy.itunes.apple.com" + PrivateAppStoreAPIPathAuth + urls := []string{testAuthEndpoint, testAuthEndpoint + "/", pod, pod + "/", pod + "/?routing=opaque"} + locations := []string{PrivateAppStoreAPIPathAuth + "/", "//p7-buy.itunes.apple.com" + PrivateAppStoreAPIPathAuth, "authenticate/", "?routing=opaque"} + statuses := []int{301, 302, 307, 308} + calls := 0 + var original apphttp.Payload + client.EXPECT().Send(gomock.Any()).DoAndReturn(func(request apphttp.Request) (apphttp.Result[loginResult], error) { + Expect(request.URL).To(Equal(urls[calls])) + if calls == 0 { + original = request.Payload + } + Expect(request.Payload).To(BeIdenticalTo(original)) + calls++ + if calls <= len(locations) { + return apphttp.Result[loginResult]{StatusCode: statuses[calls-1], Headers: map[string]string{"Location": locations[calls-1]}}, nil + } + + return apphttp.Result[loginResult]{StatusCode: 200, Headers: map[string]string{HTTPHeaderStoreFront: "storefront"}, Data: loginResult{PasswordToken: "token", DirectoryServicesID: "123"}}, nil + }).Times(5) + _, err := sut.login("email", "password", "", "guid", testAuthEndpoint, &stubActionSigner{}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("stops a redirect loop without retaining response secrets", func() { + ctrl := gomock.NewController(GinkgoT()) + client := apphttp.NewMockClient[loginResult](ctrl) + client.EXPECT().Send(gomock.Any()).Return(apphttp.Result[loginResult]{ + StatusCode: 301, + Headers: map[string]string{"Location": testAuthEndpoint + "?token=secret-query", "Set-Cookie": "secret-cookie"}, + Data: loginResult{PasswordToken: "secret-token"}, + }, nil).Times(maxAuthenticationRedirects + 1) + sut := &appstore{loginClient: client} + _, err := sut.login("email", "password", "", "guid", testAuthEndpoint, &stubActionSigner{}) + Expect(err).To(MatchError("too many authentication redirects (HTTP 301)")) + var authErr *Error + Expect(errors.As(err, &authErr)).To(BeTrue()) + Expect(authErr.Metadata).To(Equal(map[string]interface{}{"statusCode": 301, "destination": testAuthEndpoint})) + }) + + It("retries the first credential failure at the assigned pod", func() { + ctrl := gomock.NewController(GinkgoT()) + client := apphttp.NewMockClient[loginResult](ctrl) + sut := &appstore{loginClient: client} + pod := "https://p7-buy.itunes.apple.com" + PrivateAppStoreAPIPathAuth + "/" + calls := 0 + client.EXPECT().Send(gomock.Any()).DoAndReturn(func(request apphttp.Request) (apphttp.Result[loginResult], error) { + calls++ + if calls == 1 { + return apphttp.Result[loginResult]{StatusCode: 302, Headers: map[string]string{"Location": pod}}, nil + } + Expect(request.URL).To(Equal(pod)) + if calls == 2 { + Expect(request.Payload.(*apphttp.XMLPayload).Content).To(HaveKeyWithValue("attempt", "1")) + + return apphttp.Result[loginResult]{StatusCode: 200, Data: loginResult{FailureType: FailureTypeInvalidCredentials}}, nil + } + Expect(request.Payload.(*apphttp.XMLPayload).Content).To(HaveKeyWithValue("attempt", "2")) + + return apphttp.Result[loginResult]{StatusCode: 200, Data: loginResult{FailureType: FailureTypeInvalidCredentials, CustomerMessage: "invalid credentials"}}, nil + }).Times(3) + _, err := sut.login("email", "password", "", "guid", testAuthEndpoint, &stubActionSigner{}) + Expect(err).To(MatchError("invalid credentials")) + }) + + DescribeTable("rejects unsafe or unsupported redirects before reposting credentials", + func(status int, location string) { + ctrl := gomock.NewController(GinkgoT()) + client := apphttp.NewMockClient[loginResult](ctrl) + client.EXPECT().Send(gomock.Any()).Return(apphttp.Result[loginResult]{ + StatusCode: status, + Headers: map[string]string{"Location": location, "Set-Cookie": "secret-cookie"}, + Data: loginResult{PasswordToken: "secret-token"}, + }, nil).Times(1) + sut := &appstore{loginClient: client} + _, err := sut.login("email", "password", "", "guid", testAuthEndpoint, &stubActionSigner{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).NotTo(ContainSubstring("secret")) + var authErr *Error + Expect(errors.As(err, &authErr)).To(BeTrue()) + data, marshalErr := json.Marshal(authErr.Metadata) + Expect(marshalErr).NotTo(HaveOccurred()) + Expect(string(data)).NotTo(ContainSubstring("secret")) + }, + Entry("HTTP downgrade", 301, "http://buy.itunes.apple.com"+PrivateAppStoreAPIPathAuth), + Entry("foreign host", 302, "https://example.com"+PrivateAppStoreAPIPathAuth+"?token=secret-query"), + Entry("host suffix spoof", 307, "https://buy.itunes.apple.com.example.com"+PrivateAppStoreAPIPathAuth), + Entry("foreign protocol-relative host", 308, "//example.com"+PrivateAppStoreAPIPathAuth), + Entry("different path", 301, "https://buy.itunes.apple.com/other"), + Entry("userinfo", 302, "https://user:secret-password@buy.itunes.apple.com"+PrivateAppStoreAPIPathAuth), + Entry("different port", 307, "https://buy.itunes.apple.com:8443"+PrivateAppStoreAPIPathAuth), + Entry("encoded path", 308, "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/%61uthenticate"), + Entry("fragment", 301, testAuthEndpoint+"#secret-fragment"), + Entry("fragment after query", 302, testAuthEndpoint+"?routing=opaque#secret-fragment"), + Entry("malformed URL", 302, "https://buy.itunes.apple.com/%secret"), + Entry("empty Location", 307, ""), + Entry("blank Location", 308, " \t"), + Entry("303 GET redirect", 303, testAuthEndpoint+"?token=secret-query"), + Entry("300 multiple choices", 300, testAuthEndpoint), + ) +}) diff --git a/pkg/appstore/authentication_retry_test.go b/pkg/appstore/authentication_retry_test.go index e541e38f..f7536c8a 100644 --- a/pkg/appstore/authentication_retry_test.go +++ b/pkg/appstore/authentication_retry_test.go @@ -77,7 +77,7 @@ var _ = Describe("Authentication recovery", func() { client.EXPECT().Send(gomock.Any()).Return(apphttp.Result[loginResult]{StatusCode: 200, Data: data}, nil).Times(1) result, err := sut.sendAuthenticationRequest(apphttp.Request{}) Expect(err).NotTo(HaveOccurred()) - _, _, err = sut.parseLoginResponse(&result, 2, "") + _, _, err = sut.parseLoginResponse(&result, 2, "", testAuthEndpoint) Expect(err).To(HaveOccurred()) Expect(err.Error()).NotTo(ContainSubstring("another network")) } diff --git a/pkg/http/authentication_test.go b/pkg/http/authentication_test.go index e86855c2..9f810275 100644 --- a/pkg/http/authentication_test.go +++ b/pkg/http/authentication_test.go @@ -16,6 +16,49 @@ type sessionCookieJar struct{ *cookiejar.Jar } func (sessionCookieJar) Save() error { return nil } var _ = Describe("Authentication transport", func() { + DescribeTable("returns redirects for explicit validation without contacting their destination", + func(http2 bool, status int, suffix string) { + jar, err := cookiejar.New(nil) + Expect(err).NotTo(HaveOccurred()) + calls := 0 + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + calls++ + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal(appStoreAuthPath + suffix)) + Expect(r.ProtoMajor).To(Equal(map[bool]int{false: 1, true: 2}[http2])) + if calls == 1 { + w.Header().Set("Location", "/must-not-follow?token=secret") + w.WriteHeader(status) + } + })) + srv.EnableHTTP2 = http2 + srv.StartTLS() + DeferCleanup(srv.Close) + sut := NewClient[struct{}](Args{CookieJar: sessionCookieJar{jar}, Authentication: true}).(*client[struct{}]) + transport := sut.internalClient.Transport.(*AddHeaderTransport).T.(*http.Transport) + transport.TLSClientConfig = srv.Client().Transport.(*http.Transport).TLSClientConfig.Clone() + DeferCleanup(transport.CloseIdleConnections) + result, err := sut.Send(Request{ + URL: srv.URL + appStoreAuthPath + suffix, Method: MethodPOST, ResponseFormat: ResponseFormatXML, + Payload: &XMLPayload{Content: map[string]interface{}{"password": "secret"}}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(result.StatusCode).To(Equal(status)) + Expect(result.Headers).To(HaveKeyWithValue("Location", "/must-not-follow?token=secret")) + Expect(calls).To(Equal(1)) + }, + Entry("301, bare path, HTTP/1.1", false, 301, ""), + Entry("301, trailing slash, HTTP/2", true, 301, "/"), + Entry("302, trailing slash, HTTP/1.1", false, 302, "/"), + Entry("302, bare path, HTTP/2", true, 302, ""), + Entry("307, trailing slash, HTTP/1.1", false, 307, "/"), + Entry("307, bare path, HTTP/2", true, 307, ""), + Entry("308, bare path, HTTP/1.1", false, 308, ""), + Entry("308, trailing slash, HTTP/2", true, 308, "/"), + Entry("303 must also reach the caller for rejection", false, 303, "/"), + ) + DescribeTable("preserves cookies and signed POSTs across fresh connections", func(http2 bool) { jar, err := cookiejar.New(nil) @@ -146,6 +189,9 @@ var _ = Describe("Authentication transport", func() { Entry("empty forbidden response", 403, "", "empty or non-plist authentication response"), Entry("HTML failure", 500, "secret-password", "empty or non-plist authentication response"), Entry("redirect without Location", 302, "secret-token", "authentication redirect is missing Location"), + Entry("permanent redirect without Location", 301, "secret-token", "authentication redirect is missing Location"), + Entry("temporary redirect without Location", 307, "secret-token", "authentication redirect is missing Location"), + Entry("permanent POST redirect without Location", 308, "secret-token", "authentication redirect is missing Location"), Entry("rate limit", 429, "secret-token", "rate limited by Apple"), Entry("malformed plist", 200, "secret-token", "malformed authentication plist"), ) diff --git a/pkg/http/client.go b/pkg/http/client.go index 55d93aff..e26b66bb 100644 --- a/pkg/http/client.go +++ b/pkg/http/client.go @@ -16,6 +16,17 @@ import ( const appStoreAuthPath = "/WebObjects/MZFinance.woa/wa/authenticate" +// IsAuthenticationRedirect reports which Store redirects can replay a signed POST. +// A 303 explicitly requests a GET and is not part of this authentication flow. +func IsAuthenticationRedirect(status int) bool { + switch status { + case http.StatusMovedPermanently, http.StatusFound, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + return true + default: + return false + } +} + var ( documentXMLPattern = regexp.MustCompile(`(?is)]*>(.*)`) plistXMLPattern = regexp.MustCompile(`(?is)]*>.*?`) @@ -100,7 +111,10 @@ func NewClient[R interface{}](args Args) Client[R] { Timeout: 0, Jar: args.CookieJar, CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) > 0 && via[len(via)-1].URL.Path == appStoreAuthPath { + // Login validates destinations and replays the signed POST itself. + // Never let net/http turn an authentication redirect into a GET. + if args.Authentication || (len(via) > 0 && + (via[len(via)-1].URL.Path == appStoreAuthPath || via[len(via)-1].URL.Path == appStoreAuthPath+"/")) { return http.ErrUseLastResponse } @@ -230,7 +244,7 @@ func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) { return Result[R]{}, fmt.Errorf("failed to read response body: %w", err) } - if c.authentication && res.StatusCode == http.StatusFound && strings.TrimSpace(res.Header.Get("Location")) == "" { + if c.authentication && IsAuthenticationRedirect(res.StatusCode) && strings.TrimSpace(res.Header.Get("Location")) == "" { return Result[R]{}, authenticationResponseError(res, body, "authentication redirect is missing Location") }