Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Packages/OsaurusNetworking/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ let package = Package(
.target(
name: "OsaurusNetworking",
path: "Sources"
)
),
.testTarget(
name: "OsaurusNetworkingTests",
dependencies: ["OsaurusNetworking"]
),
]
)
30 changes: 25 additions & 5 deletions Packages/OsaurusNetworking/Sources/GlobalProxyConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,23 +169,43 @@ public struct GlobalProxyConfiguration: Equatable, Sendable {
}

if let address = IPv4Address(normalized) {
let octets = Array(address.rawValue)
return octets[0] == 0
|| octets[0] == 127
|| (octets[0] == 169 && octets[1] == 254)
return isLocalOnlyIPv4(Array(address.rawValue))
}

if let address = IPv6Address(normalized) {
let octets = Array(address.rawValue)
let isUnspecified = octets.allSatisfy { $0 == 0 }
let isLoopback = octets.dropLast().allSatisfy { $0 == 0 } && octets.last == 1
let isLinkLocal = octets[0] == 0xfe && (octets[1] & 0xc0) == 0x80
return isUnspecified || isLoopback || isLinkLocal
if isUnspecified || isLoopback || isLinkLocal {
return true
}
// IPv4-mapped (`::ffff:a.b.c.d`) and IPv4-compatible (`::a.b.c.d`)
// addresses embed an IPv4 address in the low 32 bits. Apply the
// IPv4 local-host rules to it so a loopback / link-local /
// this-network endpoint written in IPv6 form (e.g.
// `::ffff:127.0.0.1`) isn't mistaken for a remote host.
let isV4Mapped =
octets[0 ..< 10].allSatisfy { $0 == 0 } && octets[10] == 0xff && octets[11] == 0xff
let isV4Compatible = octets[0 ..< 12].allSatisfy { $0 == 0 }
if isV4Mapped || isV4Compatible {
return isLocalOnlyIPv4(Array(octets[12 ..< 16]))
}
return false
}

return false
}

/// Loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), and
/// "this network" (`0.0.0.0/8`) IPv4 ranges, given the four address octets.
private static func isLocalOnlyIPv4(_ octets: [UInt8]) -> Bool {
guard octets.count == 4 else { return false }
return octets[0] == 0
|| octets[0] == 127
|| (octets[0] == 169 && octets[1] == 254)
}

private func key(_ value: CFString) -> AnyHashable {
AnyHashable(value as String)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//
// GlobalProxyConfigurationLocalHostTests.swift
// OsaurusNetworking
//

import XCTest

import OsaurusNetworking

final class GlobalProxyConfigurationLocalHostTests: XCTestCase {

private func isRejectedAsUnsafeHost(_ urlString: String) -> Bool {
do {
_ = try GlobalProxyConfiguration(urlString: urlString)
return false
} catch let error as GlobalProxyConfiguration.ValidationError {
if case .unsafeHost = error { return true }
return false
} catch {
return false
}
}

/// IPv4-mapped (`::ffff:a.b.c.d`) and IPv4-compatible (`::a.b.c.d`) IPv6
/// forms address the very same machine-local endpoints as their IPv4 /
/// plain-IPv6 spellings, so they must be rejected as unsafe proxy hosts
/// too. Without decoding the embedded IPv4 the local-host guard only saw
/// `0xff 0xff …` (mapped) or a non-`::1` low word (compatible) and let
/// loopback / link-local addresses through.
func testRejectsIPv4MappedAndCompatibleLocalHosts() {
for url in [
"http://[::ffff:127.0.0.1]:8080", // IPv4-mapped loopback
"http://[::ffff:169.254.1.1]:8080", // IPv4-mapped link-local
"http://[::127.0.0.1]:8080", // IPv4-compatible loopback
] {
XCTAssertTrue(
isRejectedAsUnsafeHost(url),
"\(url) is a machine-local endpoint and must be rejected as unsafe"
)
}
}

/// Regression guard: the plain spellings already worked and must keep
/// being rejected.
func testStillRejectsPlainLocalHosts() {
for url in [
"http://127.0.0.1:8080",
"http://[::1]:8080",
"http://[fe80::1]:8080",
"http://169.254.1.1:8080",
"http://localhost:8080",
] {
XCTAssertTrue(isRejectedAsUnsafeHost(url), "\(url) must remain rejected")
}
}

/// Genuinely remote proxies — including a non-local IPv4-mapped address
/// and a public IPv6 — must NOT be rejected.
func testAcceptsRemoteHosts() {
for url in [
"http://proxy.example.com:8080",
"http://8.8.8.8:8080",
"http://[::ffff:8.8.8.8]:8080", // IPv4-mapped, but public
"http://[2606:4700:4700::1111]:8080", // public IPv6
] {
XCTAssertNoThrow(
try GlobalProxyConfiguration(urlString: url),
"\(url) is remote and must be accepted"
)
}
}
}
Loading