wechat_protocol/
link_thumbnail.rs1use 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 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}