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
e0ac4e4iko
ParseableViewer/Models/SQLTokenizer.swift+5−1
@@ -86,7 +86,11 @@ struct SQLTokenizer: Sendable {
86
86
87 // Line comment
87 // Line comment
88 case "-" where peek(sql, at: i, offset: 1) == "-":
88 case "-" where peek(sql, at: i, offset: 1) == "-":
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.
89− advance(&i, in: sql, while: { $0 != "\n" })
93+ advance(&i, in: sql, while: { $0 != "\n" && $0 != "\r" })
90 tokens.append(Token(kind: .lineComment, range: start..<i))
94 tokens.append(Token(kind: .lineComment, range: start..<i))
91
95
92 // Block comment
96 // Block comment
ParseableViewer/Services/ParseableClient.swift+61−1
@@ -88,6 +88,58 @@ private actor ResponseCache {
88 }
88 }
89}
89}
90
90
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+
91final class ParseableClient: Sendable {
143final class ParseableClient: Sendable {
92 let baseURL: URL
144 let baseURL: URL
93 let username: String
145 let username: String
@@ -126,7 +178,15 @@ final class ParseableClient: Sendable {
126 let config = URLSessionConfiguration.default
178 let config = URLSessionConfiguration.default
127 config.timeoutIntervalForRequest = 30
179 config.timeoutIntervalForRequest = 30
128 config.timeoutIntervalForResource = 120
180 config.timeoutIntervalForResource = 120
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.
129− self.session = URLSession(configuration: config)
185+ self.session = URLSession(
186+ configuration: config,
187+ delegate: RedirectSanitizer(baseURL: url),
188+ delegateQueue: nil
189+ )
130 }
190 }
131
191
132 convenience init(connection: ServerConnection) throws {
192 convenience init(connection: ServerConnection) throws {
ParseableViewer/ViewModels/LiveTailViewModel.swift+9
@@ -193,6 +193,11 @@ final class LiveTailViewModel {
193 pollGeneration += 1
193 pollGeneration += 1
194 isRunning = true
194 isRunning = true
195 isPaused = false
195 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
196 errorMessage = nil
201 errorMessage = nil
197 entries = []
202 entries = []
198 seenFingerprints = []
203 seenFingerprints = []
@@ -243,6 +248,10 @@ final class LiveTailViewModel {
243
248
244 func clear() {
249 func clear() {
245 pollGeneration += 1
250 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
246 entries = []
255 entries = []
247 seenFingerprints = []
256 seenFingerprints = []
248 seenFingerprintOrder = []
257 seenFingerprintOrder = []
ParseableViewer/Views/ServerInfoView.swift+8−2
@@ -3,7 +3,10 @@ import SwiftUI
3struct ServerInfoView: View {
3struct ServerInfoView: View {
4 @Environment(AppState.self) private var appState
4 @Environment(AppState.self) private var appState
5 @State private var about: ServerAbout?
5 @State private var about: ServerAbout?
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.
6− @State private var isLoading = false
9+ @State private var isLoading = true
7 @State private var isHealthy = false
10 @State private var isHealthy = false
8 @State private var errorMessage: String?
11 @State private var errorMessage: String?
9
12
@@ -151,7 +154,10 @@ struct ServerInfoView: View {
151 }
154 }
152
155
153 private func loadInfo() async {
156 private func loadInfo() async {
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.
154− guard let client = appState.client else { return }
160+ guard let client = appState.client else { isLoading = false; return }
155 let connID = appState.activeConnection?.id
161 let connID = appState.activeConnection?.id
156 isLoading = true
162 isLoading = true
157 errorMessage = nil
163 errorMessage = nil