diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index a72f287f7a..90202b3f4c 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -1601,7 +1601,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.controller?._isPanGestureEnabled = isPanGestureEnabled } case "web_app_share_to_story": - if let json, let mediaUrl = json["media_url"] as? String { + if let json, let mediaUrl = json["media_url"] as? String, isAllowedBotMediaUrl(mediaUrl) { let text = json["text"] as? String let link = json["widget_link"] as? [String: Any] @@ -4309,3 +4309,116 @@ private struct WebAppConfiguration { } } } + +private func isAllowedBotMediaUrl(_ urlString: String) -> Bool { + guard let escaped = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: escaped) else { + return false + } + guard url.scheme?.lowercased() == "https" else { + return false + } + if url.user != nil || url.password != nil { + return false + } + guard var host = url.host?.lowercased(), !host.isEmpty else { + return false + } + if host.hasPrefix("[") && host.hasSuffix("]") { + host = String(host.dropFirst().dropLast()) + } + + // Strict canonical dotted-decimal IPv4 (4 octets, no leading zeros, each 0-255). + // Do NOT use inet_pton here: Darwin's inet_pton accepts "0177.0.0.1" as + // decimal 177.0.0.1, but getaddrinfo (used by URLSession) interprets the + // same string as octal 127.0.0.1 — the divergence is a loopback bypass. + if let v4Bytes = parseCanonicalIPv4(host) { + return isPublicIPv4(v4Bytes) + } + + // IPv6 only — host must contain ":" so we don't accidentally hand a + // numeric-looking hostname to inet_pton. + if host.contains(":") { + var v6 = in6_addr() + if host.withCString({ inet_pton(AF_INET6, $0, &v6) }) == 1 { + let bytes = withUnsafeBytes(of: &v6) { ptr -> [UInt8] in + return Array(ptr) + } + return isPublicIPv6(bytes) + } + return false + } + + // Strict DNS-name validation. Anything that doesn't look like a real + // FQDN is rejected — this catches non-canonical numeric IP forms + // (decimal-32 like "2130706433", octal like "0177.0.0.1", hex like + // "0x7f.0.0.1", short forms like "127.1") that the OS resolver may + // still treat as 127.0.0.1 even when inet_pton would accept them as + // a different value or reject outright. + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + guard labels.count >= 2 else { return false } + for label in labels { + guard !label.isEmpty, label.count <= 63 else { return false } + if label.first == "-" || label.last == "-" { return false } + for ch in label { + guard ch.isASCII else { return false } + if !(ch.isLetter || ch.isNumber || ch == "-") { return false } + } + } + guard let tld = labels.last, tld.count >= 2, tld.contains(where: { $0.isLetter }) else { + return false + } + + if host == "localhost" || host.hasSuffix(".localhost") || host.hasSuffix(".local") { + return false + } + return true +} + +private func parseCanonicalIPv4(_ host: String) -> [UInt8]? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + var bytes: [UInt8] = [] + bytes.reserveCapacity(4) + for part in parts { + guard !part.isEmpty, part.count <= 3 else { return nil } + if part.count > 1 && part.first == "0" { return nil } // no leading zeros (octal-spoof) + guard part.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } + guard let value = UInt8(part) else { return nil } // also caps at 255 + bytes.append(value) + } + return bytes +} + +private func isPublicIPv4(_ bytes: [UInt8]) -> Bool { + guard bytes.count == 4 else { return false } + let a = bytes[0] + let b = bytes[1] + if a == 0 { return false } // 0.0.0.0/8 + if a == 10 { return false } // 10.0.0.0/8 + if a == 127 { return false } // 127.0.0.0/8 loopback + if a == 169 && b == 254 { return false } // 169.254.0.0/16 link-local + if a == 172 && (b & 0xf0) == 16 { return false } // 172.16.0.0/12 + if a == 192 && b == 168 { return false } // 192.168.0.0/16 + if a == 100 && (b & 0xc0) == 64 { return false } // 100.64.0.0/10 CGNAT + if a >= 224 { return false } // multicast + reserved + 255.255.255.255 + return true +} + +private func isPublicIPv6(_ bytes: [UInt8]) -> Bool { + guard bytes.count == 16 else { return false } + if bytes.allSatisfy({ $0 == 0 }) { return false } // :: + let loopback: [UInt8] = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1] + if bytes == loopback { return false } // ::1 + if bytes[0] == 0xff { return false } // ff00::/8 multicast + if bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80 { return false } // fe80::/10 link-local + if (bytes[0] & 0xfe) == 0xfc { return false } // fc00::/7 unique-local + let v4MappedPrefix: [UInt8] = [0,0,0,0,0,0,0,0,0,0,0xff,0xff] + if Array(bytes.prefix(12)) == v4MappedPrefix { // ::ffff:a.b.c.d + return isPublicIPv4(Array(bytes.suffix(4))) + } + if Array(bytes.prefix(12)).allSatisfy({ $0 == 0 }) { // ::a.b.c.d (deprecated) + return isPublicIPv4(Array(bytes.suffix(4))) + } + return true +}