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")
}