Skip to content

Commit 2013836

Browse files
committed
refactor(connections): cap command output, precise timeout, log malformed source (#1254)
1 parent 7a1e6c6 commit 2013836

5 files changed

Lines changed: 99 additions & 10 deletions

File tree

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -814,7 +814,7 @@ private struct StoredConnection: Codable {
814814
sshTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .sshTunnelModeJson)
815815
cloudflareTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudflareTunnelModeJson)
816816
additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields)
817-
passwordSource = try? container.decodeIfPresent(PasswordSource.self, forKey: .passwordSource)
817+
passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource)
818818
localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
819819
isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false
820820
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false

TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ enum PasswordSourceResolver {
1313
private static let logger = Logger(subsystem: "com.TablePro", category: "PasswordSourceResolver")
1414

1515
private static let commandTimeoutSeconds: UInt64 = 30
16+
private static let maxOutputBytes = 1_048_576
1617

1718
enum ResolutionError: LocalizedError {
1819
case fileNotFound(path: String)
1920
case fileUnreadable(path: String)
2021
case environmentVariableNotSet(name: String)
2122
case commandFailed(exitCode: Int32, stderr: String)
2223
case commandTimedOut
24+
case outputTooLarge
2325
case emptyPassword
2426

2527
var errorDescription: String? {
@@ -45,6 +47,8 @@ enum PasswordSourceResolver {
4547
return String(format: String(localized: "Password command failed (exit %d): %@"), exitCode, message)
4648
case .commandTimedOut:
4749
return String(localized: "Password command timed out after 30 seconds")
50+
case .outputTooLarge:
51+
return String(localized: "Password command produced too much output")
4852
case .emptyPassword:
4953
return String(localized: "The password source produced an empty password")
5054
}
@@ -94,11 +98,15 @@ enum PasswordSourceResolver {
9498
process.standardOutput = stdoutPipe
9599
process.standardError = stderrPipe
96100

97-
let stdoutCollector = PipeDataCollector()
98-
let stderrCollector = PipeDataCollector()
101+
let stdoutCollector = PipeDataCollector(maxBytes: maxOutputBytes)
102+
let stderrCollector = PipeDataCollector(maxBytes: maxOutputBytes)
99103
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
100104
let chunk = handle.availableData
101-
if !chunk.isEmpty { stdoutCollector.append(chunk) }
105+
guard !chunk.isEmpty else { return }
106+
stdoutCollector.append(chunk)
107+
if stdoutCollector.overflowed, process.isRunning {
108+
process.terminate()
109+
}
102110
}
103111
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
104112
let chunk = handle.availableData
@@ -107,9 +115,13 @@ enum PasswordSourceResolver {
107115

108116
try process.run()
109117

118+
let didTimeout = AtomicFlag()
110119
let timeoutTask = Task.detached {
111120
try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000)
112-
if process.isRunning { process.terminate() }
121+
if process.isRunning {
122+
didTimeout.set()
123+
process.terminate()
124+
}
113125
}
114126

115127
process.waitUntilExit()
@@ -118,7 +130,10 @@ enum PasswordSourceResolver {
118130
stdoutPipe.fileHandleForReading.readabilityHandler = nil
119131
stderrPipe.fileHandleForReading.readabilityHandler = nil
120132

121-
if process.terminationReason == .uncaughtSignal {
133+
if stdoutCollector.overflowed {
134+
throw ResolutionError.outputTooLarge
135+
}
136+
if didTimeout.isSet {
122137
throw ResolutionError.commandTimedOut
123138
}
124139
if process.terminationStatus != 0 {
@@ -167,12 +182,34 @@ enum PasswordSourceResolver {
167182

168183
private final class PipeDataCollector: @unchecked Sendable {
169184
private let lock = NSLock()
185+
private let maxBytes: Int
170186
private var data = Data()
187+
private var didOverflow = false
188+
189+
init(maxBytes: Int) {
190+
self.maxBytes = maxBytes
191+
}
171192

172193
func append(_ chunk: Data) {
173194
lock.lock()
174-
data.append(chunk)
175-
lock.unlock()
195+
defer { lock.unlock() }
196+
let remaining = maxBytes - data.count
197+
guard remaining > 0 else {
198+
didOverflow = true
199+
return
200+
}
201+
if chunk.count > remaining {
202+
data.append(chunk.prefix(remaining))
203+
didOverflow = true
204+
} else {
205+
data.append(chunk)
206+
}
207+
}
208+
209+
var overflowed: Bool {
210+
lock.lock()
211+
defer { lock.unlock() }
212+
return didOverflow
176213
}
177214

178215
var string: String {
@@ -181,3 +218,20 @@ private final class PipeDataCollector: @unchecked Sendable {
181218
return String(data: data, encoding: .utf8) ?? ""
182219
}
183220
}
221+
222+
private final class AtomicFlag: @unchecked Sendable {
223+
private let lock = NSLock()
224+
private var value = false
225+
226+
func set() {
227+
lock.lock()
228+
value = true
229+
lock.unlock()
230+
}
231+
232+
var isSet: Bool {
233+
lock.lock()
234+
defer { lock.unlock() }
235+
return value
236+
}
237+
}

TablePro/Models/Connection/DatabaseConnection.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ extension DatabaseConnection: Codable {
553553
localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
554554
isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false
555555
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
556-
passwordSource = try? container.decodeIfPresent(PasswordSource.self, forKey: .passwordSource)
556+
passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource)
557557
cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled
558558

559559
// Migrate from legacy fields if sshTunnelMode is not present

TablePro/Models/Connection/PasswordSource.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//
55

66
import Foundation
7+
import os
78

89
/// Declares where a connection's password comes from when it is not stored in the Keychain.
910
/// Resolved at connect time from a file, an environment variable, or the stdout of a shell command.
@@ -12,6 +13,8 @@ enum PasswordSource: Codable, Hashable, Sendable {
1213
case env(variable: String)
1314
case command(shell: String)
1415

16+
private static let logger = Logger(subsystem: "com.TablePro", category: "PasswordSource")
17+
1518
private enum CodingKeys: String, CodingKey {
1619
case kind, path, variable, shell
1720
}
@@ -53,4 +56,18 @@ enum PasswordSource: Codable, Hashable, Sendable {
5356
try container.encode(shell, forKey: .shell)
5457
}
5558
}
59+
60+
/// Decodes a password source from a connection container, treating a present-but-malformed
61+
/// entry as absent so one bad connection cannot fail loading of the whole store.
62+
static func resilientlyDecoded<Key>(
63+
from container: KeyedDecodingContainer<Key>,
64+
forKey key: Key
65+
) -> PasswordSource? {
66+
do {
67+
return try container.decodeIfPresent(PasswordSource.self, forKey: key)
68+
} catch {
69+
logger.warning("Ignoring malformed passwordSource in a connection")
70+
return nil
71+
}
72+
}
5673
}

TableProTests/Core/Utilities/PasswordSourceResolverTests.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Foundation
77
import Testing
88
@testable import TablePro
99

10-
@Suite("PasswordSourceResolver")
10+
@Suite("PasswordSourceResolver", .serialized)
1111
struct PasswordSourceResolverTests {
1212
@Test("Reads a password from a file")
1313
func fileHappyPath() async throws {
@@ -154,6 +154,24 @@ struct PasswordSourceResolverTests {
154154
}
155155
}
156156

157+
@Test("Throws when command output exceeds the size cap")
158+
func commandOutputTooLarge() async {
159+
do {
160+
_ = try await PasswordSourceResolver.resolveCommand(
161+
shell: "head -c 2000000 /dev/zero | tr '\\0' 'a'",
162+
timeoutSeconds: 30
163+
)
164+
Issue.record("Expected resolveCommand to reject oversized output")
165+
} catch let error as PasswordSourceResolver.ResolutionError {
166+
guard case .outputTooLarge = error else {
167+
Issue.record("Expected outputTooLarge, got \(error)")
168+
return
169+
}
170+
} catch {
171+
Issue.record("Unexpected error: \(error)")
172+
}
173+
}
174+
157175
@Test("Times out a slow command")
158176
func commandTimesOut() async {
159177
do {

0 commit comments

Comments
 (0)