| @@ -0,0 +1,100 @@ |
| 1 | import Foundation |
| 2 | import 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. |
| 7 | struct 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 | } |