From f887c621950ae7c8503ea43cc556aa13a8cb5169 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Tue, 9 Jun 2026 06:30:39 +0330 Subject: [PATCH] feat: enforce dynamic browser extension pairing security - Generate random pairing token in AppSettings - Update LocalExtensionServer to strict check token on non-OPTIONS endpoints - Add GET /ping endpoint for extension connection verification - Redesign Integration settings pane with modern step-by-step UI and Toast notifications - Harden media extraction and aria2 engine paths - Update IP resolution with getaddrinfo --- Sources/Firelink/AppSettings.swift | 10 +- Sources/Firelink/Aria2DownloadEngine.swift | 11 +- .../Firelink/DownloadMetadataFetcher.swift | 87 +++++++-- Sources/Firelink/FirelinkApp.swift | 2 +- .../Firelink/KeychainCredentialStore.swift | 3 +- Sources/Firelink/LocalExtensionServer.swift | 35 ++-- Sources/Firelink/MediaExtractionEngine.swift | 15 +- .../Settings/IntegrationSettingsPane.swift | 179 ++++++++++++++---- .../Firelink/Settings/ToastNotification.swift | 45 +++++ 9 files changed, 301 insertions(+), 86 deletions(-) create mode 100644 Sources/Firelink/Settings/ToastNotification.swift diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 3017efa..7a18849 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -183,6 +183,10 @@ final class AppSettings: ObservableObject { didSet { save() } } + @Published var extensionPairingToken: String { + didSet { save() } + } + @Published var message = "" private let defaults: UserDefaults @@ -204,6 +208,7 @@ final class AppSettings: ObservableObject { proxySettings = stored.proxySettings?.normalized ?? ProxySettings() siteLogins = stored.siteLogins mediaCookieSource = stored.mediaCookieSource ?? .none + extensionPairingToken = stored.extensionPairingToken ?? UUID().uuidString downloadDirectories = Self.decodeDirectories(stored.downloadDirectories) } else { appTheme = .system @@ -216,6 +221,7 @@ final class AppSettings: ObservableObject { proxySettings = ProxySettings() siteLogins = [] mediaCookieSource = .none + extensionPairingToken = UUID().uuidString downloadDirectories = Self.defaultDirectories() } @@ -332,7 +338,8 @@ final class AppSettings: ObservableObject { proxySettings: proxySettings.normalized, downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }), siteLogins: siteLogins, - mediaCookieSource: mediaCookieSource + mediaCookieSource: mediaCookieSource, + extensionPairingToken: extensionPairingToken ) let defaults = self.defaults let storageKey = self.storageKey @@ -404,4 +411,5 @@ private struct StoredSettings: Codable { var downloadDirectories: [String: String] var siteLogins: [SiteLogin] var mediaCookieSource: BrowserCookieSource? + var extensionPairingToken: String? } diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index 1c5efd4..1c2d9f9 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -56,21 +56,14 @@ final class Aria2DownloadEngine { let candidates = [ "/opt/homebrew/bin/aria2c", "/usr/local/bin/aria2c", - "/usr/bin/aria2c" + "/usr/bin/aria2c", + "/opt/local/bin/aria2c" ] if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { return URL(fileURLWithPath: found) } - let path = ProcessInfo.processInfo.environment["PATH"] ?? "" - for folder in path.split(separator: ":") { - let candidate = URL(fileURLWithPath: String(folder)).appendingPathComponent("aria2c") - if FileManager.default.isExecutableFile(atPath: candidate.path) { - return candidate - } - } - return nil } diff --git a/Sources/Firelink/DownloadMetadataFetcher.swift b/Sources/Firelink/DownloadMetadataFetcher.swift index d06365a..a6acd8e 100644 --- a/Sources/Firelink/DownloadMetadataFetcher.swift +++ b/Sources/Firelink/DownloadMetadataFetcher.swift @@ -134,23 +134,80 @@ enum DownloadMetadataFetcher { if h == "localhost" || h.hasSuffix(".local") { return true } if !h.contains(".") && !h.contains(":") { return true } - let parts = h.split(separator: ".") - if parts.count == 4, let first = Int(parts[0]), let second = Int(parts[1]) { - if first == 127 || first == 10 || (first == 192 && second == 168) { - return true - } - if first == 172 && (16...31).contains(second) { - return true - } - if first == 169 && second == 254 { - return true - } - } + var hints = addrinfo( + ai_flags: 0, + ai_family: AF_UNSPEC, + ai_socktype: SOCK_STREAM, + ai_protocol: 0, + ai_addrlen: 0, + ai_canonname: nil, + ai_addr: nil, + ai_next: nil + ) - if h.contains(":") { - if h == "[::1]" || h.hasPrefix("[fc") || h.hasPrefix("[fd") || h.hasPrefix("[fe8") || h.hasPrefix("[fe9") || h.hasPrefix("[fea") || h.hasPrefix("[feb") { - return true + var res: UnsafeMutablePointer? + if getaddrinfo(host, nil, &hints, &res) == 0 { + var current = res + while let info = current { + let family = info.pointee.ai_family + if family == AF_INET { + let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } + let ip = UInt32(bigEndian: addr.sin_addr.s_addr) + let first = (ip >> 24) & 0xFF + let second = (ip >> 16) & 0xFF + + if first == 127 || first == 10 || (first == 192 && second == 168) { + freeaddrinfo(res) + return true + } + if first == 172 && (16...31).contains(second) { + freeaddrinfo(res) + return true + } + if first == 169 && second == 254 { + freeaddrinfo(res) + return true + } + } else if family == AF_INET6 { + let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } + let bytes = addr.sin6_addr.__u6_addr.__u6_addr8 + + let isLoopback = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 && + bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 && + bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0 && bytes.11 == 0 && + bytes.12 == 0 && bytes.13 == 0 && bytes.14 == 0 && bytes.15 == 1 + + let isULA = (bytes.0 & 0xFE) == 0xFC + let isLinkLocal = bytes.0 == 0xFE && (bytes.1 & 0xC0) == 0x80 + + let isIPv4Mapped = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 && + bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 && + bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0xFF && bytes.11 == 0xFF + + if isLoopback || isULA || isLinkLocal { + freeaddrinfo(res) + return true + } + if isIPv4Mapped { + let first = bytes.12 + let second = bytes.13 + if first == 127 || first == 10 || (first == 192 && second == 168) { + freeaddrinfo(res) + return true + } + if first == 172 && (16...31).contains(second) { + freeaddrinfo(res) + return true + } + if first == 169 && second == 254 { + freeaddrinfo(res) + return true + } + } + } + current = info.pointee.ai_next } + freeaddrinfo(res) } return false } diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index 4bd3859..85caa96 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -93,7 +93,7 @@ struct FirelinkApp: App { _controller = StateObject(wrappedValue: controller) _schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller)) - extensionServer = LocalExtensionServer(downloadController: controller) + extensionServer = LocalExtensionServer(downloadController: controller, settings: settings) extensionServer?.start() controller.extensionServerPort = extensionServer?.port } diff --git a/Sources/Firelink/KeychainCredentialStore.swift b/Sources/Firelink/KeychainCredentialStore.swift index 09f968f..3f07361 100644 --- a/Sources/Firelink/KeychainCredentialStore.swift +++ b/Sources/Firelink/KeychainCredentialStore.swift @@ -30,7 +30,8 @@ enum KeychainCredentialStore { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: id.uuidString, - kSecValueData as String: Data(password.utf8) + kSecValueData as String: Data(password.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly ] return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index 27f493d..af74f57 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -9,20 +9,24 @@ final class LocalExtensionServer: @unchecked Sendable { static let maxURLCount = 200 static let extensionRequestHeader = "x-firelink-extension" - // Firelink Companion 1.0.7+ sends this token. Keep accepted tokens here - // when future store releases need a non-breaking local API transition. - static let supportedExtensionTokens = Set(["firelink-extension-v1"]) + // Firelink Companion sends this token. + // We now use a dynamic token generated in AppSettings, but fallback to this + // for backward compatibility during the extension rollout if needed, though + // we'll enforce the dynamic token strictly in the processRequest method. + static let legacyExtensionToken = "firelink-extension-v1" static let allowedSchemes = Set(["http", "https", "ftp", "sftp"]) } private let listener: NWListener private let downloadController: DownloadController + private let settings: AppSettings private let queue = DispatchQueue(label: "local.firelink.server") let port: UInt16 - init?(downloadController: DownloadController) { + init?(downloadController: DownloadController, settings: AppSettings) { self.downloadController = downloadController + self.settings = settings let parameters = NWParameters.tcp var createdListener: NWListener? @@ -124,10 +128,6 @@ final class LocalExtensionServer: @unchecked Sendable { } private func processRequest(_ request: HTTPRequest) -> HTTPStatus { - guard request.path == "/download" else { - return .notFound - } - let host = request.header(named: "host") ?? "" let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost" guard isLocalhost else { @@ -138,13 +138,22 @@ final class LocalExtensionServer: @unchecked Sendable { return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden } - guard request.method == "POST" else { - return .methodNotAllowed + let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken } + guard let token = request.header(named: Constants.extensionRequestHeader), + token == expectedToken else { + return .forbidden } - guard let token = request.header(named: Constants.extensionRequestHeader), - Constants.supportedExtensionTokens.contains(token) else { - return .forbidden + if request.path == "/ping" { + return request.method == "GET" ? .ok : .methodNotAllowed + } + + guard request.path == "/download" else { + return .notFound + } + + guard request.method == "POST" else { + return .methodNotAllowed } guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else { diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift index 2ee2c64..e949b34 100644 --- a/Sources/Firelink/MediaExtractionEngine.swift +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -139,18 +139,19 @@ enum MediaExtractionEngine { } private static func executablePath(named name: String, candidates: [String]) -> String? { - if let path = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { - return path - } + var safeCandidates = candidates + safeCandidates.append(contentsOf: [ + "/opt/homebrew/bin/\(name)", + "/usr/local/bin/\(name)", + "/usr/bin/\(name)", + "/opt/local/bin/\(name)" + ]) - let pathEnvironment = ProcessInfo.processInfo.environment["PATH"] ?? "" - for directory in pathEnvironment.split(separator: ":") { - let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent(name).path + for candidate in safeCandidates { if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } } - return nil } diff --git a/Sources/Firelink/Settings/IntegrationSettingsPane.swift b/Sources/Firelink/Settings/IntegrationSettingsPane.swift index 5982baa..dfd1f4a 100644 --- a/Sources/Firelink/Settings/IntegrationSettingsPane.swift +++ b/Sources/Firelink/Settings/IntegrationSettingsPane.swift @@ -3,51 +3,80 @@ import SwiftUI struct IntegrationSettingsPane: View { @EnvironmentObject private var controller: DownloadController + @EnvironmentObject private var settings: AppSettings + @State private var showToast = false var body: some View { - Form { - Section { - HStack(alignment: .center, spacing: 14) { - Image(systemName: "puzzlepiece.extension") + ScrollView { + VStack(spacing: 24) { + // Header + HStack(alignment: .center, spacing: 16) { + Image(systemName: "puzzlepiece.extension.fill") .resizable() + .aspectRatio(contentMode: .fit) .frame(width: 48, height: 48) - .foregroundStyle(.orange) + .foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0))) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { - Text("Firefox Extension") - .font(.title2.weight(.semibold)) - Text("Capture downloads directly from your browser.") + Text("Connect Browser Extension") + .font(.title.weight(.bold)) + Text("Capture downloads directly from your browser in three easy steps.") .foregroundStyle(.secondary) + .font(.body) + } + Spacer() + } + .padding(.bottom, 8) + + // Step 1: Copy Token + StepCardView( + stepNumber: 1, + title: "Copy Pairing Token", + description: "This secure token authorizes your browser extension.", + icon: "doc.on.clipboard.fill", + iconColor: .blue, + actionText: "Copy Token" + ) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string) + withAnimation { + showToast = true } } - .padding(.vertical, 4) - } - Section("Installation") { - VStack(alignment: .leading, spacing: 16) { - Text("Firelink Companion is officially available on the Mozilla Add-on store. Install it to easily intercept downloads and send media directly to Firelink.") + // Step 2: Open Browser + StepCardView( + stepNumber: 2, + title: "Open Firefox", + description: "Launch your browser. If you haven't installed the extension yet, do that first.", + icon: "safari.fill", + iconColor: .orange, + actionText: "Open Firefox" + ) { + if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") { + NSWorkspace.shared.open(url) + } + } + + // Step 3: Paste and Save + StepCardView( + stepNumber: 3, + title: "Paste & Connect", + description: "Click the Firelink icon in your browser's toolbar and paste the token into the App Pairing Token field.", + icon: "arrow.down.doc.fill", + iconColor: .green, + actionText: nil, + action: nil + ) + + Divider() + + // Diagnostics + HStack { + Text("Diagnostics:") .foregroundStyle(.secondary) - - Button { - if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") { - NSWorkspace.shared.open(url) - } - } label: { - Label("Install on Firefox", systemImage: "arrow.down.app") - .font(.headline) - .padding(.horizontal, 12) - .padding(.vertical, 4) - } - .buttonStyle(.borderedProminent) - .tint(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0))) // Firefox Orange - .controlSize(.large) - } - .padding(.vertical, 8) - } - - Section("Diagnostics") { - LabeledContent("Local receiver") { + Spacer() if let port = controller.extensionServerPort { Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill") .foregroundStyle(.green) @@ -56,14 +85,86 @@ struct IntegrationSettingsPane: View { .foregroundStyle(.orange) } } - } + .font(.footnote) + .padding(.top, 8) - Section("Permissions & Privacy") { - Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.") - .font(.caption) - .foregroundStyle(.secondary) } + .padding(32) } - .formStyle(.grouped) + .toast(isShowing: $showToast, message: "Token copied to clipboard!") + .background(Color(NSColor.windowBackgroundColor)) + } +} + +struct StepCardView: View { + let stepNumber: Int + let title: String + let description: String + let icon: String + let iconColor: Color + let actionText: String? + let action: (() -> Void)? + + var body: some View { + HStack(spacing: 16) { + // Step Number Badge + ZStack { + Circle() + .fill(Color(nsColor: .controlBackgroundColor)) + .frame(width: 32, height: 32) + .shadow(color: .black.opacity(0.1), radius: 2, y: 1) + + Text("\(stepNumber)") + .font(.system(.headline, design: .rounded).weight(.bold)) + .foregroundStyle(.primary) + } + + // Icon + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(iconColor.opacity(0.15)) + .frame(width: 48, height: 48) + + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundStyle(iconColor) + } + + // Text Content + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + // Action Button + if let actionText = actionText, let action = action { + Button(action: action) { + Text(actionText) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(nsColor: .controlBackgroundColor)) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1) + ) } } diff --git a/Sources/Firelink/Settings/ToastNotification.swift b/Sources/Firelink/Settings/ToastNotification.swift new file mode 100644 index 0000000..5e1d7ca --- /dev/null +++ b/Sources/Firelink/Settings/ToastNotification.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct ToastNotification: ViewModifier { + var message: String + @Binding var isShowing: Bool + + func body(content: Content) -> some View { + ZStack(alignment: .bottom) { + content + + if isShowing { + VStack { + Spacer() + Text(message) + .font(.subheadline.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Capsule() + .fill(Color.black.opacity(0.8)) + .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4) + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 24) + } + .zIndex(1) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + isShowing = false + } + } + } + } + } + } +} + +extension View { + func toast(isShowing: Binding, message: String) -> some View { + self.modifier(ToastNotification(message: message, isShowing: isShowing)) + } +}