Skip to main content

wechat_protocol/
sticker.rs

1//! Original type-47 bytes from the verified CDN host. Signed URLs remain private.
2use 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 fn validate_sticker_url(value: &str) -> Result<reqwest::Url> {
33    ensure!(
34        value.len() <= 8192 && !value.chars().any(|c| c.is_control()),
35        "sticker_source_not_allowed"
36    );
37    let url =
38        reqwest::Url::parse(value).map_err(|_| anyhow::anyhow!("sticker_source_not_allowed"))?;
39    ensure!(
40        ["http", "https"].contains(&url.scheme())
41            && url.host_str() == Some("snsvideo.c2c.wechat.com")
42            && url.username().is_empty()
43            && url.password().is_none()
44            && url.fragment().is_none()
45            && url
46                .port()
47                .is_none_or(|p| p == if url.scheme() == "https" { 443 } else { 80 }),
48        "sticker_source_not_allowed"
49    );
50    Ok(url)
51}
52pub fn fetch_sticker(
53    descriptor: &StickerDescriptor,
54    mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
55) -> Result<(Vec<u8>, ImageInspection)> {
56    let url = validate_sticker_url(&descriptor.url)?;
57    ensure!(
58        descriptor.expected_bytes > 0
59            && descriptor.expected_bytes <= 16 * 1024 * 1024
60            && descriptor.expected_md5.len() == 32
61            && descriptor
62                .expected_md5
63                .bytes()
64                .all(|b| b.is_ascii_hexdigit()),
65        "sticker_metadata_invalid"
66    );
67    if let Some(expiry) = descriptor.expires_at {
68        ensure!(expiry > crate::wire::unix_time()? as u64, "media_expired");
69    }
70    progress(0, Some(descriptor.expected_bytes))?;
71    let port = if url.scheme() == "https" { 443 } else { 80 };
72    let addresses = ("snsvideo.c2c.wechat.com", port)
73        .to_socket_addrs()
74        .map_err(|_| anyhow::anyhow!("sticker_dns_failed"))?
75        .collect::<Vec<_>>();
76    ensure!(
77        !addresses.is_empty()
78            && addresses.len() <= 64
79            && addresses.iter().all(|a| public_ip(a.ip())),
80        "sticker_source_dns_not_public"
81    );
82    let address = addresses
83        .iter()
84        .find(|a| a.is_ipv4())
85        .or_else(|| addresses.first())
86        .unwrap();
87    let client = reqwest::blocking::Client::builder()
88        .timeout(Duration::from_secs(20))
89        .connect_timeout(Duration::from_secs(5))
90        .redirect(reqwest::redirect::Policy::none())
91        .no_proxy()
92        .resolve("snsvideo.c2c.wechat.com", *address)
93        .build()?;
94    let mut response = client
95        .get(url)
96        .header("Accept-Encoding", "identity")
97        .send()
98        .map_err(|_| anyhow::anyhow!("sticker_fetch_failed"))?;
99    ensure!(
100        response.status() == 200,
101        "sticker_http_status_{}",
102        response.status().as_u16()
103    );
104    ensure!(
105        response
106            .headers()
107            .get("content-encoding")
108            .is_none_or(|v| v.as_bytes().eq_ignore_ascii_case(b"identity")),
109        "sticker_content_encoding_refused"
110    );
111    if let Some(length) = response.content_length() {
112        ensure!(length <= 16 * 1024 * 1024, "sticker_response_too_large");
113    }
114    let mut bytes = vec![];
115    let mut chunk = [0u8; 65536];
116    loop {
117        progress(bytes.len() as u64, Some(descriptor.expected_bytes))?;
118        let n = response
119            .read(&mut chunk)
120            .map_err(|_| anyhow::anyhow!("sticker_fetch_failed"))?;
121        if n == 0 {
122            break;
123        }
124        bytes.extend_from_slice(&chunk[..n]);
125        ensure!(
126            bytes.len() <= 16 * 1024 * 1024,
127            "sticker_response_too_large"
128        );
129    }
130    ensure!(
131        bytes.len() as u64 == descriptor.expected_bytes
132            && hex::encode(crate::crypto::md5(&bytes))
133                .eq_ignore_ascii_case(&descriptor.expected_md5),
134        "media_integrity_failed"
135    );
136    let inspection = inspect_image(&bytes)?;
137    Ok((bytes, inspection))
138}
139#[cfg(test)]
140mod tests {
141    use super::*;
142    #[test]
143    fn url_and_network_sources_are_strict() {
144        for u in [
145            "https://127.0.0.1/x",
146            "https://snsvideo.c2c.wechat.com.evil/x",
147            "https://user@snsvideo.c2c.wechat.com/x",
148            "https://snsvideo.c2c.wechat.com:444/x",
149            "https://snsvideo.c2c.wechat.com/x#fragment",
150        ] {
151            assert!(validate_sticker_url(u).is_err());
152        }
153        for ip in ["127.0.0.1", "10.0.0.1", "::1", "2001:db8::1"] {
154            assert!(!public_ip(ip.parse().unwrap()));
155        }
156    }
157}