a native ios client for gitbay

client ios swift

https://gitbay.org

gitbayTests/SecurityTests.swift

ui-smoke
gitbay-ios/gitbayTests/SecurityTests.swift history · blame · raw

100 lines · 4576 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5/// The trust-surface invariants from the v1 security sweep (#2). These
  6/// pin behavior so a refactor cannot quietly loosen it.
  7struct SecuritySweepTests {
  8
  9    // MARK: - Transport
 10
 11    @Test func theDefaultSessionStoresNothing() {
 12        let configuration = GitbayClient.makeEphemeralSession().configuration
 13        // No disk cache: private repo content must never land in Cache.db.
 14        #expect(configuration.urlCache == nil)
 15        // No cookies accepted or sent  the API is bearer-token only.
 16        #expect(configuration.httpCookieAcceptPolicy == .never)
 17        #expect(configuration.httpShouldSetCookies == false)
 18        // Ephemeral: no credential or cookie storage backed by disk.
 19        #expect(configuration.httpCookieStorage?.cookies?.isEmpty ?? true)
 20    }
 21
 22    @Test func theTokenOnlyTravelsToTheInstanceHost() async throws {
 23        let box = StubProtocol.box()
 24        let client = GitbayClient(
 25            instance: try GitbayInstance(url: "https://gitbay.org"),
 26            token: "gb_secret",
 27            session: box.session()
 28        )
 29        box.enqueue(.init(status: 200, json:
 30            #"{"protocol_version":1,"data":{"username":"cmc"},"exit_code":0}"#))
 31
 32        nonisolated struct Who: Decodable, Sendable { let username: String }
 33        _ = try await client.read(["whoami"], as: Who.self)
 34
 35        let seen = try #require(box.seen.first)
 36        #expect(seen.url.host() == "gitbay.org")
 37        #expect(seen.headers["Authorization"] == "Bearer gb_secret")
 38        // And no cookie header ever accompanies it.
 39        #expect(seen.headers["Cookie"] == nil)
 40    }
 41
 42    @Test func redirectsOffTheInstanceAreRefusedByHostCheck() throws {
 43        let instance = try GitbayInstance(url: "https://gitbay.org")
 44        // The exact checks RedirectGuard and perform() rely on.
 45        #expect(!instance.isOwn(URL(string: "https://evil.example/api/v1/read")!))
 46        #expect(!instance.isOwn(URL(string: "http://gitbay.org/api/v1/read")!))       // downgrade
 47        #expect(!instance.isOwn(URL(string: "https://gitbay.org.evil.example/x")!))   // suffix trick
 48        #expect(!instance.isOwn(URL(string: "https://gitbay.org:8443/x")!))           // port swap
 49        #expect(instance.isOwn(URL(string: "https://GITBAY.ORG/api/v1/cmd")!))
 50    }
 51
 52    @Test func plaintextHTTPIsRefusedForAnythingButLoopback() {
 53        #expect(throws: GitbayInstance.InvalidURL.self) {
 54            _ = try GitbayInstance(url: "http://forge.example")
 55        }
 56        #expect(throws: GitbayInstance.InvalidURL.self) {
 57            // A LAN address is not loopback; ATS would block it too.
 58            _ = try GitbayInstance(url: "http://192.168.1.10")
 59        }
 60        #expect((try? GitbayInstance(url: "http://localhost:3000")) != nil)
 61        #expect((try? GitbayInstance(url: "http://127.0.0.1:3000")) != nil)
 62    }
 63
 64    // MARK: - Storage
 65
 66    @Test func nothingTokenShapedReachesUserDefaults() async throws {
 67        let defaults = try #require(UserDefaults(suiteName: "security.\(UUID().uuidString)"))
 68        let store = MemoryTokenStore()
 69        let box = StubProtocol.box()
 70        box.enqueue(.init(status: 200, json:
 71            #"{"protocol_version":1,"data":{"username":"cmc"},"exit_code":0}"#))
 72        let session = await SessionStore(store: store, defaults: defaults) { instance, token in
 73            GitbayClient(instance: instance, token: token, session: box.session())
 74        }
 75
 76        try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret_value")
 77
 78        // The only thing persisted outside the token store is the account
 79        // id, and no persisted value contains the token.
 80        let persisted = defaults.persistentDomain(forName: defaults.description) ?? [:]
 81        _ = persisted
 82        for (key, value) in defaults.dictionaryRepresentation() {
 83            if let text = value as? String {
 84                #expect(!text.contains("gb_secret_value"),
 85                        "token leaked into UserDefaults key \(key)")
 86            }
 87        }
 88        #expect(store.token(for: "https://gitbay.org#cmc") == "gb_secret_value")
 89    }
 90
 91    @Test func theCacheKeyIsAOneWayDigestOfTheToken() throws {
 92        // Two clients with different tokens must not share ETag cache
 93        // keys, and the key must not contain the token itself.
 94        let store = ETagStore()
 95        _ = store
 96        let key = ETagStore.key(account: "0a1b2c3d4e5f6a7b", argv: ["repo", "list"])
 97        #expect(!key.contains("gb_"))
 98        #expect(key.contains("repo"))
 99    }
100}