1- use std:: { sync:: LazyLock , time:: Duration } ;
1+ use std:: { net :: IpAddr , sync:: LazyLock , time:: Duration } ;
22
3+ use bytes:: BytesMut ;
34use chrono:: Utc ;
5+ use futures:: TryStreamExt ;
46use regex:: Regex ;
7+ use reqwest:: redirect:: Policy ;
58use sea_orm:: { ColumnTrait , EntityTrait , QueryFilter } ;
69
710use crate :: {
@@ -229,6 +232,7 @@ impl SharedChatService {
229232 url : & str ,
230233 fetch_enabled : bool ,
231234 timeout_ms : u64 ,
235+ allow_private_networks : bool ,
232236 ) -> Result < Option < open_graph_link:: Model > , MegaError > {
233237 let cached = self
234238 . open_graph_storage
@@ -245,8 +249,11 @@ impl SharedChatService {
245249 if !fetch_enabled {
246250 return Ok ( cached) ;
247251 }
252+ if !allow_private_networks {
253+ validate_preview_url ( url, false ) ?;
254+ }
248255
249- let fetched = match fetch_open_graph ( url, timeout_ms) . await {
256+ let fetched = match fetch_open_graph ( url, timeout_ms, allow_private_networks ) . await {
250257 Ok ( v) => v,
251258 Err ( e) => {
252259 tracing:: warn!( url = %url, error = %e, "failed to fetch open graph link" ) ;
@@ -273,24 +280,27 @@ struct FetchedOpenGraph {
273280 favicon : Option < String > ,
274281}
275282
276- async fn fetch_open_graph ( url : & str , timeout_ms : u64 ) -> Result < FetchedOpenGraph , MegaError > {
283+ async fn fetch_open_graph (
284+ url : & str ,
285+ timeout_ms : u64 ,
286+ allow_private_networks : bool ,
287+ ) -> Result < FetchedOpenGraph , MegaError > {
288+ let parsed = validate_preview_url ( url, allow_private_networks) ?;
277289 let client = reqwest:: Client :: builder ( )
278290 . timeout ( Duration :: from_millis ( timeout_ms) )
291+ . redirect ( Policy :: none ( ) )
279292 . user_agent ( "monoengine-open-graph/1.0" )
280293 . build ( )
281294 . map_err ( |e| MegaError :: Other ( format ! ( "failed to build http client: {e}" ) ) ) ?;
282295
283- let body = client
284- . get ( url )
296+ let response = client
297+ . get ( parsed )
285298 . send ( )
286299 . await
287- . map_err ( |e| MegaError :: Other ( format ! ( "open graph fetch failed: {e}" ) ) ) ?
288- . text ( )
289- . await
290- . map_err ( |e| MegaError :: Other ( format ! ( "open graph fetch body read failed: {e}" ) ) ) ?;
300+ . map_err ( |e| MegaError :: Other ( format ! ( "open graph fetch failed: {e}" ) ) ) ?;
291301
292- // Avoid parsing multi-megabyte pages for a handful of meta tags.
293- let body: String = body . chars ( ) . take ( 1_000_000 ) . collect ( ) ;
302+ const MAX_BODY_BYTES : usize = 1_000_000 ;
303+ let body = read_response_body_limited ( response , MAX_BODY_BYTES ) . await ? ;
294304
295305 let title = extract_meta_property ( & body, "og:title" ) . or_else ( || extract_title_tag ( & body) ) ;
296306 let image = extract_meta_property ( & body, "og:image" ) ;
@@ -303,6 +313,98 @@ async fn fetch_open_graph(url: &str, timeout_ms: u64) -> Result<FetchedOpenGraph
303313 } )
304314}
305315
316+ fn validate_preview_url (
317+ url : & str ,
318+ allow_private_networks : bool ,
319+ ) -> Result < reqwest:: Url , MegaError > {
320+ let parsed = reqwest:: Url :: parse ( url)
321+ . map_err ( |e| MegaError :: Other ( format ! ( "invalid open graph URL: {e}" ) ) ) ?;
322+ if !matches ! ( parsed. scheme( ) , "http" | "https" ) {
323+ return Err ( MegaError :: Other (
324+ "only http/https URLs are allowed for open graph previews" . to_string ( ) ,
325+ ) ) ;
326+ }
327+
328+ if !allow_private_networks {
329+ let host = parsed
330+ . host_str ( )
331+ . ok_or_else ( || MegaError :: Other ( "open graph URL has no host" . to_string ( ) ) ) ?;
332+ if host. eq_ignore_ascii_case ( "localhost" ) || host. ends_with ( ".localhost" ) {
333+ return Err ( MegaError :: Other (
334+ "localhost URLs are not allowed for open graph previews" . to_string ( ) ,
335+ ) ) ;
336+ }
337+ if let Ok ( ip) = host. parse :: < IpAddr > ( )
338+ && !is_public_ip ( ip)
339+ {
340+ return Err ( MegaError :: Other (
341+ "non-public IP URLs are not allowed for open graph previews" . to_string ( ) ,
342+ ) ) ;
343+ }
344+ }
345+
346+ Ok ( parsed)
347+ }
348+
349+ fn is_public_ip ( ip : IpAddr ) -> bool {
350+ match ip {
351+ IpAddr :: V4 ( v4) => {
352+ let octets = v4. octets ( ) ;
353+ // 10/8, 172.16/12, 192.168/16
354+ if octets[ 0 ] == 10
355+ || ( octets[ 0 ] == 172 && ( 16 ..=31 ) . contains ( & octets[ 1 ] ) )
356+ || ( octets[ 0 ] == 192 && octets[ 1 ] == 168 )
357+ {
358+ return false ;
359+ }
360+ // 127/8, 169.254/16, 224/4, 0/8, 255/8, 100.64/10, 198.18/15, 192.0.2/24, ...
361+ !( v4. is_loopback ( )
362+ || v4. is_link_local ( )
363+ || v4. is_multicast ( )
364+ || v4. is_unspecified ( )
365+ || v4. is_broadcast ( )
366+ || v4. is_documentation ( ) )
367+ }
368+ IpAddr :: V6 ( v6) => {
369+ !( v6. is_loopback ( )
370+ || v6. is_unspecified ( )
371+ || v6. is_multicast ( )
372+ || v6. is_unicast_link_local ( )
373+ || ( v6. segments ( ) [ 0 ] & 0xfe00 ) == 0xfc00 ) // unique local (fc00::/7)
374+ }
375+ }
376+ }
377+
378+ async fn read_response_body_limited (
379+ response : reqwest:: Response ,
380+ limit : usize ,
381+ ) -> Result < String , MegaError > {
382+ if let Some ( len) = response. content_length ( )
383+ && len > limit as u64
384+ {
385+ return Err ( MegaError :: Other (
386+ "open graph response body exceeds size limit" . to_string ( ) ,
387+ ) ) ;
388+ }
389+
390+ let mut stream = response. bytes_stream ( ) ;
391+ let mut buf = BytesMut :: new ( ) ;
392+ while let Some ( chunk) = stream
393+ . try_next ( )
394+ . await
395+ . map_err ( |e| MegaError :: Other ( format ! ( "open graph response stream error: {e}" ) ) ) ?
396+ {
397+ if buf. len ( ) + chunk. len ( ) > limit {
398+ return Err ( MegaError :: Other (
399+ "open graph response body exceeds size limit" . to_string ( ) ,
400+ ) ) ;
401+ }
402+ buf. extend_from_slice ( & chunk) ;
403+ }
404+
405+ Ok ( String :: from_utf8_lossy ( & buf) . into_owned ( ) )
406+ }
407+
306408fn extract_meta_property ( html : & str , property : & str ) -> Option < String > {
307409 for caps in META_PROPERTY_FIRST . captures_iter ( html) {
308410 if caps[ 1 ] . eq_ignore_ascii_case ( property) {
@@ -460,7 +562,7 @@ mod tests {
460562 let shared_svc = SharedChatService :: from_storage ( & storage) ;
461563
462564 let preview = shared_svc
463- . fetch_or_refresh_open_graph_link ( & url, true , 5000 )
565+ . fetch_or_refresh_open_graph_link ( & url, true , 5000 , true )
464566 . await
465567 . expect ( "fetch should succeed" )
466568 . expect ( "preview should be returned" ) ;
@@ -476,7 +578,7 @@ mod tests {
476578
477579 // A second fetch should return the cached row without fetching again.
478580 let cached = shared_svc
479- . fetch_or_refresh_open_graph_link ( & url, true , 5000 )
581+ . fetch_or_refresh_open_graph_link ( & url, true , 5000 , true )
480582 . await
481583 . expect ( "cached fetch should succeed" )
482584 . expect ( "cached preview should exist" ) ;
@@ -494,7 +596,7 @@ mod tests {
494596 let shared_svc = SharedChatService :: from_storage ( & storage) ;
495597
496598 let none = shared_svc
497- . fetch_or_refresh_open_graph_link ( & url, false , 5000 )
599+ . fetch_or_refresh_open_graph_link ( & url, false , 5000 , true )
498600 . await
499601 . expect ( "fetch should succeed" ) ;
500602 assert ! ( none. is_none( ) ) ;
@@ -506,10 +608,22 @@ mod tests {
506608 . await
507609 . expect ( "upsert" ) ;
508610 let cached = shared_svc
509- . fetch_or_refresh_open_graph_link ( & url, false , 5000 )
611+ . fetch_or_refresh_open_graph_link ( & url, false , 5000 , true )
510612 . await
511613 . expect ( "fetch should succeed" )
512614 . expect ( "cached entry should be returned" ) ;
513615 assert_eq ! ( cached. title, "Cached" ) ;
514616 }
617+
618+ #[ tokio:: test]
619+ async fn fetch_open_graph_rejects_private_urls_by_default ( ) {
620+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
621+ let storage = test_storage ( temp. path ( ) ) . await ;
622+ let shared_svc = SharedChatService :: from_storage ( & storage) ;
623+
624+ let result = shared_svc
625+ . fetch_or_refresh_open_graph_link ( "http://127.0.0.1:8080/" , true , 5000 , false )
626+ . await ;
627+ assert ! ( result. is_err( ) ) ;
628+ }
515629}
0 commit comments