wechat_protocol/
sticker.rs1use anyhow::{ensure, Result};
3use serde::{Deserialize, Serialize};
4use std::{
5 io::Read,
6 net::{IpAddr, ToSocketAddrs},
7 time::Duration,
8};
9pub use wechat_media::{inspect_image, ImageInspection};
10#[derive(Clone, Serialize, Deserialize)]
11pub struct StickerDescriptor {
12 pub url: String,
13 pub expected_bytes: u64,
14 pub expected_md5: String,
15 pub expires_at: Option<u64>,
16}
17pub(super) fn public_ip(ip: IpAddr) -> bool {
18 match ip {
19 IpAddr::V4(v) => {
20 let [a, b, c, _] = v.octets();
21 super::public_ipv4(v) && !(a == 192 && b == 88 && c == 99)
22 }
23 IpAddr::V6(v) => {
24 let s = v.segments();
25 s[0] & 0xe000 == 0x2000
26 && !(s[0] == 0x2001 && (s[1] < 0x0200 || s[1] == 0x0db8))
27 && s[0] != 0x2002
28 && !(s[0] == 0x3fff && s[1] & 0xf000 == 0)
29 }
30 }
31}
32pub const STICKER_HOSTS: &[&str] = &["snsvideo.c2c.wechat.com", "wxapp.tc.qq.com"];
38
39pub fn validate_sticker_url(value: &str) -> Result<reqwest::Url> {
40 ensure!(
41 value.len() <= 8192 && !value.chars().any(|c| c.is_control()),
42 "sticker_source_not_allowed"
43 );
44 let url =
45 reqwest::Url::parse(value).map_err(|_| anyhow::anyhow!("sticker_source_not_allowed"))?;
46 ensure!(
47 ["http", "https"].contains(&url.scheme())
48 && url
49 .host_str()
50 .is_some_and(|host| STICKER_HOSTS.contains(&host))
51 && url.username().is_empty()
52 && url.password().is_none()
53 && url.fragment().is_none()
54 && url
55 .port()
56 .is_none_or(|p| p == if url.scheme() == "https" { 443 } else { 80 }),
57 "sticker_source_not_allowed"
58 );
59 Ok(url)
60}
61pub fn fetch_sticker(
62 descriptor: &StickerDescriptor,
63 mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
64) -> Result<(Vec<u8>, ImageInspection)> {
65 let url = validate_sticker_url(&descriptor.url)?;
66 ensure!(
67 descriptor.expected_bytes > 0
68 && descriptor.expected_bytes <= 16 * 1024 * 1024
69 && descriptor.expected_md5.len() == 32
70 && descriptor
71 .expected_md5
72 .bytes()
73 .all(|b| b.is_ascii_hexdigit()),
74 "sticker_metadata_invalid"
75 );
76 if let Some(expiry) = descriptor.expires_at {
77 ensure!(expiry > crate::wire::unix_time()? as u64, "media_expired");
78 }
79 progress(0, Some(descriptor.expected_bytes))?;
80 let host = url
81 .host_str()
82 .ok_or_else(|| anyhow::anyhow!("sticker_source_not_allowed"))?;
83 let port = if url.scheme() == "https" { 443 } else { 80 };
84 let addresses = (host, port)
85 .to_socket_addrs()
86 .map_err(|_| anyhow::anyhow!("sticker_dns_failed"))?
87 .collect::<Vec<_>>();
88 ensure!(
89 !addresses.is_empty()
90 && addresses.len() <= 64
91 && addresses.iter().all(|a| public_ip(a.ip())),
92 "sticker_source_dns_not_public"
93 );
94 let address = addresses
95 .iter()
96 .find(|a| a.is_ipv4())
97 .or_else(|| addresses.first())
98 .unwrap();
99 let client = reqwest::blocking::Client::builder()
100 .timeout(Duration::from_secs(20))
101 .connect_timeout(Duration::from_secs(5))
102 .redirect(reqwest::redirect::Policy::none())
103 .no_proxy()
104 .resolve(host, *address)
105 .build()?;
106 let mut response = client
107 .get(url)
108 .header("Accept-Encoding", "identity")
109 .send()
110 .map_err(|_| anyhow::anyhow!("sticker_fetch_failed"))?;
111 ensure!(
112 response.status() == 200,
113 "sticker_http_status_{}",
114 response.status().as_u16()
115 );
116 ensure!(
117 response
118 .headers()
119 .get("content-encoding")
120 .is_none_or(|v| v.as_bytes().eq_ignore_ascii_case(b"identity")),
121 "sticker_content_encoding_refused"
122 );
123 if let Some(length) = response.content_length() {
124 ensure!(length <= 16 * 1024 * 1024, "sticker_response_too_large");
125 }
126 let mut bytes = vec![];
127 let mut chunk = [0u8; 65536];
128 loop {
129 progress(bytes.len() as u64, Some(descriptor.expected_bytes))?;
130 let n = response
131 .read(&mut chunk)
132 .map_err(|_| anyhow::anyhow!("sticker_fetch_failed"))?;
133 if n == 0 {
134 break;
135 }
136 bytes.extend_from_slice(&chunk[..n]);
137 ensure!(
138 bytes.len() <= 16 * 1024 * 1024,
139 "sticker_response_too_large"
140 );
141 }
142 ensure!(
143 bytes.len() as u64 == descriptor.expected_bytes
144 && hex::encode(crate::crypto::md5(&bytes))
145 .eq_ignore_ascii_case(&descriptor.expected_md5),
146 "media_integrity_failed"
147 );
148 let inspection = inspect_image(&bytes)?;
149 Ok((bytes, inspection))
150}
151#[cfg(test)]
152mod tests {
153 use super::*;
154 #[test]
155 fn both_receiving_transports_are_allowed() {
156 for u in [
157 "https://snsvideo.c2c.wechat.com/x",
158 "http://wxapp.tc.qq.com/275/20304/stodownload?m=318341a160b08b297892a0e5741cc553&filekey=3035020101042130&bizid=1023",
159 "https://wxapp.tc.qq.com:443/x",
160 ] {
161 assert!(validate_sticker_url(u).is_ok(), "{u}");
162 }
163 for u in [
164 "http://wxapp.tc.qq.com.evil/x",
165 "https://wxapp.tc.qq.com.cn/x",
166 "http://evil.tc.qq.com/x",
167 "http://wxapp.tc.qq.com:8080/x",
168 ] {
169 assert!(validate_sticker_url(u).is_err(), "{u}");
170 }
171 }
172
173 #[test]
174 fn url_and_network_sources_are_strict() {
175 for u in [
176 "https://127.0.0.1/x",
177 "https://snsvideo.c2c.wechat.com.evil/x",
178 "https://user@snsvideo.c2c.wechat.com/x",
179 "https://snsvideo.c2c.wechat.com:444/x",
180 "https://snsvideo.c2c.wechat.com/x#fragment",
181 ] {
182 assert!(validate_sticker_url(u).is_err());
183 }
184 for ip in ["127.0.0.1", "10.0.0.1", "::1", "2001:db8::1"] {
185 assert!(!public_ip(ip.parse().unwrap()));
186 }
187 }
188}