171 lines
7.1 KiB
Swift
171 lines
7.1 KiB
Swift
import Foundation
|
|
|
|
enum ProviderEndpointOverrideError: LocalizedError, Equatable {
|
|
case minimax(String)
|
|
case alibabaCodingPlan(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case let .minimax(key):
|
|
"MiniMax endpoint override \(key) is not allowed. " +
|
|
"Use an HTTPS endpoint without user info or encoded host tricks. " +
|
|
"If MINIMAX_REQUIRE_PROVIDER_ENDPOINT_OVERRIDES=true is set, the endpoint must also be MiniMax-owned."
|
|
case let .alibabaCodingPlan(key):
|
|
"Alibaba Coding Plan endpoint override \(key) is not allowed. " +
|
|
"Use an HTTPS endpoint without user info or encoded host tricks. " +
|
|
"If ALIBABA_CODING_PLAN_REQUIRE_PROVIDER_ENDPOINT_OVERRIDES=true is set, " +
|
|
"the endpoint must also be Alibaba-owned."
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ProviderEndpointOverrideValidator {
|
|
enum HostPolicy {
|
|
case allowAnyHTTPSHost
|
|
case providerOwnedOnly
|
|
}
|
|
|
|
private let allowedHosts: Set<String>
|
|
private let allowedDomainSuffixes: Set<String>
|
|
|
|
init(allowedHosts: [String] = [], allowedDomainSuffixes: [String] = []) {
|
|
self.allowedHosts = Set(allowedHosts.map { $0.lowercased() })
|
|
self.allowedDomainSuffixes = Set(allowedDomainSuffixes.map { $0.lowercased() })
|
|
}
|
|
|
|
func validatedHost(_ raw: String?, policy: HostPolicy = .allowAnyHTTPSHost) -> String? {
|
|
guard let raw,
|
|
let url = self.url(from: raw),
|
|
let host = self.validatedDecodedHost(for: url, policy: policy)
|
|
else { return nil }
|
|
return self.hostAuthority(host: host, port: url.port)
|
|
}
|
|
|
|
func validatedURL(_ raw: String?, policy: HostPolicy = .allowAnyHTTPSHost) -> URL? {
|
|
guard let raw,
|
|
let url = self.url(from: raw),
|
|
self.validatedDecodedHost(for: url, policy: policy) != nil
|
|
else { return nil }
|
|
return url
|
|
}
|
|
|
|
func validatedURLAllowingLoopbackHTTP(_ raw: String?) -> URL? {
|
|
guard let raw,
|
|
Self.hasExplicitURLScheme(raw),
|
|
let url = URL(string: raw),
|
|
let scheme = url.scheme?.lowercased(),
|
|
scheme == "https" || scheme == "http",
|
|
url.user == nil,
|
|
url.password == nil,
|
|
let host = self.validatedDecodedHost(for: url, policy: .allowAnyHTTPSHost),
|
|
scheme == "https" || Self.isLoopbackHost(host)
|
|
else { return nil }
|
|
return url
|
|
}
|
|
|
|
static func normalizedHTTPSURL(from raw: String) -> URL? {
|
|
let url = if Self.hasExplicitURLScheme(raw) {
|
|
URL(string: raw)
|
|
} else {
|
|
URL(string: "https://\(raw)")
|
|
}
|
|
guard let url else { return nil }
|
|
guard let scheme = url.scheme?.lowercased(), scheme == "https" else { return nil }
|
|
guard url.user == nil, url.password == nil else { return nil }
|
|
guard let decodedHost = url.host(percentEncoded: false)?.lowercased(),
|
|
!decodedHost.isEmpty,
|
|
!decodedHost.contains("%"),
|
|
decodedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
|
|
decodedHost.rangeOfCharacter(from: .controlCharacters) == nil,
|
|
let encodedHost = url.host(percentEncoded: true)?.lowercased(),
|
|
Self.hostHasNoEncodedDelimiters(encodedHost, decodedHost: decodedHost, url: url)
|
|
else { return nil }
|
|
return url
|
|
}
|
|
|
|
private func url(from raw: String) -> URL? {
|
|
Self.normalizedHTTPSURL(from: raw)
|
|
}
|
|
|
|
private static func hasExplicitURLScheme(_ raw: String) -> Bool {
|
|
guard let colonIndex = raw.firstIndex(of: ":") else { return false }
|
|
if raw[colonIndex...].hasPrefix("://") { return true }
|
|
|
|
if let authorityEnd = raw.firstIndex(where: { ["/", "?", "#"].contains($0) }),
|
|
colonIndex > authorityEnd
|
|
{
|
|
return false
|
|
}
|
|
|
|
let afterColon = raw.index(after: colonIndex)
|
|
guard afterColon < raw.endIndex else { return true }
|
|
let portEnd = raw[afterColon...].firstIndex { Set<Character>(["/", "?", "#"]).contains($0) } ?? raw.endIndex
|
|
let suffix = raw[afterColon..<portEnd]
|
|
if !suffix.isEmpty, suffix.allSatisfy(\.isNumber) {
|
|
return false
|
|
}
|
|
|
|
let scheme = raw[..<colonIndex]
|
|
guard let first = scheme.first, first.isLetter else { return false }
|
|
return scheme.dropFirst().allSatisfy { $0.isLetter || $0.isNumber || ["+", "-", "."].contains($0) }
|
|
}
|
|
|
|
private static func isLoopbackHost(_ host: String) -> Bool {
|
|
if host == "localhost" || host == "::1" { return true }
|
|
let octets = host.split(separator: ".", omittingEmptySubsequences: false)
|
|
guard octets.count == 4,
|
|
let first = UInt8(octets[0]),
|
|
octets.dropFirst().allSatisfy({ UInt8($0) != nil })
|
|
else { return false }
|
|
return first == 127
|
|
}
|
|
|
|
private func hostAuthority(host: String, port: Int?) -> String {
|
|
let authorityHost = host.contains(":") ? "[\(host)]" : host
|
|
guard let port else { return authorityHost }
|
|
return "\(authorityHost):\(port)"
|
|
}
|
|
|
|
private func validatedDecodedHost(for url: URL, policy: HostPolicy) -> String? {
|
|
guard let decodedHost = url.host(percentEncoded: false)?.lowercased(),
|
|
!decodedHost.isEmpty,
|
|
!decodedHost.contains("%"),
|
|
decodedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
|
|
decodedHost.rangeOfCharacter(from: .controlCharacters) == nil,
|
|
let encodedHost = url.host(percentEncoded: true)?.lowercased(),
|
|
Self.hostHasNoEncodedDelimiters(encodedHost, decodedHost: decodedHost, url: url)
|
|
else { return nil }
|
|
|
|
switch policy {
|
|
case .allowAnyHTTPSHost:
|
|
return decodedHost
|
|
case .providerOwnedOnly:
|
|
let isAllowedHost = self.allowedHosts.contains(decodedHost)
|
|
let isAllowedSuffix = self.allowedDomainSuffixes.contains { suffix in
|
|
decodedHost == suffix || decodedHost.hasSuffix(".\(suffix)")
|
|
}
|
|
guard isAllowedHost || isAllowedSuffix else { return nil }
|
|
return decodedHost
|
|
}
|
|
}
|
|
|
|
private static func hostHasNoEncodedDelimiters(_ encodedHost: String, decodedHost: String, url: URL) -> Bool {
|
|
if decodedHost.contains(":") {
|
|
guard encodedHost == decodedHost,
|
|
let componentHost = URLComponents(url: url, resolvingAgainstBaseURL: false)?.host,
|
|
componentHost.hasPrefix("["),
|
|
componentHost.hasSuffix("]")
|
|
else { return false }
|
|
|
|
let address = componentHost.dropFirst().dropLast()
|
|
return !address.isEmpty && address.allSatisfy { $0.isHexDigit || $0 == ":" || $0 == "." }
|
|
}
|
|
|
|
let decodedDelimiters = CharacterSet(charactersIn: "/\\?#@:")
|
|
guard decodedHost.rangeOfCharacter(from: decodedDelimiters) == nil else { return false }
|
|
|
|
let encodedDelimiters = ["%2f", "%5c", "%3f", "%23", "%40", "%3a"]
|
|
return !encodedDelimiters.contains { encodedHost.contains($0) }
|
|
}
|
|
}
|