Skip to content

Commit 9d1e838

Browse files
committed
fix(chat): secure and wire Open Graph link preview fetching
- Block non-public URLs (localhost, private IPs) from Open Graph fetches by default; add chat.open_graph_allow_private_networks for tests/dev. - Disable redirects, cap response body size via streaming, and reject oversized Content-Length before buffering. - Wire preview refresh into send_message so URLs in message content are fetched/cached automatically. - Add tests for private-URL rejection, URL extraction, and the existing fetch/cache behavior. - Update docs/refactoring/chat.md. - Bump version to 0.1.115. Signed-off-by: Eli Ma <eli@patch.sh>
1 parent 8d9eefc commit 9d1e838

10 files changed

Lines changed: 201 additions & 21 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = ["bin"]
33

44
[package]
55
name = "monoengine-core"
6-
version = "0.1.114"
6+
version = "0.1.115"
77
edition = "2024"
88

99
[lib]

bin/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "monoengine"
3-
version = "0.1.114"
3+
version = "0.1.115"
44
edition = "2024"
55

66
# The thin composition-root binary. It depends on `monoengine-core` (which only

config/config.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,8 @@ attachment_allowed_mime_types = []
360360
open_graph_fetch_enabled = true
361361
# Network timeout in milliseconds for a single Open Graph fetch attempt.
362362
open_graph_fetch_timeout_ms = 5000
363+
# Allow fetches against localhost/private IPs. Keep false in production.
364+
open_graph_allow_private_networks = false
363365

364366
[vault.audit]
365367
# Secret-access auditing (docs/vault.md stage H). When enabled (the default),

docs/refactoring/chat.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414
2. **SeaORM 实体与迁移已落地**`attachments``custom_reactions``open_graph_links``channels``channel_memberships``channel_membership_updates``messages``message_notifications` 等实体/迁移存在;`reactions` 复用并扩展既有表。
1515
3. **HTTP Router 已接入**`src/api/router/chat_router.rs` 已在 `src/api/api_router.rs` merge,提供 channel CRUD、message CRUD、reaction、attachment presign/confirm、read/unread 端点并带 OpenAPI 标注。
1616
4. **权限主干已实现并在本轮收紧**。channel list/detail/message list/send/reaction/attachment 等路径会校验 membership;2026-06-23 新增 message edit/delete 的 channel path 校验和当前 membership 校验,避免只凭 sender ownership 跨 channel path 或被移除成员继续写旧消息;同日 `chat_router` 的 message/custom-reaction 映射读取已下沉到 storage helper,减少 handler 直接 SeaORM 查询。
17-
5. **仍未完成**:外部数据迁移工具的真实源库导入/校验、WebSocket/Pusher 兼容网关、完整真实 HTTP 黑盒矩阵和外部通知投递。message notification 内部状态已完成首批 reply 与 `@username` mention 写入;更完整 rich-text mention 语义仍属于后续。附件 presign/confirm 已有首批 file name/type/size/object-key 校验;**2026-06-29 更新:产品级 MIME allowlist 已落地**——`[chat]` 配置新增 `attachment_allowed_mime_types`,支持精确类型(`image/png`)与子类型通配(`image/*`),并在 presign/confirm 两阶段校验;**2026-06-29 更新(二):已上传对象存在性复核已落地**——`confirm_attachment` 端点在注册附件前调用 object storage 校验 `Attachment` 命名空间下目标对象是否存在,不存在则返回 400;**2026-06-29 更新(三):链接预览抓取/缓存行为已落地**——`SharedChatService::fetch_or_refresh_open_graph_link` 按 URL 读取本地缓存,过期时通过 HTTP 拉取并解析 `og:title`/`og:image`/favicon,写回 `open_graph_links`,配置 `chat.open_graph_fetch_enabled`/`open_graph_fetch_timeout_ms` 支持热加载。实时事件已有 `NoopChatEvents` 默认实现和 `InMemoryChatEvents` 进程内 broadcast hub,可供测试与后续网关消费。mark unread 已按验收标准把 `last_read_at` 调到 latest message 之前(2026-06-23 补齐)。
17+
5. **仍未完成**:外部数据迁移工具的真实源库导入/校验、WebSocket/Pusher 兼容网关、完整真实 HTTP 黑盒矩阵和外部通知投递。message notification 内部状态已完成首批 reply 与 `@username` mention 写入;更完整 rich-text mention 语义仍属于后续。附件 presign/confirm 已有首批 file name/type/size/object-key 校验;**2026-06-29 更新:产品级 MIME allowlist 已落地**——`[chat]` 配置新增 `attachment_allowed_mime_types`,支持精确类型(`image/png`)与子类型通配(`image/*`),并在 presign/confirm 两阶段校验;**2026-06-29 更新(二):已上传对象存在性复核已落地**——`confirm_attachment` 端点在注册附件前调用 object storage 校验 `Attachment` 命名空间下目标对象是否存在,不存在则返回 400;**2026-06-29 更新(三):链接预览抓取/缓存行为已落地**——`send_message` HTTP 路径在消息发送后会从 content 中提取 http/https URL,调用 `SharedChatService::fetch_or_refresh_open_graph_link` 按 URL 读取本地缓存,过期时通过 HTTP 拉取并解析 `og:title`/`og:image`/favicon,写回 `open_graph_links`;配置 `chat.open_graph_fetch_enabled`/`open_graph_fetch_timeout_ms` 支持热加载。抓取默认禁用重定向、限制响应体大小,并拒绝 localhost/私有 IP(测试/隔离环境可通过 `chat.open_graph_allow_private_networks` 开启)。实时事件已有 `NoopChatEvents` 默认实现和 `InMemoryChatEvents` 进程内 broadcast hub,可供测试与后续网关消费。mark unread 已按验收标准把 `last_read_at` 调到 latest message 之前(2026-06-23 补齐)。
1818

