Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 103 additions & 2 deletions Sources/ClaudeAPI/HTTPTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import Foundation
import Synchronization

/// The HTTP seam ``ClaudeClient`` talks through. Production uses
/// ``URLSessionTransport``; tests inject a fake. The streaming body is surfaced
Expand All @@ -14,6 +15,24 @@ package protocol HTTPTransport: Sendable {
) async throws -> (AsyncThrowingStream<UInt8, Error>, URLResponse)
}

package enum HTTPTransportError: Error, Sendable, Hashable {
/// The server redirected to a different scheme, host, or port and the
/// transport refused to follow. Every request carries a credential minted
/// for the configured base URL — `x-api-key`, a bearer token, the
/// developer's proxy headers, or an App Attest assertion in the body — and
/// `URLSession` forwards all of those except `Authorization` on redirect.
case crossOriginRedirect(to: URL)
}

extension HTTPTransportError: LocalizedError {
package var errorDescription: String? {
switch self {
case .crossOriginRedirect(let target):
"Refused a redirect away from the configured base URL (to \(target.host() ?? "?"))."
}
}
}

/// `URLSession`-backed transport used in production.
package struct URLSessionTransport: HTTPTransport {
private let session: URLSession
Expand All @@ -23,7 +42,10 @@ package struct URLSessionTransport: HTTPTransport {
}

package func data(for request: URLRequest) async throws -> (Data, URLResponse) {
try await session.data(for: request)
let redirects = RedirectPolicy(origin: request.url)
let (data, response) = try await session.data(for: request, delegate: redirects)
try redirects.checkRefused()
return (data, response)
}

package func bytes(
Expand All @@ -32,7 +54,11 @@ package struct URLSessionTransport: HTTPTransport {
// `bytes(for:)` returns once headers arrive, so the caller can check the
// status before draining the body. Re-yield the bytes through a stream of
// the transport's vocabulary type.
let (asyncBytes, response) = try await session.bytes(for: request)
let redirects = RedirectPolicy(origin: request.url)
let (asyncBytes, response) = try await session.bytes(for: request, delegate: redirects)
// Redirects are decided before the response headers are delivered, so a
// refusal is already recorded by the time `bytes(for:)` returns.
try redirects.checkRefused()
let stream = AsyncThrowingStream<UInt8, Error> { continuation in
let task = Task {
do {
Expand All @@ -47,3 +73,78 @@ package struct URLSessionTransport: HTTPTransport {
return (stream, response)
}
}

/// Per-task delegate that follows redirects only within the authority the
/// request was made to. Refusing (rather than stripping known headers) keeps
/// the credential-bearing headers and body off the other host regardless of
/// which auth mode put them there.
///
/// A refused redirect makes `URLSession` complete the task with the 3xx
/// response itself, which callers would otherwise take for a success, so the
/// refusal is recorded and ``checkRefused()`` turns it into an error.
final class RedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
/// Nil when the request had no usable URL, in which case nothing is
/// followed.
private let origin: Authority?
private let refusedTarget = Mutex<URL?>(nil)

init(origin: URL?) {
self.origin = origin.flatMap { Authority($0) }
super.init()
}

/// Compared against the original request, not the previous hop, so a chain
/// that leaves the authority is refused wherever it leaves.
func allowsRedirect(to target: URL) -> Bool {
if let origin, Authority(target) == origin { return true }
refusedTarget.withLock { $0 = target }
return false
}

func checkRefused() throws {
if let target = refusedTarget.withLock({ $0 }) {
throw HTTPTransportError.crossOriginRedirect(to: target)
}
}

func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping @Sendable (URLRequest?) -> Void
) {
if let target = request.url, allowsRedirect(to: target) {
completionHandler(request)
} else {
completionHandler(nil)
}
}

/// The `scheme://host:port` triple a credential is scoped to. An omitted
/// port equals the scheme's default, so `https://h` and `https://h:443`
/// are one authority; a scheme change (including an https → http
/// downgrade) is not.
struct Authority: Hashable, Sendable {
let scheme: String
let host: String
let port: Int?

init?(_ url: URL) {
guard let scheme = url.scheme?.lowercased(), let host = url.host(), !host.isEmpty else {
return nil
}
self.scheme = scheme
self.host = host.lowercased()
self.port = url.port ?? Self.defaultPort(for: scheme)
}

private static func defaultPort(for scheme: String) -> Int? {
switch scheme {
case "https": 443
case "http": 80
default: nil
}
}
}
}
2 changes: 2 additions & 0 deletions Sources/ClaudeForFoundationModels/ClaudeLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ public struct ClaudeLanguageModel: Sendable {
/// `tools:` array, which the framework invokes client-side.
/// - baseURL: API endpoint. Override to point at a developer-run proxy
/// that adds authentication server-side (use with ``AuthMode/proxied``).
/// Credentials are only ever sent to this scheme, host, and port: a
/// redirect elsewhere fails the request instead of being followed.
Comment on lines +53 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we drop this comment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how come? I think it's relevant when you're initializing your client actually?

public init(
name: ClaudeModel,
auth: AuthMode,
Expand Down
246 changes: 246 additions & 0 deletions Tests/ClaudeAPITests/URLSessionTransportTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// Copyright 2026 Anthropic PBC
// SPDX-License-Identifier: Apache-2.0

import Foundation
import Synchronization
import Testing

@testable import ClaudeAPI

/// Redirect handling in `URLSessionTransport`: the decision itself, and the
/// decision wired through a real `URLSession` via a `URLProtocol` stub. The
/// stub routes by host and every test owns two hosts of its own, so the suite
/// runs in parallel.
@Suite struct URLSessionTransportTests {

// MARK: - Decision

@Test(arguments: [
"https://proxy.test/moved",
"https://proxy.test:443/v1/messages",
"HTTPS://Proxy.TEST/v1/messages",
])
func `a redirect within the origin's authority is followed`(target: String) {
let policy = RedirectPolicy(origin: URL(string: "https://proxy.test/v1/messages"))

#expect(policy.allowsRedirect(to: URL(string: target)!))
#expect(throws: Never.self) { try policy.checkRefused() }
}

@Test(arguments: [
"https://other.test/v1/messages",
"https://proxy.test.other.test/v1/messages",
"https://proxy.test:8443/v1/messages",
"http://proxy.test/v1/messages",
])
func `a redirect to another authority is refused and reported`(target: String) {
let policy = RedirectPolicy(origin: URL(string: "https://proxy.test/v1/messages"))
let url = URL(string: target)!

#expect(!policy.allowsRedirect(to: url))
#expect(throws: HTTPTransportError.crossOriginRedirect(to: url)) { try policy.checkRefused() }
}

@Test func `an explicit default port matches an omitted one for http too`() {
let policy = RedirectPolicy(origin: URL(string: "http://proxy.test:80/v1/messages"))

#expect(policy.allowsRedirect(to: URL(string: "http://proxy.test/moved")!))
#expect(!policy.allowsRedirect(to: URL(string: "https://proxy.test/moved")!))
}

@Test func `nothing is followed when the origin has no authority`() {
let policy = RedirectPolicy(origin: nil)

#expect(!policy.allowsRedirect(to: URL(string: "https://proxy.test/moved")!))
#expect(throws: HTTPTransportError.self) { try policy.checkRefused() }
}

// MARK: - Through URLSession

@Test func `data refuses a redirect to another host`() async throws {
let stub = Stub("data-cross")
stub.redirectOrigin(to: stub.otherURL)

let error = try await #require(throws: HTTPTransportError.self) {
_ = try await stub.transport.data(for: stub.request())
}

guard case .crossOriginRedirect(let target) = error else {
Issue.record("unexpected error \(error)")
return
}
#expect(target.host() == stub.otherHost)
// The other host never saw a request, credentials or otherwise.
#expect(stub.receivedHosts == [stub.originHost])
}

@Test func `bytes refuses a redirect to another host`() async throws {
let stub = Stub("bytes-cross")
stub.redirectOrigin(to: stub.otherURL)

let error = try await #require(throws: HTTPTransportError.self) {
_ = try await stub.transport.bytes(for: stub.request())
}

guard case .crossOriginRedirect(let target) = error else {
Issue.record("unexpected error \(error)")
return
}
#expect(target.host() == stub.otherHost)
#expect(stub.receivedHosts == [stub.originHost])
}

@Test func `data follows an origin-host redirect and keeps the credentials`() async throws {
let stub = Stub("data-same")
stub.redirectOrigin(to: stub.movedURL)

let (body, response) = try await stub.transport.data(for: stub.request())

#expect(String(decoding: body, as: UTF8.self) == "ok")
#expect((response as? HTTPURLResponse)?.statusCode == 200)
#expect(stub.receivedHosts == [stub.originHost, stub.originHost])
let followUp = try #require(stub.received.last)
#expect(followUp.url?.path() == "/moved")
#expect(followUp.value(forHTTPHeaderField: "x-api-key") == "sk-test")
#expect(followUp.value(forHTTPHeaderField: "X-App-Token") == "app-secret")
}

@Test func `bytes follows an origin-host redirect and keeps the credentials`() async throws {
let stub = Stub("bytes-same")
stub.redirectOrigin(to: stub.movedURL)

let (bytes, response) = try await stub.transport.bytes(for: stub.request())
var body = Data()
for try await byte in bytes { body.append(byte) }

#expect(String(decoding: body, as: UTF8.self) == "ok")
#expect((response as? HTTPURLResponse)?.statusCode == 200)
#expect(stub.receivedHosts == [stub.originHost, stub.originHost])
let followUp = try #require(stub.received.last)
#expect(followUp.url?.path() == "/moved")
#expect(followUp.value(forHTTPHeaderField: "x-api-key") == "sk-test")
#expect(followUp.value(forHTTPHeaderField: "X-App-Token") == "app-secret")
}
}

// MARK: - Stub

/// One test's slice of ``StubProtocol``: an origin host whose `/v1/messages`
/// answers with a 307, and a second host standing in for wherever the
/// redirect points. Every other request on either host answers 200 `ok`.
private struct Stub {
let originHost: String
let otherHost: String
let transport: URLSessionTransport
private let recorder = Recorder()

init(_ name: String) {
originHost = "origin-\(name).test"
otherHost = "other-\(name).test"
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [StubProtocol.self]
transport = URLSessionTransport(session: URLSession(configuration: configuration))
}

var otherURL: URL { URL(string: "https://\(otherHost)/v1/messages")! }
var movedURL: URL { URL(string: "https://\(originHost)/moved")! }

/// Every request the stub served, in order, across both hosts.
var received: [URLRequest] { recorder.requests.withLock { $0 } }
var receivedHosts: [String] { received.compactMap { $0.url?.host() } }

func redirectOrigin(to target: URL) {
let recorder = self.recorder
let originHost = self.originHost
StubProtocol.register(hosts: [originHost, otherHost]) { request in
recorder.requests.withLock { $0.append(request) }
let isOriginEndpoint =
request.url?.host() == originHost && request.url?.path() == "/v1/messages"
return isOriginEndpoint ? .redirect(to: target) : .ok
}
}

/// Carries a credential in each of the header positions the SDK uses:
/// `x-api-key` for `.apiKey`, and a developer header for `.proxied`.
func request() -> URLRequest {
var request = URLRequest(url: URL(string: "https://\(originHost)/v1/messages")!)
request.httpMethod = "POST"
request.setValue("sk-test", forHTTPHeaderField: "x-api-key")
request.setValue("app-secret", forHTTPHeaderField: "X-App-Token")
return request
}
}

private final class Recorder: Sendable {
let requests = Mutex<[URLRequest]>([])
}

/// Serves the hosts handed to ``register(hosts:handler:)`` without touching
/// the network. A `.redirect` reply goes through `URLSession`'s redirect
/// machinery, so the task delegate under test decides whether the follow-up
/// request is loaded.
private final class StubProtocol: URLProtocol {
enum Reply: Sendable {
case redirect(to: URL)
case ok
}

typealias Handler = @Sendable (URLRequest) -> Reply

private static let handlers = Mutex<[String: Handler]>([:])

static func register(hosts: [String], handler: @escaping Handler) {
handlers.withLock { table in
for host in hosts { table[host] = handler }
}
}

private static func handler(for request: URLRequest) -> Handler? {
guard let host = request.url?.host() else { return nil }
return handlers.withLock { $0[host] }
}

override class func canInit(with request: URLRequest) -> Bool {
handler(for: request) != nil
}

override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}

override func startLoading() {
guard let client, let url = request.url, let handler = StubProtocol.handler(for: request) else {
return
}
switch handler(request) {
case .redirect(let target):
let response = HTTPURLResponse(
url: url,
statusCode: 307,
httpVersion: "HTTP/1.1",
headerFields: ["Location": target.absoluteString]
)!
// Shaped like the request Foundation proposes for a 307: same method,
// body, and custom headers, new URL — exactly what leaks if followed.
var next = request
next.url = target
client.urlProtocol(self, wasRedirectedTo: next, redirectResponse: response)
// When the delegate declines, the redirect response itself completes
// the task; when it accepts, this load is stopped and these are dropped.
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client.urlProtocolDidFinishLoading(self)
case .ok:
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil
)!
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client.urlProtocol(self, didLoad: Data("ok".utf8))
client.urlProtocolDidFinishLoading(self)
}
}

override func stopLoading() {}
}