a native ios client for gitbay

client ios swift

https://gitbay.org

security sweep: ephemeral transport, log privacy, snapshot hygiene !11

merged cmc wants to merge krz/gitbay-ios:security-sweep into main

3 files changed, +115 −2

gitbay/Networking/GitbayClient.swift +14 −2
@@ -35,10 +35,22 @@ nonisolated final class GitbayClient: Sendable {
3535 unauthorizedHandler.withLock { $0 = handler }
3636 }
3737
38 /// The default transport: ephemeral, so nothing an authenticated
39 /// request returns is written to the on-disk URL cache, and no cookie
40 /// or credential outlives the process. `URLSession.shared` would
41 /// store private repo content in an unprotected Cache.db.
42 nonisolated static func makeEphemeralSession() -> URLSession {
43 let configuration = URLSessionConfiguration.ephemeral
44 configuration.httpCookieAcceptPolicy = .never
45 configuration.httpShouldSetCookies = false
46 configuration.urlCache = nil
47 return URLSession(configuration: configuration)
48 }
49
3850 init(
3951 instance: GitbayInstance,
4052 token: String,
41 session: URLSession = .shared,
53 session: URLSession = GitbayClient.makeEphemeralSession(),
4254 etags: ETagStore = ETagStore()
4355 ) {
4456 self.instance = instance
@@ -314,7 +326,7 @@ nonisolated final class GitbayClient: Sendable {
314326 logger.error("read refused: \(message, privacy: .public)")
315327 return .notReadable(message)
316328 case 400:
317 logger.error("usage error: \(argv, privacy: .private)\(message, privacy: .public)")
329 logger.error("usage error: \(argv, privacy: .private)\(message, privacy: .private)")
318330 return .usage(message)
319331 default: return .failure(message)
320332 }
gitbay/Views/SignInView.swift +1
@@ -37,6 +37,7 @@ struct SignInView: View {
3737 .focused($tokenFieldFocused)
3838 .submitLabel(.go)
3939 .onSubmit { signIn() }
40 .privacySensitive()
4041 } header: {
4142 Text("Token")
4243 } footer: {
gitbayTests/SecurityTests.swift added +100
@@ -0,0 +1,100 @@
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}