ローカルサーバーで URLSession のリダイレクト境界を検証する

ローカルサーバーで URLSession のリダイレクト境界を検証する

クラウド Mac 上で iOS の通信をテストするとき、API が最終的に返した結果だけを確認していると、途中の HTTP リダイレクトを見落としがちです。たとえばダウンロード要求に対して最初に 302 が返り、転送先ではポートが変わっていたとします。クライアントがそのまま追従すれば、一つのサービスだけに送るつもりだったテスト要求が別のサービスに届きます。ここでは二つのローカルポートでこの状況を再現し、XCTest で「同一オリジンへの転送は許可する」「異なるオリジンへの転送は拒否する」をそれぞれ検証します。外部サイトは不要で、失敗も再現しやすい構成です。

アサーションより先に境界を決める

ここでいう「オリジン」は、スキーム・ホスト・ポートの組み合わせです。http://127.0.0.1:8765 から同じポートの /target への転送は同一オリジンですが、http://127.0.0.1:8766/target への転送は、ホスト名が同じでも異なるオリジンです。この例では同一オリジンへの転送だけを許可します。あくまで説明用のポリシーであり、すべてのアプリで異なるオリジンへの転送を禁止すべきという意味ではありません。ダウンロードサービスが実際に別のドメインを使うなら、許可する転送先を先に明示し、要件に沿ってルールを実装してください。

リダイレクトのテストでは、途中で下された判断を確認する必要があります。最終的に 200 が返ったことだけをアサートしても、想定外の宛先を経由していない証明にはなりません。

テスト用のフィクスチャはテストリポジトリに置き、ダミーのトークンを使ってください。本番環境の認証情報をスクリプトやテストログに書き込んではいけません。本番の API が HTTPS を使う場合は、TLS も別途検証する必要があります。ローカルの HTTP フィクスチャが検証するのは転送の判断だけであり、本番接続のセキュリティテストの代わりにはなりません。

ローカルホストだけで待ち受ける二つのポートを起動する

次のコードを redirect_fixture.py として保存し、クラウド Mac のターミナルで python3 redirect_fixture.py を実行します。一つ目のポートは二種類のリダイレクトを返し、二つ目のポートは異なるオリジンへの転送先として要求を受け取るためだけに使います。サーバーは 127.0.0.1 にバインドするため、テスト用ポートを外部には公開しません。

from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        port = self.server.server_port
        if port == 8765 and self.path in ("/same", "/start"):
            target = (
                "/target" if self.path == "/same"
                else "http://127.0.0.1:8766/target"
            )
            self.send_response(302)
            self.send_header("Location", target)
            self.end_headers()
            return
        if self.path == "/target":
            body = b"ok"
            self.send_response(200)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        self.send_error(404)

servers = [
    HTTPServer(("127.0.0.1", port), Handler)
    for port in (8765, 8766)
]
threads = [
    Thread(target=server.serve_forever, daemon=True)
    for server in servers
]
for thread in threads:
    thread.start()
threads[0].join()

まず別のターミナルからフィクスチャを確認します。curl -i http://127.0.0.1:8765/start では二つ目のポートのアドレスを含む 302、curl -i http://127.0.0.1:8765/same では /target を指す 302 が返るはずです。ここで -L を付けると curl が転送を追ってしまい、最初のレスポンスを確認できません。テスト後は、フィクスチャを起動したターミナルで Ctrl-C を押して停止します。

URLSession のデリゲートで転送を判定する

転送を許可するかどうかは、URLSessionTaskDelegate のリダイレクトコールバックで判断します。比較するのは現在のレスポンスの URL と、次に要求する URL です。ホスト名だけを比較するのではありません。次のクラスは iOS のテスト target に配置できます。タスクの状態を保持しないため、二つのテストでそれぞれインスタンスを作成できます。

import Foundation

final class SameOriginGate: NSObject, URLSessionTaskDelegate,
                            @unchecked Sendable {
    func urlSession(
        _ session: URLSession,
        task: URLSessionTask,
        willPerformHTTPRedirection response: HTTPURLResponse,
        newRequest request: URLRequest,
        completionHandler: @escaping (URLRequest?) -> Void
    ) {
        guard let from = response.url,
              let to = request.url,
              let source = URLComponents(url: from, resolvingAgainstBaseURL: false),
              let destination = URLComponents(url: to, resolvingAgainstBaseURL: false),
              source.scheme == destination.scheme,
              source.host == destination.host,
              source.port == destination.port else {
            completionHandler(nil)
            return
        }
        completionHandler(request)
    }
}

