mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-09 00:17:35 +00:00
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:
parent
7d3456ec7b
commit
cbbe4f488f
2 changed files with 193 additions and 0 deletions
95
appleApp/AppleCore/FluxaAppleNetGuard.swift
Normal file
95
appleApp/AppleCore/FluxaAppleNetGuard.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
98
appleApp/AppleCore/FluxaApplePluginHttpClient.swift
Normal file
98
appleApp/AppleCore/FluxaApplePluginHttpClient.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue