@@ -88,6 +88,58 @@ private actor ResponseCache {
91+/// Strips the `Authorization` header on redirects that cross to a different
92+/// origin. By default `URLSession` re-sends the original request's headers —
93+/// including the `Basic` credentials this client attaches to every request —
94+/// to the redirect target. A server (or a malicious/misconfigured proxy)
95+/// answering with a 3xx to an unrelated host would otherwise harvest the user's
96+/// credentials. Same-origin redirects keep the header so normal auth still works.
98+/// Holds only an immutable `URL`, so it is safely `Sendable` despite the
99+/// NSObject base (which isn't formally `Sendable`).
100+private final class RedirectSanitizer: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
101+ private let baseURL: URL
103+ init(baseURL: URL) {
104+ self.baseURL = baseURL
108+ _ session: URLSession,
109+ task: URLSessionTask,
110+ willPerformHTTPRedirection response: HTTPURLResponse,
111+ newRequest request: URLRequest,
112+ completionHandler: @escaping (URLRequest?) -> Void
114+ guard !RedirectSanitizer.sameOrigin(baseURL, request.url) else {
115+ completionHandler(request)
118+ var sanitized = request
119+ sanitized.setValue(nil, forHTTPHeaderField: "Authorization")
120+ completionHandler(sanitized)
123+ /// Origin equality per scheme/host/port, normalizing the implicit default
124+ /// port so `https://host` and `https://host:443` are treated as the same
125+ /// origin (and aren't stripped on a legitimate same-host redirect).
126+ static func sameOrigin(_ a: URL, _ b: URL?) -> Bool {
127+ guard let b else { return false }
128+ return a.scheme?.lowercased() == b.scheme?.lowercased()
129+ && a.host?.lowercased() == b.host?.lowercased()
130+ && effectivePort(a) == effectivePort(b)
133+ private static func effectivePort(_ url: URL) -> Int? {
134+ if let port = url.port { return port }
135+ switch url.scheme?.lowercased() {
136+ case "https": return 443
137+ case "http": return 80
91final class ParseableClient: Sendable {
143final class ParseableClient: Sendable {
92 let baseURL: URL
144 let baseURL: URL
93 let username: String
145 let username: String
@@ -126,7 +178,15 @@ final class ParseableClient: Sendable {
126 let config = URLSessionConfiguration.default
178 let config = URLSessionConfiguration.default
127 config.timeoutIntervalForRequest = 30
179 config.timeoutIntervalForRequest = 30
128 config.timeoutIntervalForResource = 120
180 config.timeoutIntervalForResource = 120
181+ // Install a delegate that drops the Authorization header on cross-origin
182+ // redirects so Basic credentials can't be forwarded to an unrelated host.
183+ // The session retains the delegate until `finishTasksAndInvalidate()` in
184+ // deinit releases it, so there's no leak.
129− self.session = URLSession(configuration: config)
185+ self.session = URLSession(
186+ configuration: config,
187+ delegate: RedirectSanitizer(baseURL: url),
132 convenience init(connection: ServerConnection) throws {
192 convenience init(connection: ServerConnection) throws {