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.
This commit is contained in:
KhooLy 2026-07-20 14:43:39 +03:00
parent 7d3456ec7b
commit cbbe4f488f
2 changed files with 193 additions and 0 deletions

View file

@ -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<addrinfo>?
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<addrinfo>? = 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])
}
}

View file

@ -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)
}
}