ここではポートを明示的に比較しており、フィクスチャの二つのアドレスを区別するには十分です。本番コードではデフォルトポートの正規化ルールも定義してください。たとえば、ポートを省略した HTTPS の URL と 443 を明記した URL を同等とみなすかどうかです。テスト例の簡略化した比較が、そのまま製品のポリシーにならないようにします。また、デリゲートが新しい要求を許可する場合は、どのリクエストヘッダーを引き続き送信してよいかも業務上の要件に照らして確認してください。

結果を区別できる二つの XCTest を書く

テスト target に次のテストケースを追加します。data(for:delegate:) を使い、この要求のリダイレクト判断を指定したデリゲートに任せます。各テストで一時的なセッションを使い、セッションの状態を使い回さないようにします。テストを実行する同じクラウド Mac 上でフィクスチャを起動してから、テストを実行してください。

import XCTest

final class RedirectBoundaryTests: XCTestCase {
    func testSameOriginRedirectSucceeds() async throws {
        let session = URLSession(configuration: .ephemeral)
        defer { session.invalidateAndCancel() }

        let url = URL(string: "http://127.0.0.1:8765/same")!
        let (data, response) = try await session.data(
            for: URLRequest(url: url),
            delegate: SameOriginGate()
        )

        XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
        XCTAssertEqual(String(data: data, encoding: .utf8), "ok")
        XCTAssertEqual(response.url?.path, "/target")
    }

    func testDifferentPortRedirectStops() async throws {
        let session = URLSession(configuration: .ephemeral)
        defer { session.invalidateAndCancel() }

        let url = URL(string: "http://127.0.0.1:8765/start")!
        var request = URLRequest(url: url)
        request.setValue("fixture-only", forHTTPHeaderField: "X-Test-Token")
        let (_, response) = try await session.data(
            for: request,
            delegate: SameOriginGate()
        )

        XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 302)
        XCTAssertEqual(response.url?.port, 8765)
    }
}

転送を拒否する際に完了ハンドラーへ nil を渡すと、URLSession は二つ目のポートへ要求せず、元の 302 レスポンスをテストに返します。したがって、二つ目のテストが期待するのはネットワークエラーではなく 302 です。例の X-Test-Token は、要求に機密性のあるヘッダーが含まれ得ることを示すためのものです。転送先のサービスがそのヘッダーを実際に受け取っていないことまで検証するには、フィクスチャで二つ目のポートへの要求回数を記録し、ゼロであることをアサートする必要があります。

失敗したら層ごとに切り分ける

両方のテストで接続に失敗する場合は、まずフィクスチャのプロセスが動いているか、テストが本当にフィクスチャを起動した Mac 上で実行されているかを確認します。127.0.0.1 は常に要求を送るデバイス自身を指します。同じテストを iOS シミュレータ内で直接実行すると、接続先はシミュレータ自身のローカルアドレスになり、Mac 上のフィクスチャには接続できない場合があります。シミュレータで実行するプロジェクトでは、到達可能なテストホストのアドレスを設定し、サーバーの公開範囲は信頼できるテストネットワーク内に限定してください。

同一オリジンのテストで 302 が返る場合は、デリゲートを data(for:delegate:) に渡しているか、要求とレスポンスのスキーム・ホスト・ポートが想定どおりかを確認します。異なるオリジンのテストで予期せず 200 が返る場合は、コールバックで completionHandler(request) を呼んでいないかを優先して確認してください。別のネットワークラッパーがテスト用セッションを差し替えていないかも確認します。

最後に、フィクスチャが起動できること、二つのテストでそれぞれ 200 と 302 が返ること、テスト終了後にサーバープロセスが終了すること、ログに本物のトークンが含まれないことを、パイプラインの受け入れ条件に加えます。将来リダイレクトのルールを緩めるときは、先に新たに許可するケースのテストを追加し、それからデリゲートを変更してください。そうすれば、ポリシーの変更を実行可能な記録として残せます。

よくある質問

ホストが同じでもポートが変われば別オリジンですか?

はい。オリジンはスキーム、ホスト、ポートの組み合わせです。8765 から 8766 への遷移は別オリジンとして扱います。

リダイレクトを拒否したテストで 302 が返るのはなぜですか?

デリゲートが completionHandler に nil を渡すと、URLSession は遷移先を要求せず、元のリダイレクト応答を返します。

実機で試してみませんか?

専有 Mac mini をレンタルして、ビルドを実行

M4搭載の物理マシン2種類から選び、用途に合わせて利用期間とロケーションを指定できます。利用状況は管理画面に表示される最新情報をご確認ください。

Mac mini をレンタル