From cbbe4f488f273ed3203e833bb3a3207fd06baf21 Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:43:39 +0300 Subject: [PATCH] Add Swift PluginHttpClient implementation for iOS plugin scrapers Implements the generated PluginHttpClient callback interface synchronously via URLSession, with an SSRF guard (scheme allow-list plus resolved-address blocklist) mirroring Android's PluginNetGuard. --- appleApp/AppleCore/FluxaAppleNetGuard.swift | 95 ++++++++++++++++++ .../FluxaApplePluginHttpClient.swift | 98 +++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 appleApp/AppleCore/FluxaAppleNetGuard.swift create mode 100644 appleApp/AppleCore/FluxaApplePluginHttpClient.swift diff --git a/appleApp/AppleCore/FluxaAppleNetGuard.swift b/appleApp/AppleCore/FluxaAppleNetGuard.swift new file mode 100644 index 0000000..422c1f3 --- /dev/null +++ b/appleApp/AppleCore/FluxaAppleNetGuard.swift @@ -0,0 +1,95 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#endif + +enum FluxaAppleNetGuard { + static func isSchemeAllowed(_ scheme: String?) -> Bool { + guard let scheme = scheme?.lowercased() else { return false } + return scheme == "http" || scheme == "https" + } + + static func resolveAllowedAddresses(host: String) -> [[UInt8]]? { + 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 + ) + var resultPointer: UnsafeMutablePointer? + let status = getaddrinfo(host, nil, &hints, &resultPointer) + guard status == 0, let firstResult = resultPointer else { return nil } + defer { freeaddrinfo(resultPointer) } + + var addresses: [[UInt8]] = [] + var pointer: UnsafeMutablePointer? = firstResult + while let info = pointer { + if let addr = info.pointee.ai_addr { + if info.pointee.ai_family == AF_INET { + let sockaddrIn = addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } + var inAddr = sockaddrIn.sin_addr + let bytes = withUnsafeBytes(of: &inAddr) { Array($0) } + addresses.append(bytes) + } else if info.pointee.ai_family == AF_INET6 { + let sockaddrIn6 = addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } + var in6Addr = sockaddrIn6.sin6_addr + let bytes = withUnsafeBytes(of: &in6Addr) { Array($0) } + addresses.append(bytes) + } + } + pointer = info.pointee.ai_next + } + + guard !addresses.isEmpty, !addresses.contains(where: isBlockedAddress) else { return nil } + return addresses + } + + static func isBlockedAddress(_ bytes: [UInt8]) -> Bool { + switch bytes.count { + case 4: + return isBlockedIPv4(bytes) + case 16: + if let mapped = ipv4Mapped(bytes) { + return isBlockedIPv4(mapped) + } + return isBlockedIPv6(bytes) + default: + return true + } + } + + private static func isBlockedIPv4(_ bytes: [UInt8]) -> Bool { + guard bytes.count == 4 else { return true } + let a = Int(bytes[0]) + let b = Int(bytes[1]) + if a == 127 { return true } + if a == 10 { return true } + if a == 172, (16...31).contains(b) { return true } + if a == 192, b == 168 { return true } + if a == 169, b == 254 { return true } + if bytes == [0, 0, 0, 0] { return true } + if bytes == [255, 255, 255, 255] { return true } + if a == 100, (64...127).contains(b) { return true } + return false + } + + private static func isBlockedIPv6(_ bytes: [UInt8]) -> Bool { + guard bytes.count == 16 else { return true } + if bytes.allSatisfy({ $0 == 0 }) { return true } + if bytes[0..<15].allSatisfy({ $0 == 0 }), bytes[15] == 1 { return true } + let firstSegment = (Int(bytes[0]) << 8) | Int(bytes[1]) + if (firstSegment & 0xfe00) == 0xfc00 { return true } + if (firstSegment & 0xffc0) == 0xfe80 { return true } + return false + } + + private static func ipv4Mapped(_ bytes: [UInt8]) -> [UInt8]? { + guard bytes.count == 16 else { return nil } + guard bytes[0..<10].allSatisfy({ $0 == 0 }), bytes[10] == 0xFF, bytes[11] == 0xFF else { return nil } + return Array(bytes[12..<16]) + } +} diff --git a/appleApp/AppleCore/FluxaApplePluginHttpClient.swift b/appleApp/AppleCore/FluxaApplePluginHttpClient.swift new file mode 100644 index 0000000..f9caf92 --- /dev/null +++ b/appleApp/AppleCore/FluxaApplePluginHttpClient.swift @@ -0,0 +1,98 @@ +import Foundation + +private final class FluxaApplePluginRedirectBlockingDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} + +final class FluxaApplePluginHttpClient: PluginHttpClient { + private let session: URLSession + private let delegate = FluxaApplePluginRedirectBlockingDelegate() + private let maxRedirects = 10 + + init() { + session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + } + + func fetch(request: PluginHttpRequest) -> PluginHttpResponse { + var currentUrl = request.url + var redirectsLeft = request.followRedirects ? maxRedirects : 0 + + while true { + guard let url = URL(string: currentUrl), FluxaAppleNetGuard.isSchemeAllowed(url.scheme) else { + return blockedResponse("unsupported scheme or invalid url") + } + guard let host = url.host, FluxaAppleNetGuard.resolveAllowedAddresses(host: host) != nil else { + return blockedResponse("blocked address for host \(url.host ?? currentUrl)") + } + + let method = request.method.isEmpty ? "GET" : request.method.uppercased() + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = method + for (key, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: key) + } + if let body = request.body, method != "GET", method != "HEAD" { + urlRequest.httpBody = Data(body.utf8) + } + + let (data, response, error) = performSynchronously(urlRequest) + if let error { + return blockedResponse(error.localizedDescription) + } + guard let httpResponse = response as? HTTPURLResponse else { + return blockedResponse("no response") + } + + if (300..<400).contains(httpResponse.statusCode), redirectsLeft > 0, + let location = httpResponse.value(forHTTPHeaderField: "Location"), + let redirectUrl = URL(string: location, relativeTo: url) { + currentUrl = redirectUrl.absoluteString + redirectsLeft -= 1 + continue + } + + let body = data.map { String(decoding: $0, as: UTF8.self) } ?? "" + var headers: [String: String] = [:] + for (key, value) in httpResponse.allHeaderFields { + if let keyString = key as? String, let valueString = value as? String { + headers[keyString] = valueString + } + } + let ok = (200..<300).contains(httpResponse.statusCode) + return PluginHttpResponse( + status: UInt16(clamping: httpResponse.statusCode), + headers: headers, + body: body, + ok: ok, + error: ok ? nil : "http_\(httpResponse.statusCode)" + ) + } + } + + private func performSynchronously(_ request: URLRequest) -> (Data?, URLResponse?, Error?) { + let semaphore = DispatchSemaphore(value: 0) + var resultData: Data? + var resultResponse: URLResponse? + var resultError: Error? + session.dataTask(with: request) { data, response, error in + resultData = data + resultResponse = response + resultError = error + semaphore.signal() + }.resume() + semaphore.wait() + return (resultData, resultResponse, resultError) + } + + private func blockedResponse(_ reason: String) -> PluginHttpResponse { + PluginHttpResponse(status: 0, headers: [:], body: "", ok: false, error: reason) + } +}