Files
ProxyIOS/ProxyCore/Sources/Shared/HTTPBodyDecoder.swift
Trey t 148bc3887c 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
2026-04-11 12:52:18 -05:00

204 lines
6.9 KiB
Swift

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