git.iko.soy/parseable-macos
on master help sign in
Fix 4 bugs: auth redirect exploit, CRLF line-comment offset, live-tail polling skip, ServerInfoView flash
commite0ac4e43e8c4188bf842cafac35e4c68ecb7c1a0 authoriko <mail@iko.soy> dateMon 22 Jun 2026, 07:32 (3 months ago) parent47d7125
4 files changed+83−4
ParseableViewer/Models/SQLTokenizer.swift+5−1 ParseableViewer/Services/ParseableClient.swift+61−1 ParseableViewer/ViewModels/LiveTailViewModel.swift+9 ParseableViewer/Views/ServerInfoView.swift+8−2
ParseableViewer/Models/SQLTokenizer.swift+5−1
@@ -86,7 +86,11 @@ struct SQLTokenizer: Sendable {
8686
8787 // Line comment
8888 case "-" where peek(sql, at: i, offset: 1) == "-":
89− advance(&i, in: sql, while: { $0 != "\n" })
89+ // Stop at CR as well as LF: with CRLF (`\r\n`) line endings, consuming
90+ // the trailing `\r` into the comment token would push its boundary past
91+ // where `characterOffset`/DataFusion treat the line as ending, shifting
92+ // the server-error highlight range. `\r` is then tokenized as whitespace.
93+ advance(&i, in: sql, while: { $0 != "\n" && $0 != "\r" })
9094 tokens.append(Token(kind: .lineComment, range: start..<i))
9195
9296 // Block comment
ParseableViewer/Services/ParseableClient.swift+61−1
@@ -88,6 +88,58 @@ private actor ResponseCache {
8888 }
8989}
9090
91+/// Strips the `Authorization` header on redirects that cross to a different
92+/// origin. By default `URLSession` re-sends the original request's headers —
93+/// including the `Basic` credentials this client attaches to every request —
94+/// to the redirect target. A server (or a malicious/misconfigured proxy)
95+/// answering with a 3xx to an unrelated host would otherwise harvest the user's
96+/// credentials. Same-origin redirects keep the header so normal auth still works.
97+///
98+/// Holds only an immutable `URL`, so it is safely `Sendable` despite the
99+/// NSObject base (which isn't formally `Sendable`).
100+private final class RedirectSanitizer: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
101+ private let baseURL: URL
102+
103+ init(baseURL: URL) {
104+ self.baseURL = baseURL
105+ }
106+
107+ func urlSession(
108+ _ session: URLSession,
109+ task: URLSessionTask,
110+ willPerformHTTPRedirection response: HTTPURLResponse,
111+ newRequest request: URLRequest,
112+ completionHandler: @escaping (URLRequest?) -> Void
113+ ) {
114+ guard !RedirectSanitizer.sameOrigin(baseURL, request.url) else {
115+ completionHandler(request)
116+ return
117+ }
118+ var sanitized = request
119+ sanitized.setValue(nil, forHTTPHeaderField: "Authorization")
120+ completionHandler(sanitized)
121+ }
122+
123+ /// Origin equality per scheme/host/port, normalizing the implicit default
124+ /// port so `https://host` and `https://host:443` are treated as the same
125+ /// origin (and aren't stripped on a legitimate same-host redirect).
126+ static func sameOrigin(_ a: URL, _ b: URL?) -> Bool {
127+ guard let b else { return false }
128+ return a.scheme?.lowercased() == b.scheme?.lowercased()
129+ && a.host?.lowercased() == b.host?.lowercased()
130+ && effectivePort(a) == effectivePort(b)
131+ }
132+
133+ private static func effectivePort(_ url: URL) -> Int? {
134+ if let port = url.port { return port }
135+ switch url.scheme?.lowercased() {
136+ case "https": return 443
137+ case "http": return 80
138+ default: return nil
139+ }
140+ }
141+}
142+
91143final class ParseableClient: Sendable {
92144 let baseURL: URL
93145 let username: String
@@ -126,7 +178,15 @@ final class ParseableClient: Sendable {
126178 let config = URLSessionConfiguration.default
127179 config.timeoutIntervalForRequest = 30
128180 config.timeoutIntervalForResource = 120
129− self.session = URLSession(configuration: config)
181+ // Install a delegate that drops the Authorization header on cross-origin
182+ // redirects so Basic credentials can't be forwarded to an unrelated host.
183+ // The session retains the delegate until `finishTasksAndInvalidate()` in
184+ // deinit releases it, so there's no leak.
185+ self.session = URLSession(
186+ configuration: config,
187+ delegate: RedirectSanitizer(baseURL: url),
188+ delegateQueue: nil
189+ )
130190 }
131191
132192 convenience init(connection: ServerConnection) throws {
ParseableViewer/ViewModels/LiveTailViewModel.swift+9
@@ -193,6 +193,11 @@ final class LiveTailViewModel {
193193 pollGeneration += 1
194194 isRunning = true
195195 isPaused = false
196+ // Reset the in-flight guard: if a poll was awaiting its network round-trip
197+ // when the tail was stopped, its `defer { isPolling = false }` only runs when
198+ // that request finally returns. Without clearing it here, the new timer's
199+ // first tick would hit `guard !isPolling` and be skipped for a whole interval.
200+ isPolling = false
196201 errorMessage = nil
197202 entries = []
198203 seenFingerprints = []
@@ -243,6 +248,10 @@ final class LiveTailViewModel {
243248
244249 func clear() {
245250 pollGeneration += 1
251+ // Match start(): drop the in-flight guard so a poll still awaiting its
252+ // response (whose results this generation bump will discard) can't make the
253+ // next timer tick skip itself via `guard !isPolling`.
254+ isPolling = false
246255 entries = []
247256 seenFingerprints = []
248257 seenFingerprintOrder = []
ParseableViewer/Views/ServerInfoView.swift+8−2
@@ -3,7 +3,10 @@ import SwiftUI
33struct ServerInfoView: View {
44 @Environment(AppState.self) private var appState
55 @State private var about: ServerAbout?
6− @State private var isLoading = false
6+ // Start in the loading state: the `.task` always runs `loadInfo()` on appear, so
7+ // initializing to `false` would paint the `else` branch's red "Unhealthy" dot for
8+ // one frame before the check begins. Begin neutral ("Checking…") instead.
9+ @State private var isLoading = true
710 @State private var isHealthy = false
811 @State private var errorMessage: String?
912
@@ -151,7 +154,10 @@ struct ServerInfoView: View {
151154 }
152155
153156 private func loadInfo() async {
154− guard let client = appState.client else { return }
157+ // Clear isLoading on the no-client path: since isLoading now starts `true`
158+ // (to avoid a red "Unhealthy" flash before the first check), an early return
159+ // without resetting it would wedge the spinner permanently.
160+ guard let client = appState.client else { isLoading = false; return }
155161 let connID = appState.activeConnection?.id
156162 isLoading = true
157163 errorMessage = nil