1919
## 当前实现状态速览表
2020

2121
| 能力 / 组件 | 实现状态 | 关键事实与风险 |
2222
|-----------|--------|-------------|
2323
| Chat 模块入口 | 已实现主干 | `src/chat/` 下已有 domain/engine/service;仍需继续清理文档中的旧 slice 叙述。 |
24-
| Shared Foundations(附件、表情) | 部分实现 | attachment/reaction/custom reaction/open graph 的实体、迁移、storage 与 service 主路径已落地;附件 presign/confirm 已补 file name/type/size/object-key 首批校验,并新增 `Config.chat.attachment_allowed_mime_types` 产品级 MIME allowlist(支持精确类型与子类型通配,空列表保持向后兼容);custom_reactions 已补 `lower(name)` 唯一索引和应用层 lowercase;reactions 已改为 `WHERE discarded_at IS NULL` 部分唯一索引;attachment 已补 `discarded_at` 软删除(满足硬约束 #4);**2026-06-29 更新:已上传附件对象存在性复核已落地**,`confirm_attachment` 注册前会校验 object storage 中目标对象是否存在;**2026-06-29 更新(三):链接预览抓取/缓存行为已落地**——`SharedChatService::fetch_or_refresh_open_graph_link` 按 URL 读取本地缓存,未命中或缓存过期时通过 HTTP 拉取 HTML,解析 `og:title`/`og:image`/favicon 后写回 `open_graph_links`支持 `chat.open_graph_fetch_enabled``chat.open_graph_fetch_timeout_ms` 配置并在热加载时生效。 |
24+
| Shared Foundations(附件、表情) | 部分实现 | attachment/reaction/custom reaction/open graph 的实体、迁移、storage 与 service 主路径已落地;附件 presign/confirm 已补 file name/type/size/object-key 首批校验,并新增 `Config.chat.attachment_allowed_mime_types` 产品级 MIME allowlist(支持精确类型与子类型通配,空列表保持向后兼容);custom_reactions 已补 `lower(name)` 唯一索引和应用层 lowercase;reactions 已改为 `WHERE discarded_at IS NULL` 部分唯一索引;attachment 已补 `discarded_at` 软删除(满足硬约束 #4);**2026-06-29 更新:已上传附件对象存在性复核已落地**,`confirm_attachment` 注册前会校验 object storage 中目标对象是否存在;**2026-06-29 更新(三):链接预览抓取/缓存行为已落地**——`send_message` HTTP 路径会提取消息中的 http/https URL,调用 `SharedChatService::fetch_or_refresh_open_graph_link` 按 URL 读取本地缓存,过期时通过 HTTP 拉取 HTML,解析 `og:title`/`og:image`/favicon 后写回 `open_graph_links`支持 `chat.open_graph_fetch_enabled`/`open_graph_fetch_timeout_ms` 配置并在热加载时生效;默认禁用重定向、限制响应体大小、拒绝 localhost/私有 IP,测试/隔离环境可通过 `chat.open_graph_allow_private_networks` 开启。 |
2525
| Channel Chat(频道、消息) | 部分实现 | channel/message/membership 实体、迁移、storage、service 已落地;create/send/edit/delete/read/unread/member service 主路径可用;reply 与 `@username` mention message notification 内部状态已写入;**2026-06-29 更新**`@username` mention 的外部邮件投递已接入 `send_message` HTTP 路径,通过 `notification::triggers::on_chat_mention_created` 按用户偏好入队 `chat.mention.created` email job。**2026-06-29 更新(二)**:reply 的外部邮件投递也已接入,通过 `notification::triggers::on_chat_reply_created` 向被回复消息的作者入队 `chat.reply.created` email job。rich-text mention 更复杂语义(如 markdown/HTML 解析、非 ASCII handle)仍为后续。channels/channel_memberships/channel_membership_updates/messages/message_notifications 的完整索引集和 `message_notifications` 唯一约束已补齐。 |
2626
| HTTP API | 部分实现 | `chat_router` 已挂载,DTO/OpenAPI 标注存在;仍缺真实 HTTP 黑盒矩阵、成员管理 HTTP 端点是否暴露的产品决策,以及更完整错误码兼容性。 |
2727
| 实时事件 | 进程内 broadcaster 已实现 | `ChatEvents`/`NoopChatEvents` 已定义并由 service 调用;`InMemoryChatEvents` 已提供 tokio broadcast 订阅能力并覆盖 service mutation 事件。WebSocket/Pusher 兼容网关仍未实现。 |

src/api/router/chat_router.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ fn validate_chat_attachment_file_path(file_path: &str) -> Result<(), ApiError> {
138138
Ok(())
139139
}
140140

141+
fn extract_urls(content: &str) -> Vec<String> {
142+
let mut urls = Vec::new();
143+
for token in content.split_whitespace() {
144+
let trimmed = token.trim_matches(|c: char| {
145+
matches!(
146+
c,
147+
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\'' | '>'
148+
)
149+
});
150+
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
151+
urls.push(trimmed.to_string());
152+
}
153+
}
154+
urls
155+
}
156+
141157
async fn map_channel_model(
142158
ch: crate::callisto::channel::Model,
143159
state: &MonoApiServiceState,
@@ -552,6 +568,26 @@ async fn send_message(
552568
}
553569
}
554570

571+
// Refresh Open Graph previews for any URLs in the message content. Failures
572+
// are logged but do not block the send response.
573+
let chat_cfg = state.storage.config().chat.clone().unwrap_or_default();
574+
if chat_cfg.open_graph_fetch_enabled {
575+
for url in extract_urls(&payload.content) {
576+
if let Err(e) = state
577+
.shared_chat_svc()
578+
.fetch_or_refresh_open_graph_link(
579+
&url,
580+
true,
581+
chat_cfg.open_graph_fetch_timeout_ms,
582+
chat_cfg.open_graph_allow_private_networks,
583+
)
584+
.await
585+
{
586+
tracing::warn!(error = %e, url = %url, "failed to refresh open graph link");
587+
}
588+
}
589+
}
590+
555591
let mapped = map_message_model(msg, &state).await?;
556592
Ok(Json(CommonResult::success(Some(mapped))))
557593
}
@@ -1371,4 +1407,15 @@ mod tests {
13711407
.0;
13721408
assert!(del_ch_res.req_result);
13731409
}
1410+
1411+
#[test]
1412+
fn extract_urls_finds_http_and_https_tokens() {
1413+
let content = "Check out https://example.com/page, and http://test.org?q=1. Also \
1414+
https://else.where/path.";
1415+
let urls = extract_urls(content);
1416+
assert_eq!(urls.len(), 3);
1417+
assert!(urls.contains(&"https://example.com/page".to_string()));
1418+
assert!(urls.contains(&"http://test.org?q=1".to_string()));
1419+
assert!(urls.contains(&"https://else.where/path".to_string()));
1420+
}
13741421
}

src/chat/service/shared.rs

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
use std::{sync::LazyLock, time::Duration};
1+
use std::{net::IpAddr, sync::LazyLock, time::Duration};
22

3+
use bytes::BytesMut;
34
use chrono::Utc;
5+
use futures::TryStreamExt;
46
use regex::Regex;
7+
use reqwest::redirect::Policy;
58
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
69

710
use 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+
306408
fn 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

Comments
 (0)