diff --git a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift index 9ee76c9..f235ee3 100644 --- a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift +++ b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift @@ -1,4 +1,7 @@ import SwiftUI +import AppKit +import ReverseAPIProxy +import UniformTypeIdentifiers struct CaptureToolbar: View { @Environment(AppState.self) private var state @@ -43,6 +46,7 @@ struct CaptureToolbar: View { SidebarSectionLabel("Actions") trustButton systemProxyButton + exportButton clearButton } @@ -119,6 +123,34 @@ struct CaptureToolbar: View { } } + private func exportHAR() { + let panel = NSSavePanel() + panel.allowedContentTypes = [UTType(filenameExtension: "har") ?? .json, .json] + panel.nameFieldStringValue = "rae-\(Self.exportTimestamp()).har" + panel.canCreateDirectories = true + let response = panel.runModal() + guard response == .OK, let url = panel.url else { return } + let snapshot = state.store.flows + Task { + do { + try await Task.detached(priority: .userInitiated) { + let data = try HARExporter.export(snapshot) + try data.write(to: url, options: .atomic) + }.value + } catch { + await MainActor.run { + _ = NSAlert(error: error).runModal() + } + } + } + } + + private static func exportTimestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter.string(from: Date()) + } + private var captureButton: some View { Button { Task { await state.toggleCapture() } @@ -161,6 +193,7 @@ struct CaptureToolbar: View { .help(state.captureMode == .device ? "Start proxy capture and route macOS HTTP/HTTPS traffic through it" : "Start proxy capture without changing macOS network settings") + .keyboardShortcut("r", modifiers: [.command]) } private var trustButton: some View { @@ -205,6 +238,18 @@ struct CaptureToolbar: View { .help("Toggle macOS HTTP/HTTPS proxy for active network services") } + private var exportButton: some View { + Button { + exportHAR() + } label: { + SidebarActionLabel(title: "Export HAR", systemImage: "square.and.arrow.up") + } + .buttonStyle(.plain) + .disabled(state.store.flows.isEmpty || state.isWorking) + .help("Export all captured flows to a .har file") + .keyboardShortcut("e", modifiers: [.command, .shift]) + } + private var clearButton: some View { Button { state.clearFlows() @@ -214,6 +259,7 @@ struct CaptureToolbar: View { .buttonStyle(.plain) .disabled(state.store.flows.isEmpty || state.isWorking) .help("Remove captured flows from the list and local database") + .keyboardShortcut("k", modifiers: [.command]) } private var captureTitle: String { diff --git a/macos/Sources/ReverseAPIProxy/CA/CAStore.swift b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift index 81eb7cf..bce9cc8 100644 --- a/macos/Sources/ReverseAPIProxy/CA/CAStore.swift +++ b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift @@ -2,33 +2,31 @@ import Foundation import Crypto import X509 import SwiftASN1 -import Security public enum CAStoreError: Error { case missingCertificateOnDisk - case missingPrivateKeyInKeychain - case keychainWriteFailed(OSStatus) - case keychainReadFailed(OSStatus) - case keychainDeleteFailed(OSStatus) + case missingPrivateKeyOnDisk case invalidStoredPrivateKey case certificateDeleteFailed(any Error) case privateKeyDeleteFailed(any Error) + case privateKeyWriteFailed(any Error) } public final class CAStore: @unchecked Sendable { public let directory: URL public let certificateURL: URL - - private let keychainService = "app.reverseapi" - private let keychainAccount = "ca.root-private-key" - private let legacyPrivateKeyURL: URL + public let privateKeyURL: URL public init(applicationSupportURL: URL) throws { let root = applicationSupportURL.appendingPathComponent("ReverseAPI", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) self.directory = root self.certificateURL = root.appendingPathComponent("root.cer") - self.legacyPrivateKeyURL = root.appendingPathComponent("root-key.pem") + self.privateKeyURL = root.appendingPathComponent("root-key.pem") } public func loadOrCreate() throws -> RootCertificate { @@ -69,86 +67,33 @@ public final class CAStore: @unchecked Sendable { throw CAStoreError.certificateDeleteFailed(error) } } - if manager.fileExists(atPath: legacyPrivateKeyURL.path) { + if manager.fileExists(atPath: privateKeyURL.path) { do { - try manager.removeItem(at: legacyPrivateKeyURL) + try manager.removeItem(at: privateKeyURL) } catch { throw CAStoreError.privateKeyDeleteFailed(error) } } - try deletePrivateKey() } public func exists() -> Bool { guard FileManager.default.fileExists(atPath: certificateURL.path) else { return false } - return privateKeyExists() + return FileManager.default.fileExists(atPath: privateKeyURL.path) } private func storePrivateKeyPEM(_ data: Data) throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - ] - - var addQuery = query - addQuery[kSecValueData as String] = data - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let status = SecItemAdd(addQuery as CFDictionary, nil) - if status == errSecDuplicateItem { - let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) - guard updateStatus == errSecSuccess else { throw CAStoreError.keychainWriteFailed(updateStatus) } - return + do { + try data.write(to: privateKeyURL, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyURL.path) + } catch { + throw CAStoreError.privateKeyWriteFailed(error) } - guard status == errSecSuccess else { throw CAStoreError.keychainWriteFailed(status) } } private func loadPrivateKeyPEM() throws -> Data { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound, FileManager.default.fileExists(atPath: legacyPrivateKeyURL.path) { - let data = try Data(contentsOf: legacyPrivateKeyURL) - try storePrivateKeyPEM(data) - try? FileManager.default.removeItem(at: legacyPrivateKeyURL) - return data - } - if status == errSecItemNotFound { - throw CAStoreError.missingPrivateKeyInKeychain - } - guard status == errSecSuccess, let data = result as? Data else { - throw CAStoreError.keychainReadFailed(status) - } - return data - } - - private func privateKeyExists() -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - kSecReturnData as String: false, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - let status = SecItemCopyMatching(query as CFDictionary, nil) - return status == errSecSuccess || FileManager.default.fileExists(atPath: legacyPrivateKeyURL.path) - } - - private func deletePrivateKey() throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - ] - let status = SecItemDelete(query as CFDictionary) - if status != errSecSuccess && status != errSecItemNotFound { - throw CAStoreError.keychainDeleteFailed(status) + guard FileManager.default.fileExists(atPath: privateKeyURL.path) else { + throw CAStoreError.missingPrivateKeyOnDisk } + return try Data(contentsOf: privateKeyURL) } } diff --git a/macos/Sources/ReverseAPIProxy/Export/HARExporter.swift b/macos/Sources/ReverseAPIProxy/Export/HARExporter.swift new file mode 100644 index 0000000..256defb --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/Export/HARExporter.swift @@ -0,0 +1,115 @@ +import Foundation + +public enum HARExporter { + private static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + public static func export(_ flows: [CapturedFlow]) throws -> Data { + let entries = flows.map(entry(for:)) + let har: [String: Any] = [ + "log": [ + "version": "1.2", + "creator": ["name": "rae", "version": "0.1"], + "entries": entries, + ] + ] + return try JSONSerialization.data(withJSONObject: har, options: [.prettyPrinted, .sortedKeys]) + } + + static func entry(for flow: CapturedFlow) -> [String: Any] { + let started = Self.dateFormatter.string(from: flow.startedAt) + let duration = ((flow.finishedAt ?? flow.startedAt).timeIntervalSince(flow.startedAt)) * 1000 + + let requestContentType = header(flow.requestHeaders, "content-type") + let responseContentType = header(flow.responseHeaders, "content-type") + + var request: [String: Any] = [ + "method": flow.method, + "url": flow.url, + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": flow.requestHeaders.map { ["name": $0.name, "value": $0.value] }, + "queryString": queryString(from: flow.path), + "headersSize": -1, + "bodySize": flow.requestBody.count, + ] + if !flow.requestBody.isEmpty { + var postData: [String: Any] = ["mimeType": requestContentType ?? ""] + if let text = String(data: flow.requestBody, encoding: .utf8) { + postData["text"] = text + } else { + postData["encoding"] = "base64" + postData["text"] = flow.requestBody.base64EncodedString() + } + request["postData"] = postData + } + + var responseContent: [String: Any] = [ + "size": flow.responseBody.count, + "mimeType": responseContentType ?? "", + ] + if !flow.responseBody.isEmpty { + if let text = String(data: flow.responseBody, encoding: .utf8) { + responseContent["text"] = text + } else { + responseContent["encoding"] = "base64" + responseContent["text"] = flow.responseBody.base64EncodedString() + } + } + + var record: [String: Any] = [ + "startedDateTime": started, + "time": duration, + "request": request, + "response": [ + "status": flow.responseStatus ?? 0, + "statusText": "", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": flow.responseHeaders.map { ["name": $0.name, "value": $0.value] }, + "content": responseContent, + "redirectURL": "", + "headersSize": -1, + "bodySize": flow.responseBody.count, + ], + "cache": [:], + "timings": [ + "send": 0, + "wait": duration, + "receive": 0, + ], + ] + if let error = flow.error { + record["_error"] = error + } + return record + } + + private static func header(_ headers: [HTTPHeader], _ name: String) -> String? { + let lower = name.lowercased() + return headers.first(where: { $0.name.lowercased() == lower })?.value + } + + static func queryString(from path: String) -> [[String: String]] { + guard let queryIndex = path.firstIndex(of: "?") else { return [] } + let rawQuery = path[path.index(after: queryIndex)...] + let query = rawQuery.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first ?? "" + return query.split(separator: "&").compactMap { pair in + let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard let name = parts.first else { return nil } + let value = parts.count > 1 ? String(parts[1]) : "" + return [ + "name": decodeFormComponent(String(name)), + "value": decodeFormComponent(value), + ] + } + } + + static func decodeFormComponent(_ value: String) -> String { + let withSpaces = value.replacingOccurrences(of: "+", with: " ") + return withSpaces.removingPercentEncoding ?? withSpaces + } +} diff --git a/macos/Sources/ReverseAPIProxy/Proxy/ProxyHandler.swift b/macos/Sources/ReverseAPIProxy/Proxy/ProxyHandler.swift index 17656be..e411ccb 100644 --- a/macos/Sources/ReverseAPIProxy/Proxy/ProxyHandler.swift +++ b/macos/Sources/ReverseAPIProxy/Proxy/ProxyHandler.swift @@ -95,6 +95,20 @@ final class ProxyHandler: ChannelInboundHandler, RemovableChannelHandler, @unche let channel = channelContext.channel let eventLoop = channelContext.eventLoop + if isWebSocketUpgrade(inflight.head) { + let flow = makeFlow(from: inflight) + var failed = flow + failed.responseStatus = Int(HTTPResponseStatus.badGateway.code) + failed.error = "WebSocket upgrades are not supported yet" + failed.finishedAt = Date() + Task { + await proxyContext.bus.emit(.started(flow)) + await proxyContext.bus.emit(.finished(failed)) + } + respondError(channelContext: channelContext, status: .badGateway) + return + } + var headersForUpstream = inflight.head.headers sanitizeRequestHeaders(&headersForUpstream) let flow = makeFlow(from: inflight) @@ -187,6 +201,17 @@ final class ProxyHandler: ChannelInboundHandler, RemovableChannelHandler, @unche headers.replaceOrAdd(name: "Accept-Encoding", value: "identity") } + static func isWebSocketUpgrade(_ head: HTTPRequestHead) -> Bool { + let tokens = head.headers["Upgrade"] + .flatMap { $0.split(separator: ",") } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + return tokens.contains("websocket") + } + + private func isWebSocketUpgrade(_ head: HTTPRequestHead) -> Bool { + Self.isWebSocketUpgrade(head) + } + private func makeFlow(from inflight: InflightRequest) -> CapturedFlow { var flow = CapturedFlow( scheme: inflight.scheme, diff --git a/macos/Tests/ReverseAPIProxyTests/CertificateAuthorityTests.swift b/macos/Tests/ReverseAPIProxyTests/CertificateAuthorityTests.swift index e12ded8..9b5e93e 100644 --- a/macos/Tests/ReverseAPIProxyTests/CertificateAuthorityTests.swift +++ b/macos/Tests/ReverseAPIProxyTests/CertificateAuthorityTests.swift @@ -46,6 +46,37 @@ final class CertificateAuthorityTests: XCTestCase { XCTAssertEqual(first.privateKey.publicKey, second.privateKey.publicKey) } + func testCAStorePersistsPrivateKeyOnDiskWithUserOnlyPermissions() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = try CAStore(applicationSupportURL: directory) + let first = try store.loadOrCreate() + let second = try store.loadOrCreate() + let attributes = try FileManager.default.attributesOfItem(atPath: store.privateKeyURL.path) + let permissions = attributes[.posixPermissions] as? NSNumber + + XCTAssertTrue(FileManager.default.fileExists(atPath: store.certificateURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.privateKeyURL.path)) + XCTAssertEqual(permissions?.intValue, 0o600) + XCTAssertEqual(try first.derBytes(), try second.derBytes()) + XCTAssertEqual(first.privateKey.publicKey, second.privateKey.publicKey) + } + + func testCAStoreRegeneratesWhenCertificateExistsWithoutPrivateKeyFile() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = try CAStore(applicationSupportURL: directory) + let stale = try CertificateAuthority.generateRoot() + try Data(try stale.derBytes()).write(to: store.certificateURL, options: .atomic) + + let regenerated = try store.loadOrCreate() + + XCTAssertNotEqual(try stale.derBytes(), try regenerated.derBytes()) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.privateKeyURL.path)) + } + func testLeafCertificateFactoryProducesLeafForHost() async throws { let root = try CertificateAuthority.generateRoot() let factory = try LeafCertificateFactory(root: root) diff --git a/macos/Tests/ReverseAPIProxyTests/HARExporterTests.swift b/macos/Tests/ReverseAPIProxyTests/HARExporterTests.swift new file mode 100644 index 0000000..8da68a8 --- /dev/null +++ b/macos/Tests/ReverseAPIProxyTests/HARExporterTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import ReverseAPIProxy + +final class HARExporterTests: XCTestCase { + // MARK: - queryString + + func testQueryStringEmpty() { + XCTAssertEqual(HARExporter.queryString(from: "/users").count, 0) + } + + func testQueryStringSinglePair() { + let result = HARExporter.queryString(from: "/users?id=42") + XCTAssertEqual(result, [["name": "id", "value": "42"]]) + } + + func testQueryStringMultiplePairs() { + let result = HARExporter.queryString(from: "/users?id=42&name=alice") + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result[0]["name"], "id") + XCTAssertEqual(result[1]["name"], "name") + } + + func testQueryStringPlusDecodedAsSpace() { + let result = HARExporter.queryString(from: "/search?q=hello+world") + XCTAssertEqual(result, [["name": "q", "value": "hello world"]]) + } + + func testQueryStringPercentEncodedDecoded() { + let result = HARExporter.queryString(from: "/search?q=hello%20world") + XCTAssertEqual(result, [["name": "q", "value": "hello world"]]) + } + + func testQueryStringMixedPlusAndPercent() { + let result = HARExporter.queryString(from: "/search?q=a+b%20c") + XCTAssertEqual(result, [["name": "q", "value": "a b c"]]) + } + + func testQueryStringIgnoresFragment() { + let result = HARExporter.queryString(from: "/users?id=42#section") + XCTAssertEqual(result, [["name": "id", "value": "42"]]) + } + + func testQueryStringKeyOnly() { + let result = HARExporter.queryString(from: "/x?debug") + XCTAssertEqual(result, [["name": "debug", "value": ""]]) + } + + // MARK: - decodeFormComponent + + func testDecodeFormComponentPlusIsSpace() { + XCTAssertEqual(HARExporter.decodeFormComponent("hello+world"), "hello world") + } + + func testDecodeFormComponentPercentEncoding() { + XCTAssertEqual(HARExporter.decodeFormComponent("caf%C3%A9"), "café") + } + + // MARK: - entry + + func testEntryOmitsPostDataForGet() { + let flow = CapturedFlow(scheme: .https, method: "GET", host: "api.example.com", port: 443, path: "/users") + let entry = HARExporter.entry(for: flow) + guard let request = entry["request"] as? [String: Any] else { + return XCTFail("missing request object") + } + XCTAssertNil(request["postData"], "postData must be absent for empty request body") + } + + func testEntryIncludesPostDataForPost() { + var flow = CapturedFlow(scheme: .https, method: "POST", host: "api.example.com", port: 443, path: "/users") + flow.requestBody = Data("{\"name\":\"x\"}".utf8) + flow.requestHeaders = [HTTPHeader("content-type", "application/json")] + let entry = HARExporter.entry(for: flow) + guard let request = entry["request"] as? [String: Any], + let postData = request["postData"] as? [String: Any] else { + return XCTFail() + } + XCTAssertEqual(postData["mimeType"] as? String, "application/json") + XCTAssertEqual(postData["text"] as? String, "{\"name\":\"x\"}") + } + + func testEntryBase64EncodesBinaryResponseBody() { + var flow = CapturedFlow(scheme: .https, method: "GET", host: "h", port: 443, path: "/") + flow.responseBody = Data([0x00, 0x01, 0xFE, 0xFF]) + let entry = HARExporter.entry(for: flow) + guard let response = entry["response"] as? [String: Any], + let content = response["content"] as? [String: Any] else { + return XCTFail() + } + XCTAssertEqual(content["encoding"] as? String, "base64") + XCTAssertEqual(content["text"] as? String, "AAH+/w==") + } + + func testEntryAttachesErrorWhenPresent() { + var flow = CapturedFlow(scheme: .https, method: "GET", host: "h", port: 443, path: "/") + flow.error = "boom" + let entry = HARExporter.entry(for: flow) + XCTAssertEqual(entry["_error"] as? String, "boom") + } + + func testEntryAbsentErrorOnSuccess() { + let flow = CapturedFlow(scheme: .https, method: "GET", host: "h", port: 443, path: "/") + let entry = HARExporter.entry(for: flow) + XCTAssertNil(entry["_error"]) + } + + // MARK: - full export + + func testExportProducesValidHARStructure() throws { + var flow = CapturedFlow(scheme: .https, method: "GET", host: "api.example.com", port: 443, path: "/v1/x?q=hi") + flow.responseStatus = 200 + flow.responseBody = Data("{}".utf8) + flow.responseHeaders = [HTTPHeader("Content-Type", "application/json")] + let data = try HARExporter.export([flow]) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertNotNil(json?["log"]) + if let log = json?["log"] as? [String: Any] { + XCTAssertEqual(log["version"] as? String, "1.2") + XCTAssertEqual((log["entries"] as? [Any])?.count, 1) + } + } +} diff --git a/macos/Tests/ReverseAPIProxyTests/WebSocketDetectionTests.swift b/macos/Tests/ReverseAPIProxyTests/WebSocketDetectionTests.swift new file mode 100644 index 0000000..49e1fd0 --- /dev/null +++ b/macos/Tests/ReverseAPIProxyTests/WebSocketDetectionTests.swift @@ -0,0 +1,53 @@ +import XCTest +import NIOHTTP1 +@testable import ReverseAPIProxy + +final class WebSocketDetectionTests: XCTestCase { + func testDetectsLowercaseWebsocket() { + var headers = HTTPHeaders() + headers.add(name: "Upgrade", value: "websocket") + let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/ws", headers: headers) + XCTAssertTrue(ProxyHandler.isWebSocketUpgrade(head)) + } + + func testDetectsCapitalizedWebSocket() { + var headers = HTTPHeaders() + headers.add(name: "Upgrade", value: "WebSocket") + let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/ws", headers: headers) + XCTAssertTrue(ProxyHandler.isWebSocketUpgrade(head)) + } + + func testDetectsCommaSeparatedValues() { + var headers = HTTPHeaders() + headers.add(name: "Upgrade", value: "h2c, websocket") + let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/ws", headers: headers) + XCTAssertTrue(ProxyHandler.isWebSocketUpgrade(head)) + } + + func testDetectsWebsocketAsSecondToken() { + var headers = HTTPHeaders() + headers.add(name: "Upgrade", value: "h2c,websocket") + let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/ws", headers: headers) + XCTAssertTrue(ProxyHandler.isWebSocketUpgrade(head)) + } + + func testDoesNotDetectWhenAbsent() { + var headers = HTTPHeaders() + headers.add(name: "Upgrade", value: "h2c") + let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/ws", headers: headers) + XCTAssertFalse(ProxyHandler.isWebSocketUpgrade(head)) + } + + func testDoesNotDetectWhenHeaderMissing() { + let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/", headers: HTTPHeaders()) + XCTAssertFalse(ProxyHandler.isWebSocketUpgrade(head)) + } + + func testMultipleUpgradeHeaderLinesScanned() { + var headers = HTTPHeaders() + headers.add(name: "Upgrade", value: "h2c") + headers.add(name: "Upgrade", value: "websocket") + let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/", headers: headers) + XCTAssertTrue(ProxyHandler.isWebSocketUpgrade(head)) + } +}