Skip to main content

wechat_protocol/
link_thumbnail.rs

1//! Fetch only a card's advertised thumbnail, without opening its target page.
2use anyhow::{ensure, Result};
3use std::{
4    io::Read,
5    net::{IpAddr, SocketAddr, ToSocketAddrs},
6    time::Duration,
7};
8pub const MAX_LINK_THUMBNAIL_BYTES: usize = 8 * 1024 * 1024;
9
10pub fn validate_shared_link_url(value: &str) -> Result<reqwest::Url> {
11    ensure!(
12        !value.is_empty() && value.len() <= 8192 && !value.chars().any(|c| c.is_control()),
13        "link_source_not_allowed"
14    );
15    let url = reqwest::Url::parse(value).map_err(|_| anyhow::anyhow!("link_source_not_allowed"))?;
16    ensure!(
17        matches!(url.scheme(), "http" | "https")
18            && url.host_str().is_some()
19            && url.username().is_empty()
20            && url.password().is_none(),
21        "link_source_not_allowed"
22    );
23    Ok(url)
24}
25pub fn validate_link_thumbnail_url(value: &str) -> Result<reqwest::Url> {
26    let url = validate_shared_link_url(value)?;
27    ensure!(
28        url.fragment().is_none() && url.port().is_none(),
29        "link_thumbnail_source_not_allowed"
30    );
31    let host = url.host_str().unwrap().trim_matches(['[', ']']);
32    if let Ok(ip) = host.parse::<IpAddr>() {
33        ensure!(
34            super::sticker::public_ip(ip),
35            "link_thumbnail_dns_not_public"
36        );
37    }
38    Ok(url)
39}
40pub fn fetch_link_thumbnail(
41    value: &str,
42    progress: impl FnMut(u64, Option<u64>) -> Result<()>,
43) -> Result<Vec<u8>> {
44    let url = validate_link_thumbnail_url(value)?;
45    let host = url.host_str().unwrap().trim_matches(['[', ']']).to_owned();
46    let port = url.port_or_known_default().unwrap();
47    let addresses = (host.as_str(), port)
48        .to_socket_addrs()
49        .map_err(|_| anyhow::anyhow!("link_thumbnail_dns_failed"))?
50        .collect::<Vec<_>>();
51    ensure!(
52        !addresses.is_empty()
53            && addresses.len() <= 64
54            && addresses.iter().all(|a| super::sticker::public_ip(a.ip())),
55        "link_thumbnail_dns_not_public"
56    );
57    let address = addresses
58        .iter()
59        .find(|a| a.is_ipv4())
60        .or_else(|| addresses.first())
61        .unwrap();
62    fetch_at(url, *address, progress)
63}
64fn fetch_at(
65    url: reqwest::Url,
66    address: SocketAddr,
67    mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
68) -> Result<Vec<u8>> {
69    let host = url.host_str().unwrap().to_owned();
70    let client = reqwest::blocking::Client::builder()
71        .timeout(Duration::from_secs(20))
72        .connect_timeout(Duration::from_secs(5))
73        .redirect(reqwest::redirect::Policy::none())
74        .no_proxy()
75        .resolve(&host, address)
76        .build()?;
77    let mut response = client
78        .get(url)
79        .header("Accept-Encoding", "identity")
80        .send()
81        .map_err(|_| anyhow::anyhow!("link_thumbnail_fetch_failed"))?;
82    ensure!(response.status() == 200, "link_thumbnail_fetch_failed");
83    ensure!(
84        response
85            .headers()
86            .get("content-encoding")
87            .is_none_or(|v| v.as_bytes().eq_ignore_ascii_case(b"identity")),
88        "link_thumbnail_encoding_refused"
89    );
90    ensure!(
91        response
92            .content_length()
93            .is_none_or(|n| n <= MAX_LINK_THUMBNAIL_BYTES as u64),
94        "link_thumbnail_size_limit"
95    );
96    let total = response.content_length();
97    let mut bytes = Vec::new();
98    let mut chunk = [0u8; 65536];
99    loop {
100        progress(bytes.len() as u64, total)?;
101        let n = response
102            .read(&mut chunk)
103            .map_err(|_| anyhow::anyhow!("link_thumbnail_fetch_failed"))?;
104        if n == 0 {
105            break;
106        }
107        ensure!(
108            bytes.len() + n <= MAX_LINK_THUMBNAIL_BYTES,
109            "link_thumbnail_size_limit"
110        );
111        bytes.extend_from_slice(&chunk[..n]);
112    }
113    ensure!(
114        !bytes.is_empty() && total.is_none_or(|n| n == bytes.len() as u64),
115        "media_integrity_failed"
116    );
117    super::inspect_image(&bytes)?;
118    Ok(bytes)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    #[test]
125    fn thumbnail_sources_are_public_http_without_credentials_or_redirect_targets() {
126        assert!(validate_link_thumbnail_url("https://example.invalid/preview.jpg?a=1&b=2").is_ok());
127        for value in [
128            "file:///etc/passwd",
129            "https://user:pass@example.invalid/x",
130            "http://127.0.0.1/x",
131            "http://[::1]/x",
132            "http://169.254.169.254/x",
133            "https://example.invalid:8443/x",
134            "https://example.invalid/x#fragment",
135            "https://example.invalid/\nx",
136        ] {
137            assert!(validate_link_thumbnail_url(value).is_err(), "{value}");
138        }
139    }
140    #[test]
141    fn http_thumbnail_checks_status_encoding_bounds_and_image_bytes_without_redirects() {
142        use std::{io::Write, net::TcpListener};
143        let image = hex::decode("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63606060f80f00010401005fe5c34b0000000049454e44ae426082").unwrap();
144        for (status, headers, body, expected) in [
145            (
146                "200 OK",
147                format!("Content-Length: {}\r\n", image.len()),
148                image.clone(),
149                None,
150            ),
151            (
152                "302 Found",
153                "Location: http://127.0.0.1/target\r\nContent-Length: 0\r\n".into(),
154                vec![],
155                Some("link_thumbnail_fetch_failed"),
156            ),
157            (
158                "200 OK",
159                "Content-Encoding: gzip\r\nContent-Length: 1\r\n".into(),
160                vec![0],
161                Some("link_thumbnail_encoding_refused"),
162            ),
163            (
164                "200 OK",
165                "Content-Length: 8388609\r\n".into(),
166                vec![],
167                Some("link_thumbnail_size_limit"),
168            ),
169            (
170                "200 OK",
171                "".into(),
172                vec![0; MAX_LINK_THUMBNAIL_BYTES + 1],
173                Some("link_thumbnail_size_limit"),
174            ),
175            (
176                "200 OK",
177                "Content-Length: 4\r\n".into(),
178                b"html".to_vec(),
179                Some("unsupported"),
180            ),
181        ] {
182            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
183            let address = listener.local_addr().unwrap();
184            let server = std::thread::spawn(move || {
185                let (mut stream, _) = listener.accept().unwrap();
186                stream
187                    .set_read_timeout(Some(Duration::from_secs(2)))
188                    .unwrap();
189                let mut request = vec![];
190                let mut byte = [0];
191                while !request.ends_with(b"\r\n\r\n") {
192                    stream.read_exact(&mut byte).unwrap();
193                    request.push(byte[0]);
194                }
195                assert!(String::from_utf8(request)
196                    .unwrap()
197                    .starts_with("GET /preview HTTP/1.1\r\n"));
198                let _ = stream.write_all(
199                    format!("HTTP/1.1 {status}\r\nConnection: close\r\n{headers}\r\n").as_bytes(),
200                );
201                let _ = stream.write_all(&body);
202            });
203            // Only this private transport helper accepts a local test socket;
204            // the public entry point validates and pins public DNS answers.
205            let result = fetch_at(
206                reqwest::Url::parse(&format!(
207                    "http://preview.example.invalid:{}/preview",
208                    address.port()
209                ))
210                .unwrap(),
211                address,
212                |_, _| Ok(()),
213            );
214            server.join().unwrap();
215            if let Some(code) = expected {
216                assert!(result.unwrap_err().to_string().contains(code));
217            } else {
218                assert_eq!(result.unwrap(), image);
219            }
220        }
221    }
222}