gitbayTests/SecurityTests.swift
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}