diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b121bddb0..9bd03e72c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - CLI: add `codexbar serve` for localhost JSON access to usage and cost endpoints (#957). Thanks @ThiagoCAltoe! ### Fixed +- OpenCode Go: block cross-host redirects when fetching usage so imported cookies cannot follow external redirect targets (#969). Thanks @pavbar! - Codex: keep background `/status` probes out of Codex Desktop history by using isolated non-persistent CLI storage (#953). - Menu: stabilize the Cost submenu by using a native menu item and deferring open-menu rebuilds while tracking (#954). Thanks @getogrand! - Localization: add Brazilian Portuguese quota-warning settings strings (#958). Thanks @ThiagoCAltoe! diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index 1f5c28d146..efd6aac716 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -33,6 +33,25 @@ public struct OpenCodeGoUsageFetcher: Sendable { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + private final class RedirectGuardDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + guard OpenCodeGoUsageFetcher.allowsRedirect( + from: task.originalRequest?.url, + to: request.url) + else { + completionHandler(nil) + return + } + completionHandler(request) + } + } + private struct ServerRequest { let serverID: String let args: String? @@ -73,14 +92,24 @@ public struct OpenCodeGoUsageFetcher: Sendable { "renewAt", "renew_at", ] + private static let redirectGuardDelegate = RedirectGuardDelegate() + private static let redirectGuardSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + return URLSession( + configuration: configuration, + delegate: OpenCodeGoUsageFetcher.redirectGuardDelegate, + delegateQueue: nil) + }() public static func fetchUsage( cookieHeader: String, timeout: TimeInterval, now: Date = Date(), workspaceIDOverride: String? = nil, - session: URLSession = .shared) async throws -> OpenCodeGoUsageSnapshot + session: URLSession? = nil) async throws -> OpenCodeGoUsageSnapshot { + let session = session ?? self.redirectGuardSession guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { throw OpenCodeGoUsageError.invalidCredentials } @@ -100,6 +129,15 @@ public struct OpenCodeGoUsageFetcher: Sendable { return try self.parseSubscription(text: subscriptionText, now: now) } + static func allowsRedirect(from sourceURL: URL?, to destinationURL: URL?) -> Bool { + guard let sourceHost = sourceURL?.host?.lowercased(), + let destinationHost = destinationURL?.host?.lowercased(), + sourceHost == destinationHost, + destinationURL?.scheme?.lowercased() == "https" + else { return false } + return true + } + public static func dashboardURL(workspaceID raw: String?) -> URL { guard let workspaceID = self.normalizeWorkspaceID(raw), let url = URL(string: "\(self.baseURL.absoluteString)/workspace/\(workspaceID)/go") diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift index 12c73ab773..585741ca7c 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore @Suite(.serialized) struct OpenCodeGoUsageFetcherErrorTests { @@ -28,6 +28,21 @@ struct OpenCodeGoUsageFetcherErrorTests { return URLSession(configuration: config) } + @Test + func `redirect guard allows only same-host https redirects`() { + #expect(OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "https://opencode.ai/workspace/wrk_TEST123/go"))) + + #expect(!OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "https://evil.example/steal"))) + + #expect(!OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "http://opencode.ai/insecure"))) + } + @Test func `extracts api error from detail field`() async throws { defer {