Add iPad support, auto-pinning, and comprehensive logging
- Adaptive iPhone/iPad layout with NavigationSplitView sidebar - Auto-detect SSL-pinned domains, fall back to passthrough - Certificate install via local HTTP server (Safari profile flow) - App Group-backed CA, per-domain leaf cert LRU cache - DB-backed config repository, Darwin notification throttling - Rules engine, breakpoint rules, pinned domain tracking - os.Logger instrumentation across tunnel/proxy/mitm/capture/cert/rules/db/ipc/ui - Fix dyld framework embed, race conditions, thread safety
This commit is contained in:
203
ProxyCore/Sources/Shared/HTTPBodyDecoder.swift
Normal file
203
ProxyCore/Sources/Shared/HTTPBodyDecoder.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
import zlib
|
||||
|
||||
public enum HTTPBodyDecoder {
|
||||
private static let inflateChunkSize = 64 * 1024
|
||||
private static let maxDecodedBodyBytes = 8 * 1024 * 1024
|
||||
|
||||
public static func headerValue(named name: String, in headers: [String: String]) -> String? {
|
||||
headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
|
||||
}
|
||||
|
||||
public static func decodingHint(for body: Data?, headers: [String: String]) -> String {
|
||||
guard let body, !body.isEmpty else { return "empty" }
|
||||
|
||||
let encodings = contentEncodings(in: headers)
|
||||
if encodings.isEmpty {
|
||||
return hasGzipMagic(body) ? "gzip-magic-no-header" : "identity"
|
||||
}
|
||||
|
||||
var current = body
|
||||
var applied: [String] = []
|
||||
|
||||
for encoding in encodings.reversed() {
|
||||
switch encoding {
|
||||
case "identity":
|
||||
continue
|
||||
case "gzip", "x-gzip":
|
||||
guard let decoded = inflatePayload(current, windowBits: 47) else {
|
||||
return "failed(\(encoding))"
|
||||
}
|
||||
current = decoded
|
||||
applied.append(encoding)
|
||||
case "deflate":
|
||||
guard let decoded = inflateDeflatePayload(current) else {
|
||||
return "failed(\(encoding))"
|
||||
}
|
||||
current = decoded
|
||||
applied.append(encoding)
|
||||
default:
|
||||
return "unsupported(\(encoding))"
|
||||
}
|
||||
}
|
||||
|
||||
if applied.isEmpty {
|
||||
return "identity"
|
||||
}
|
||||
|
||||
return "decoded(\(applied.joined(separator: ",")))"
|
||||
}
|
||||
|
||||
public static func decodedBodyData(from body: Data?, headers: [String: String]) -> Data? {
|
||||
guard let body, !body.isEmpty else { return nil }
|
||||
|
||||
let encodings = contentEncodings(in: headers)
|
||||
if encodings.isEmpty {
|
||||
return hasGzipMagic(body) ? inflatePayload(body, windowBits: 47) : body
|
||||
}
|
||||
|
||||
var current = body
|
||||
|
||||
for encoding in encodings.reversed() {
|
||||
switch encoding {
|
||||
case "identity":
|
||||
continue
|
||||
case "gzip", "x-gzip":
|
||||
guard let decoded = inflatePayload(current, windowBits: 47) else { return nil }
|
||||
current = decoded
|
||||
case "deflate":
|
||||
guard let decoded = inflateDeflatePayload(current) else { return nil }
|
||||
current = decoded
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
public static func searchableText(from body: Data?, headers: [String: String]) -> String? {
|
||||
guard let body, !body.isEmpty else { return nil }
|
||||
|
||||
let candidate = decodedBodyData(from: body, headers: headers) ?? body
|
||||
|
||||
if let jsonText = searchableJSONText(from: candidate) {
|
||||
return jsonText
|
||||
}
|
||||
|
||||
guard let string = String(data: candidate, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!string.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return string
|
||||
}
|
||||
|
||||
private static func contentEncodings(in headers: [String: String]) -> [String] {
|
||||
guard let value = headerValue(named: "Content-Encoding", in: headers)?
|
||||
.lowercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private static func searchableJSONText(from data: Data) -> String? {
|
||||
guard let jsonObject = try? JSONSerialization.jsonObject(with: data) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fragments = flattenedSearchFragments(from: jsonObject)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
guard !fragments.isEmpty else { return nil }
|
||||
return fragments.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func flattenedSearchFragments(from value: Any) -> [String] {
|
||||
switch value {
|
||||
case let dict as [String: Any]:
|
||||
return dict.sorted(by: { $0.key < $1.key }).flatMap { key, nestedValue in
|
||||
[key] + flattenedSearchFragments(from: nestedValue)
|
||||
}
|
||||
case let array as [Any]:
|
||||
return array.flatMap(flattenedSearchFragments(from:))
|
||||
case let string as String:
|
||||
return [string]
|
||||
case let number as NSNumber:
|
||||
return ["\(number)"]
|
||||
case _ as NSNull:
|
||||
return []
|
||||
default:
|
||||
return ["\(value)"]
|
||||
}
|
||||
}
|
||||
|
||||
private static func inflateDeflatePayload(_ data: Data) -> Data? {
|
||||
inflatePayload(data, windowBits: 47) ??
|
||||
inflatePayload(data, windowBits: 15) ??
|
||||
inflatePayload(data, windowBits: -15)
|
||||
}
|
||||
|
||||
private static func inflatePayload(_ data: Data, windowBits: Int32) -> Data? {
|
||||
guard !data.isEmpty else { return Data() }
|
||||
|
||||
return data.withUnsafeBytes { rawBuffer in
|
||||
guard let sourceBytes = rawBuffer.bindMemory(to: Bytef.self).baseAddress else {
|
||||
return Data()
|
||||
}
|
||||
|
||||
var stream = z_stream()
|
||||
stream.next_in = UnsafeMutablePointer(mutating: sourceBytes)
|
||||
stream.avail_in = uInt(data.count)
|
||||
|
||||
let status = inflateInit2_(&stream, windowBits, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size))
|
||||
guard status == Z_OK else { return nil }
|
||||
defer { inflateEnd(&stream) }
|
||||
|
||||
var output = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: inflateChunkSize)
|
||||
|
||||
while true {
|
||||
let result = buffer.withUnsafeMutableBufferPointer { outputBuffer -> Int32 in
|
||||
stream.next_out = outputBuffer.baseAddress
|
||||
stream.avail_out = uInt(outputBuffer.count)
|
||||
return zlib.inflate(&stream, Z_SYNC_FLUSH)
|
||||
}
|
||||
|
||||
let produced = inflateChunkSize - Int(stream.avail_out)
|
||||
if produced > 0 {
|
||||
buffer.withUnsafeBufferPointer { outputBuffer in
|
||||
guard let baseAddress = outputBuffer.baseAddress else { return }
|
||||
output.append(baseAddress, count: produced)
|
||||
}
|
||||
|
||||
if output.count >= maxDecodedBodyBytes {
|
||||
return Data(output.prefix(maxDecodedBodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
switch result {
|
||||
case Z_STREAM_END:
|
||||
return output
|
||||
case Z_OK:
|
||||
continue
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasGzipMagic(_ data: Data) -> Bool {
|
||||
guard data.count >= 2 else { return false }
|
||||
return data[data.startIndex] == 0x1f && data[data.index(after: data.startIndex)] == 0x8b
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user