Skip to content
Clone

fix(api): phase1 security hardening (chat authz, space integrity, auth privacy, ws ticket)

ZhenYi committed
parent 8e82b89 commit 2891708 Browse files

28 files changed

+408 −126

File tree 28
Modified lib/auth/src/handlers/change_password.rs View lib/auth/src/handlers/change_password.rs
@@ -15,7 +15,7 @@
1515 tag = "auth",
1616 request_body = ChangePasswordCmd,
1717 responses(
18 (status = 200, description = "password changed, existing sessions invalidated", body = AppResponseData<NoData>)
18 (status = 200, description = "password changed, other sessions invalidated", body = AppResponseData<NoData>)
1919 )
2020)]
2121pub async fn change_password(
@@ -25,7 +25,7 @@
2525) -> Result<AppResponseData<NoData>, ApiError> {
2626 let uid = require_uid(&session)?;
2727 service
28 .change_password(uid, cmd)
28 .change_password(&session, uid, cmd)
2929 .await
3030 .map_err(ApiError::from)?;
3131 Ok(AppResponseData::empty())
Modified lib/auth/src/handlers/get_user.rs View lib/auth/src/handlers/get_user.rs
@@ -1,5 +1,7 @@
11use axum::extract::{Path, State};
22use export::rest::AppResponseData;
3use session::Session;
4use session::share::session::require_uid;
35use uuid::Uuid;
46
57use crate::errors::ApiError;
@@ -19,8 +21,13 @@
1921)]
2022pub async fn get_user(
2123 State(service): State<AuthService>,
24 session: Session,
2225 Path(user_id): Path<Uuid>,
2326) -> Result<AppResponseData<UserPublic>, ApiError> {
24 let user = service.public_user(user_id).await.map_err(ApiError::from)?;
27 let caller = require_uid(&session)?;
28 let user = service
29 .public_user(caller, user_id)
30 .await
31 .map_err(ApiError::from)?;
2532 Ok(AppResponseData::ok(user))
2633}
Modified lib/auth/src/handlers/login.rs View lib/auth/src/handlers/login.rs
@@ -1,5 +1,6 @@
11use axum::Json;
22use axum::extract::State;
3use axum::http::HeaderMap;
34
45use export::rest::AppResponseData;
56use session::Session;
@@ -20,8 +21,23 @@
2021pub async fn login(
2122 State(service): State<AuthService>,
2223 session: Session,
24 headers: HeaderMap,
2325 Json(cmd): Json<LoginCmd>,
2426) -> Result<AppResponseData<LoginResp>, ApiError> {
25 let resp = service.login(&session, cmd).await.map_err(ApiError::from)?;
27 let user_agent = headers
28 .get(axum::http::header::USER_AGENT)
29 .and_then(|v| v.to_str().ok())
30 .map(str::to_owned);
31 let client_ip = headers
32 .get("x-forwarded-for")
33 .and_then(|v| v.to_str().ok())
34 .and_then(|v| v.split(',').next())
35 .map(str::trim)
36 .filter(|v| !v.is_empty())
37 .map(str::to_owned);
38 let resp = service
39 .login(&session, cmd, user_agent.as_deref(), client_ip.as_deref())
40 .await
41 .map_err(ApiError::from)?;
2642 Ok(AppResponseData::ok(resp))
2743}
Modified lib/auth/src/handlers/request_deletion.rs View lib/auth/src/handlers/request_deletion.rs
@@ -13,6 +13,7 @@
1313
1414#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1515pub struct RequestDeletionCmd {
16 pub password: String,
1617 pub reason: Option<String>,
1718}
1819
@@ -32,7 +33,7 @@
3233) -> Result<AppResponseData<DeletionDto>, ApiError> {
3334 let uid = require_uid(&session)?;
3435 let resp = service
35 .request_deletion(uid, cmd.reason)
36 .request_deletion(uid, &cmd.password, cmd.reason)
3637 .await
3738 .map_err(ApiError::from)?;
3839 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/services/deletion.rs View lib/auth/src/services/deletion.rs
@@ -30,9 +30,21 @@
3030 pub async fn request_deletion(
3131 &self,
3232 uid: Uuid,
33 password: &str,
3334 reason: Option<String>,
3435 ) -> Result<DeletionDto, AuthError> {
3536 let db = self.db();
37 let user = models::users::user::Entity::find_by_id(uid)
38 .one(db.read())
39 .await?
40 .ok_or(AuthError::NotFound)?;
41 let stored = user
42 .passwd_hash
43 .as_deref()
44 .ok_or_else(|| AuthError::BadRequest("account has no password".to_string()))?;
45 if !crate::share::crypto::verify_password(password, stored) {
46 return Err(AuthError::InvalidCredentials);
47 }
3648 let active = user_deletion_requests::Entity::find()
3749 .filter(user_deletion_requests::Column::UserId.eq(uid))
3850 .filter(user_deletion_requests::Column::Status.is_in([
Modified lib/auth/src/services/email.rs View lib/auth/src/services/email.rs
@@ -21,6 +21,7 @@
2121 EmailVerificationPurpose::Signup => "signup",
2222 EmailVerificationPurpose::PasswordReset => "password_reset",
2323 EmailVerificationPurpose::AddEmail => "add_email",
24 EmailVerificationPurpose::Reclaim => "reclaim",
2425 }
2526}
2627
@@ -78,6 +79,7 @@
7879#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
7980pub struct ReclaimCmd {
8081 pub email: String,
82 pub password: String,
8183 pub pow_answer: String,
8284}
8385
@@ -221,9 +223,10 @@
221223
222224 let now = OffsetDateTime::now_utc();
223225 match cmd.purpose {
224 EmailVerificationPurpose::Signup => {
226 EmailVerificationPurpose::Signup | EmailVerificationPurpose::Reclaim => {
225227 let user_id = row.user_id;
226228 let email = row.email.clone();
229 let is_reclaim = cmd.purpose == EmailVerificationPurpose::Reclaim;
227230 let row = row.clone();
228231 sea_orm::TransactionTrait::transaction(db.primary(), |txn| {
229232 Box::pin(async move {
@@ -244,6 +247,17 @@
244247 .filter(user_emails::Column::LowerEmail.eq(&email))
245248 .exec(txn)
246249 .await?;
250 if is_reclaim {
251 let _ = user::Entity::update_many()
252 .col_expr(
253 user::Column::PasswdVersion,
254 Expr::cust("COALESCE(passwd_version, 0) + 1"),
255 )
256 .col_expr(user::Column::UpdatedAt, Expr::value(now))
257 .filter(user::Column::Uid.eq(user_id))
258 .exec(txn)
259 .await?;
260 }
247261 Ok::<(), DbErr>(())
248262 })
249263 })
@@ -294,6 +308,7 @@
294308 EmailVerificationPurpose::Signup => "Verify your account",
295309 EmailVerificationPurpose::PasswordReset => "Reset your password",
296310 EmailVerificationPurpose::AddEmail => "Add your email",
311 EmailVerificationPurpose::Reclaim => "Recover your account",
297312 };
298313 let minutes = self.config().auth.verify_code_ttl_secs / 60;
299314 let link = build_verify_link(
@@ -347,8 +362,15 @@
347362 if user.is_active {
348363 return Ok(placeholder());
349364 }
365 let stored = user
366 .passwd_hash
367 .as_deref()
368 .ok_or(AuthError::InvalidCredentials)?;
369 if !crate::share::crypto::verify_password(&cmd.password, stored) {
370 return Err(AuthError::InvalidCredentials);
371 }
350372 let artifacts = self
351 .issue_code(user.uid, &lower_email, EmailVerificationPurpose::Signup)
373 .issue_code(user.uid, &lower_email, EmailVerificationPurpose::Reclaim)
352374 .await?;
353375 let minutes = self.config().auth.verify_code_ttl_secs / 60;
354376 let link = build_verify_link(
Modified lib/auth/src/services/login.rs View lib/auth/src/services/login.rs
@@ -24,8 +24,6 @@
2424 pub identifier: String,
2525 pub password: String,
2626 pub pow_answer: String,
27 pub ua_hash: Option<String>,
28 pub ip_hash: Option<String>,
2927}
3028
3129#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -38,11 +36,13 @@
3836#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
3937pub struct TwoFactorLoginCmd {
4038 pub code: String,
39 pub challenge: String,
4140}
4241
4342#[derive(Debug, Clone, Serialize, Deserialize)]
4443struct TwoFactorChallenge {
4544 pub uid: Uuid,
45 pub challenge: String,
4646 pub expires_at: i64,
4747 pub ua_hash: Option<String>,
4848 pub ip_hash: Option<String>,
@@ -60,9 +60,20 @@
6060}
6161
6262impl AuthService {
63 pub async fn login(&self, session: &Session, cmd: LoginCmd) -> Result<LoginResp, AuthError> {
63 pub async fn login(
64 &self,
65 session: &Session,
66 cmd: LoginCmd,
67 user_agent: Option<&str>,
68 client_ip: Option<&str>,
69 ) -> Result<LoginResp, AuthError> {
6470 self.verify_pow(session, &cmd.pow_answer)?;
6571
72 let ua_hash = user_agent.map(|ua| sha256_b64url(ua.as_bytes()));
73 let ip_hash = client_ip.map(|ip| sha256_b64url(ip.as_bytes()));
74 let ua_hash = Some(ua_hash.unwrap_or_default()).filter(|s| !s.is_empty());
75 let ip_hash = Some(ip_hash.unwrap_or_default()).filter(|s| !s.is_empty());
76
6677 let db = self.db();
6778 let identifier = cmd.identifier.trim().to_ascii_lowercase();
6879 let attempts_key = format!("auth:login_attempts:{identifier}");
@@ -77,7 +88,7 @@
7788 return Err(AuthError::RateLimited);
7889 }
7990
80 let Some(user) = Self::find_user_by_identifier(db.write(), &identifier).await? else {
91 let Some(user) = Self::find_user_by_identifier(db.read(), &identifier).await? else {
8192 return Err(AuthError::InvalidCredentials);
8293 };
8394 if user.prohibit_login {
@@ -102,20 +113,20 @@
102113 session.set_store(true);
103114 let challenge = TwoFactorChallenge {
104115 uid: user.uid,
116 challenge: Uuid::now_v7().to_string(),
105117 expires_at: now_secs() + TWO_FA_TTL_SECS,
106 ua_hash: cmd.ua_hash,
107 ip_hash: cmd.ip_hash,
118 ua_hash,
119 ip_hash,
108120 };
109 session.set(TWO_FA_KEY, challenge);
121 session.set(TWO_FA_KEY, &challenge);
110122 return Ok(LoginResp {
111123 user: UserPublic::from(&user),
112124 needs_2fa: true,
113 challenge: Some(Uuid::now_v7().to_string()),
125 challenge: Some(challenge.challenge.clone()),
114126 });
115127 }
116128
117 self.finish_login(session, &user, cmd.ua_hash, cmd.ip_hash)
118 .await
129 self.finish_login(session, &user, ua_hash, ip_hash).await
119130 }
120131
121132 pub async fn login_2fa(
@@ -128,6 +139,12 @@
128139 "no pending two-factor challenge".to_string(),
129140 ));
130141 };
142 if challenge.challenge != cmd.challenge {
143 session.remove(TWO_FA_KEY);
144 return Err(AuthError::BadRequest(
145 "invalid two-factor challenge".to_string(),
146 ));
147 }
131148 let attempts_key = format!("auth:2fa_attempts:{}", challenge.uid);
132149 if !attempts_track(self.redis(), &attempts_key, 5, 300).await? {
133150 session.remove(TWO_FA_KEY);
Modified lib/auth/src/services/password.rs View lib/auth/src/services/password.rs
@@ -162,6 +162,7 @@
162162
163163 pub async fn change_password(
164164 &self,
165 session: &Session,
165166 uid: Uuid,
166167 cmd: ChangePasswordCmd,
167168 ) -> Result<(), AuthError> {
@@ -189,6 +190,8 @@
189190 active.password_last_changed_at = Set(Some(now));
190191 active.updated_at = Set(now);
191192 active.update(db.write()).await?;
193
194 session::share::session::apply_pwd_version(session, next_version);
192195 Ok(())
193196 }
194197}
Modified lib/auth/src/services/users.rs View lib/auth/src/services/users.rs
@@ -1,3 +1,4 @@
1use models::users::user::ProfileVisibility;
12use models::users::user;
23use sea_orm::EntityTrait;
34use uuid::Uuid;
@@ -7,10 +8,16 @@
78use crate::share::types::UserPublic;
89
910impl AuthService {
10 pub async fn public_user(&self, uid: Uuid) -> Result<UserPublic, AuthError> {
11 pub async fn public_user(&self, caller: Uuid, uid: Uuid) -> Result<UserPublic, AuthError> {
1112 let Some(user) = user::Entity::find_by_id(uid).one(self.db().read()).await? else {
1213 return Err(AuthError::NotFound);
1314 };
15 if !user.is_active || user.system {
16 return Err(AuthError::NotFound);
17 }
18 if user.visibility == ProfileVisibility::Private && caller != user.uid {
19 return Err(AuthError::NotFound);
20 }
1421 Ok(UserPublic {
1522 user_id: user.uid,
1623 username: user.username,
Modified lib/auth/src/routes.rs View lib/auth/src/routes.rs
@@ -63,10 +63,10 @@
6363 .route("/auth/email/reclaim", post(reclaim))
6464 .route("/auth/password/forgot", post(forgot_password))
6565 .route("/auth/password/reset", post(reset_password))
66 .route("/auth/agreements", get(list_agreements))
67 .route("/users/{user_id}", get(get_user));
66 .route("/auth/agreements", get(list_agreements));
6867
6968 let authed = Router::new()
69 .route("/users/{user_id}", get(get_user))
7070 .route("/auth/email/change", post(request_email_change))
7171 .route("/auth/email/change/confirm", post(confirm_email_change))
7272 .route(
Modified lib/chat/src/handlers/ws.rs View lib/chat/src/handlers/ws.rs
@@ -5,7 +5,7 @@
55use session::share::session::require_uid;
66use utoipa::ToSchema;
77
8use crate::errors::ApiError;
8use crate::errors::{ApiError, ChatError};
99use crate::services::service::ChatService;
1010use crate::ws::ticket::issue_ticket;
1111
@@ -29,9 +29,12 @@
2929 session: Session,
3030) -> Result<AppResponseData<WsTicketDto>, ApiError> {
3131 let uid = require_uid(&session)?;
32 let token_hash = session
33 .get::<String>(&session::share::session::session_token_key(uid))
34 .ok_or_else(|| ApiError::from(ChatError::Unauthorized))?;
3235 let ttl = service.config().chat.ws.ticket_ttl_secs;
3336 let secret = service.ticket_secret();
34 let ticket = issue_ticket(&secret, uid, ttl)?;
37 let ticket = issue_ticket(&secret, uid, &token_hash, ttl)?;
3538 Ok(AppResponseData::ok(WsTicketDto {
3639 ticket,
3740 ttl_secs: ttl,
Modified lib/chat/src/services/attachments.rs View lib/chat/src/services/attachments.rs
@@ -1,7 +1,7 @@
11use axum::body::Body;
22use axum::http::header;
33use models::chat::attachments::{self, AttachmentStatus};
4use sea_orm::{ActiveModelTrait, EntityTrait, Set};
4use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
55use serde::{Deserialize, Serialize};
66use time::OffsetDateTime;
77use utoipa::ToSchema;
@@ -142,6 +142,17 @@
142142 "unsupported or unsafe content_type".to_string(),
143143 ));
144144 }
145 let unattached = attachments::Entity::find()
146 .filter(attachments::Column::UserId.eq(caller))
147 .filter(attachments::Column::MessageId.is_null())
148 .count(self.db().read())
149 .await?;
150 if unattached >= self.config().chat.max_pending_attachments_per_user as u64 {
151 return Err(ChatError::RateLimited(format!(
152 "too many unused attachments (max {}), finalize or reference them in a message first",
153 self.config().chat.max_pending_attachments_per_user
154 )));
155 }
145156
146157 let now = OffsetDateTime::now_utc();
147158 let id = Uuid::now_v7();
@@ -206,12 +217,30 @@
206217 "attachment already finalized".to_string(),
207218 ));
208219 }
209 let exists = self.storage().client().exists(&row.s3_key).await?;
210 if !exists {
220 let head = self.storage().client().head(&row.s3_key).await?;
221 let Some((actual_size, actual_content_type)) = head else {
211222 return Err(ChatError::BadRequest("object not uploaded yet".to_string()));
223 };
224 let max_size = self.config().chat.upload.max_size;
225 if actual_size == 0 || actual_size > max_size as u64 {
226 return Err(ChatError::BadRequest(format!(
227 "uploaded object size {actual_size} outside 1..{max_size} bytes"
228 )));
212229 }
230 let stored_content_type = if actual_content_type.is_empty() {
231 row.content_type.clone()
232 } else {
233 actual_content_type.clone()
234 };
235 if is_unsafe_inline(&stored_content_type) {
236 return Err(ChatError::BadRequest(
237 "uploaded object has an unsafe content_type".to_string(),
238 ));
239 }
213240 let now = OffsetDateTime::now_utc();
214241 let mut am: attachments::ActiveModel = row.into();
242 am.size = Set(actual_size as i64);
243 am.content_type = Set(stored_content_type);
215244 am.status = Set(AttachmentStatus::Uploaded);
216245 am.width = Set(cmd.width);
217246 am.height = Set(cmd.height);
Modified lib/chat/src/services/messages.rs View lib/chat/src/services/messages.rs
@@ -23,7 +23,7 @@
2323use crate::services::service::ChatService;
2424use crate::services::threads::maintain_thread_on_reply;
2525use crate::share::authz::can_manage_space;
26use crate::share::lookup::{RoomContext, require_room_readable};
26use crate::share::lookup::{RoomContext, require_room_readable, require_room_writable};
2727use crate::share::mention::parse_mentions;
2828use crate::share::notify::{
2929 NotifyDecision, advance_read_state, current_room_seq, mention_email_allowed, next_room_seq,
@@ -262,9 +262,16 @@
262262 room_id: Uuid,
263263 cmd: SendMessageCmd,
264264 ) -> Result<MessageDto, ChatError> {
265 let ctx = require_room_readable(self, room_id, caller).await?;
265 let ctx = require_room_writable(self, room_id, caller).await?;
266266 self.check_slow_mode(&ctx, caller).await?;
267267
268 if cmd.attachments.len() > self.config().chat.max_attachments_per_message {
269 return Err(ChatError::BadRequest(format!(
270 "attachments must be at most {}",
271 self.config().chat.max_attachments_per_message
272 )));
273 }
274
268275 let forwarded_from = match cmd.forwarded_from {
269276 Some(orig_id) => {
270277 let orig = messages::Entity::find_by_id(orig_id)
@@ -272,6 +279,7 @@
272279 .await?
273280 .filter(|m| m.deleted_at.is_none())
274281 .ok_or(ChatError::NotFound)?;
282 require_room_readable(self, orig.room_id, caller).await?;
275283 Some(orig.id)
276284 }
277285 None => None,
@@ -754,14 +762,6 @@
754762 "message_ids must contain 1..={MAX_BULK_DELETE} entries"
755763 )));
756764 }
757 let ctx = require_room_readable(self, room_id, caller).await?;
758 let is_manager = match &ctx.space {
759 Some(space) => can_manage_space(self, space, caller).await?,
760 None => false,
761 };
762 if !is_manager {
763 return Err(ChatError::Forbidden);
764 }
765765 self.delete_messages_internal(caller, room_id, cmd.message_ids)
766766 .await
767767 }
@@ -948,12 +948,25 @@
948948 .filter(|m| m.deleted_at.is_none())
949949 .ok_or(ChatError::NotFound)?;
950950
951 let forwarded_attachments = if original.author_id == caller {
952 original
953 .attachments
954 .as_array()
955 .map(|arr| {
956 arr.iter()
957 .filter_map(|v| v.as_str().and_then(|s| Uuid::parse_str(s).ok()))
958 .collect()
959 })
960 .unwrap_or_default()
961 } else {
962 Vec::new()
963 };
951964 let send_cmd = SendMessageCmd {
952965 content: cmd.comment.unwrap_or_default(),
953966 reply_to_id: None,
954967 thread_id: None,
955968 thread_broadcast: false,
956 attachments: Vec::new(),
969 attachments: forwarded_attachments,
957970 nonce: None,
958971 urgent: false,
959972 forwarded_from: Some(original.id),
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -13,6 +13,7 @@
1313 Set, TransactionTrait,
1414};
1515use serde::{Deserialize, Serialize};
16use space::share::authz::{MANAGE_INVITES, effective_permissions};
1617use time::OffsetDateTime;
1718use utoipa::{IntoParams, ToSchema};
1819use uuid::Uuid;
@@ -517,6 +518,12 @@
517518 room_id: Uuid,
518519 cmd: AddMembersCmd,
519520 ) -> Result<(), ChatError> {
521 if cmd.user_ids.is_empty() || cmd.user_ids.len() > self.config().chat.max_members_per_add {
522 return Err(ChatError::BadRequest(format!(
523 "user_ids must contain 1..={} entries",
524 self.config().chat.max_members_per_add
525 )));
526 }
520527 let ctx = require_room_readable(self, room_id, caller).await?;
521528 match &ctx.space {
522529 Some(space) => {
@@ -530,11 +537,6 @@
530537 }
531538 let limit = ctx.room.user_limit.unwrap_or(i32::MAX);
532539 let mut active = self.room_member_count(room_id).await? as i64;
533 if active >= limit as i64 && limit <= 2 {
534 return Err(ChatError::BadRequest(
535 "this conversation does not accept more members".to_string(),
536 ));
537 }
538540 for uid in &cmd.user_ids {
539541 if !is_room_member(self, room_id, *uid).await? {
540542 active += 1;
@@ -648,8 +650,15 @@
648650 cmd: CreateRoomInviteCmd,
649651 ) -> Result<RoomInviteDto, ChatError> {
650652 let ctx = require_room_readable(self, room_id, caller).await?;
651 if ctx.room.space_id.is_none() {
653 let Some(space) = &ctx.space else {
652654 return Err(ChatError::BadRequest("cannot invite to a DM".to_string()));
655 };
656 if !can_manage_space(self, space, caller).await?
657 && !effective_permissions(self.db().read(), space, caller)
658 .await?
659 .has(MANAGE_INVITES)
660 {
661 return Err(ChatError::Forbidden);
653662 }
654663 let now = OffsetDateTime::now_utc();
655664 let id = Uuid::now_v7();
Modified lib/chat/src/services/threads.rs View lib/chat/src/services/threads.rs
@@ -322,10 +322,7 @@
322322 Some(space) => can_manage_space(self, space, caller).await?,
323323 None => false,
324324 };
325 if ctx.space.is_some() && thread.owner_id != caller && !is_manager {
326 return Err(ChatError::Forbidden);
327 }
328 if cmd.locked.is_some() && thread.owner_id != caller && !is_manager {
325 if thread.owner_id != caller && !is_manager {
329326 return Err(ChatError::Forbidden);
330327 }
331328
Modified lib/chat/src/services/webhooks.rs View lib/chat/src/services/webhooks.rs
@@ -63,31 +63,8 @@
6363 )
6464}
6565
66/// Fixed-window limiter for the unauthenticated incoming-webhook endpoint.
6766const HOOK_WINDOW_SECS: u64 = 60;
6867const HOOK_MAX_PER_WINDOW: u32 = 30;
69
70fn hook_allow(bucket_key: &str) -> bool {
71 static BUCKETS: std::sync::OnceLock<
72 std::sync::Mutex<std::collections::HashMap<String, (u32, std::time::Instant)>>,
73 > = std::sync::OnceLock::new();
74 let map = BUCKETS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
75 let Ok(mut guard) = map.lock() else {
76 return true;
77 };
78 if guard.len() > 10_000 {
79 guard.retain(|_, (_, start)| start.elapsed().as_secs() < HOOK_WINDOW_SECS);
80 }
81 let now = std::time::Instant::now();
82 let entry = guard.entry(bucket_key.to_string()).or_insert((0, now));
83 if entry.1.elapsed().as_secs() >= HOOK_WINDOW_SECS {
84 entry.0 = 1;
85 entry.1 = now;
86 return true;
87 }
88 entry.0 = entry.0.saturating_add(1);
89 entry.0 <= HOOK_MAX_PER_WINDOW
90}
9168
9269impl From<room_webhooks::Model> for WebhookDto {
9370 fn from(w: room_webhooks::Model) -> Self {
@@ -217,7 +194,16 @@
217194 cmd: WebhookPostCmd,
218195 ) -> Result<serde_json::Value, ChatError> {
219196 let hash = token_hash(token);
220 if !hook_allow(&hash) {
197 if !self
198 .state
199 .redis
200 .quota_allow(
201 &format!("chat:webhook:{hash}"),
202 HOOK_MAX_PER_WINDOW as i64,
203 HOOK_WINDOW_SECS,
204 )
205 .await
206 {
221207 return Err(ChatError::RateLimited(
222208 "webhook rate limit exceeded, retry in 60s".to_string(),
223209 ));
Modified lib/chat/src/share/lookup.rs View lib/chat/src/share/lookup.rs
@@ -3,6 +3,7 @@
33use models::space::space_members::{self, MemberStatus};
44use models::space::spaces;
55use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
6use space::share::authz::{MESSAGE, effective_permissions};
67use uuid::Uuid;
78
89use crate::errors::ChatError;
@@ -115,6 +116,21 @@
115116 Ok(member)
116117}
117118
119pub async fn require_room_writable(
120 service: &ChatService,
121 room_id: Uuid,
122 uid: Uuid,
123) -> Result<RoomContext, ChatError> {
124 let ctx = require_room_readable(service, room_id, uid).await?;
125 if let Some(space) = &ctx.space {
126 let perms = effective_permissions(service.db().read(), space, uid).await?;
127 if !perms.has(MESSAGE) {
128 return Err(ChatError::Forbidden);
129 }
130 }
131 Ok(ctx)
132}
133
118134pub async fn require_room_readable(
119135 service: &ChatService,
120136 room_id: Uuid,
Modified lib/chat/src/ws/mod.rs View lib/chat/src/ws/mod.rs
@@ -5,10 +5,10 @@
55use std::time::Duration;
66
77use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
8use axum::extract::{Query, State};
8use axum::extract::State;
99use axum::response::Response;
1010use futures::{SinkExt, StreamExt};
11use serde::Deserialize;
11
1212use tokio::sync::mpsc;
1313use tokio_util::sync::CancellationToken;
1414use uuid::Uuid;
@@ -18,12 +18,60 @@
1818use crate::services::read_state::MarkReadCmd;
1919use crate::services::service::ChatService;
2020use crate::share::realtime::RoomEvent;
21use session::SessionGuard;
2122use frames::{ClientFrame, ServerFrame};
2223use ticket::{consume_ticket, verify_ticket};
2324
24#[derive(Debug, Deserialize)]
25pub struct WsQuery {
26 pub ticket: String,
25const TICKET_PROTOCOL_PREFIX: &str = "gitdata.ticket.";
26
27fn extract_ticket(headers: &axum::http::HeaderMap) -> Option<String> {
28 for value in headers.get_all(axum::http::header::SEC_WEBSOCKET_PROTOCOL) {
29 let Ok(raw) = value.to_str() else {
30 continue;
31 };
32 for proto in raw.split(',') {
33 let proto = proto.trim();
34 if let Some(ticket) = proto.strip_prefix(TICKET_PROTOCOL_PREFIX) {
35 if !ticket.is_empty() {
36 return Some(ticket.to_string());
37 }
38 }
39 }
40 }
41 None
42}
43
44fn host_of(raw: &str) -> Option<&str> {
45 let rest = raw.split_once("://").map(|(_, r)| r).unwrap_or(raw);
46 let host = rest.split('/').next()?;
47 let host = host.rsplit_once('@').map(|(_, h)| h).unwrap_or(host);
48 let host = match host.rsplit_once(':') {
49 Some((h, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
50 _ => host,
51 };
52 (!host.is_empty()).then_some(host)
53}
54
55fn origin_allowed(service: &ChatService, headers: &axum::http::HeaderMap) -> bool {
56 let Some(origin) = headers
57 .get(axum::http::header::ORIGIN)
58 .and_then(|v| v.to_str().ok())
59 else {
60 return true;
61 };
62 let Some(origin_host) = host_of(origin) else {
63 return false;
64 };
65 let mut allowed: Vec<&str> = vec![];
66 if let Some(host) = host_of(&service.config().web.web_base_url) {
67 allowed.push(host);
68 }
69 for entry in &service.config().web.cors_allow_origins {
70 if let Some(host) = host_of(entry) {
71 allowed.push(host);
72 }
73 }
74 allowed.iter().any(|host| *host == origin_host)
2775}
2876
2977#[derive(Clone)]
@@ -37,27 +85,60 @@
3785 handle: tokio::task::JoinHandle<()>,
3886}
3987
88fn reject(_ws: WebSocketUpgrade) -> Response {
89 axum::http::Response::builder()
90 .status(axum::http::StatusCode::BAD_REQUEST)
91 .body(axum::body::Body::empty())
92 .unwrap()
93}
94
4095pub async fn ws_upgrade(
4196 State(service): State<ChatService>,
42 Query(query): Query<WsQuery>,
97 headers: axum::http::HeaderMap,
4398 ws: WebSocketUpgrade,
4499) -> Response {
45 let verified = match verify_ticket(&service.ticket_secret(), &query.ticket) {
100 if !origin_allowed(&service, &headers) {
101 return reject(ws);
102 }
103 let Some(ticket) = extract_ticket(&headers) else {
104 return reject(ws);
105 };
106 let verified = match verify_ticket(&service.ticket_secret(), &ticket) {
46107 Ok(value) => value,
47 Err(_) => {
48 return ws.on_upgrade(|socket| async move {
49 let _ = close_with(socket, "invalid ticket").await;
50 });
51 }
108 Err(_) => return reject(ws),
52109 };
53110 if !consume_ticket(&service.state.redis, verified.jti, verified.ttl_left_secs).await {
54 return ws.on_upgrade(|socket| async move {
55 let _ = close_with(socket, "invalid ticket").await;
56 });
111 return reject(ws);
57112 }
113 let session = match service
114 .lookup_session_with_user(&verified.token_hash)
115 .await
116 {
117 Ok(Some(session)) => session,
118 _ => return reject(ws),
119 };
120 let now = std::time::SystemTime::now()
121 .duration_since(std::time::UNIX_EPOCH)
122 .unwrap_or_default()
123 .as_secs() as i64;
124 let authorized = session.user_id == verified.uid
125 && !session.is_revoked
126 && time::OffsetDateTime::from_unix_timestamp(now)
127 .map(|n| session.expires_at > n)
128 .unwrap_or(false)
129 && session.is_active
130 && !session.prohibit_login
131 && !session.is_restricted;
132 if !authorized {
133 return reject(ws);
134 }
58135 let uid = verified.uid;
59 let session_id = verified.sid;
60
136 let session_id = Uuid::now_v7();
137 let mut ws = ws;
138 if let Ok(value) = axum::http::HeaderValue::from_str(&format!("{TICKET_PROTOCOL_PREFIX}{ticket}"))
139 {
140 ws.set_selected_protocol(value);
141 }
61142 ws.max_message_size(service.config().chat.ws.max_frame_bytes)
62143 .on_upgrade(move |socket| handle_socket(socket, service, uid, session_id))
63144}
Modified lib/chat/src/ws/ticket.rs View lib/chat/src/ws/ticket.rs
@@ -12,12 +12,17 @@
1212#[derive(Debug, Serialize, Deserialize)]
1313struct TicketPayload {
1414 uid: Uuid,
15 sid: Uuid,
15 token_hash: String,
1616 jti: Uuid,
1717 exp: i64,
1818}
1919
20pub fn issue_ticket(secret: &str, uid: Uuid, ttl_secs: u64) -> Result<String, ChatError> {
20pub fn issue_ticket(
21 secret: &str,
22 uid: Uuid,
23 token_hash: &str,
24 ttl_secs: u64,
25) -> Result<String, ChatError> {
2126 if secret.is_empty() {
2227 return Err(ChatError::Internal(
2328 "chat ticket_secret not configured".into(),
@@ -25,7 +30,7 @@
2530 }
2631 let payload = TicketPayload {
2732 uid,
28 sid: Uuid::now_v7(),
33 token_hash: token_hash.to_string(),
2934 jti: Uuid::now_v7(),
3035 exp: OffsetDateTime::now_utc().unix_timestamp() + ttl_secs as i64,
3136 };
@@ -40,7 +45,7 @@
4045
4146pub struct VerifiedTicket {
4247 pub uid: Uuid,
43 pub sid: Uuid,
48 pub token_hash: String,
4449 pub jti: Uuid,
4550 pub ttl_left_secs: i64,
4651}
@@ -72,7 +77,7 @@
7277 }
7378 Ok(VerifiedTicket {
7479 uid: payload.uid,
75 sid: payload.sid,
80 token_hash: payload.token_hash,
7681 jti: payload.jti,
7782 ttl_left_secs: payload.exp - now,
7883 })
Modified lib/config/src/chat.rs View lib/config/src/chat.rs
@@ -51,6 +51,9 @@
5151 pub max_bookmark_title_len: usize,
5252 pub max_webhook_name_len: usize,
5353 pub max_mentions_per_message: usize,
54 pub max_attachments_per_message: usize,
55 pub max_members_per_add: usize,
56 pub max_pending_attachments_per_user: i64,
5457 pub typing_ttl_secs: u64,
5558 pub presence_ttl_secs: u64,
5659 pub ticket_secret: String,
@@ -71,6 +74,9 @@
7174 max_bookmark_title_len: 255,
7275 max_webhook_name_len: 100,
7376 max_mentions_per_message: 20,
77 max_attachments_per_message: 10,
78 max_members_per_add: 50,
79 max_pending_attachments_per_user: 200,
7480 typing_ttl_secs: 5,
7581 presence_ttl_secs: 300,
7682 ticket_secret: String::new(),
Modified lib/models/src/users/user_email_verification_codes.rs View lib/models/src/users/user_email_verification_codes.rs
@@ -23,6 +23,8 @@
2323 PasswordReset,
2424 #[sea_orm(string_value = "add_email")]
2525 AddEmail,
26 #[sea_orm(string_value = "reclaim")]
27 Reclaim,
2628}
2729
2830#[sea_orm::model]
Modified lib/session/src/share/session.rs View lib/session/src/share/session.rs
@@ -20,6 +20,11 @@
2020 session.set(PWD_VERSION_KEY, passwd_version.unwrap_or(0));
2121}
2222
23pub fn apply_pwd_version(session: &Session, version: i64) {
24 session.set_store(true);
25 session.set(PWD_VERSION_KEY, version);
26}
27
2328pub fn pwd_version_matches(session: &Session, current: Option<i64>) -> bool {
2429 let stored = session.get::<i64>(PWD_VERSION_KEY);
2530 match (stored, current) {
Modified lib/space/src/services/bans.rs View lib/space/src/services/bans.rs
@@ -1,6 +1,7 @@
11use models::space::space_bans;
22use models::space::space_member_groups;
33use models::space::space_members::{self, MemberStatus};
4use models::space::spaces;
45use models::users::user;
56use sea_orm::{
67 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
@@ -123,11 +124,24 @@
123124 let ban = ban_row.insert(txn).await?;
124125
125126 if let Some(m) = member {
127 let was_active = m.status == MemberStatus::Active;
126128 let mut am: space_members::ActiveModel = m.into();
127129 am.status = Set(MemberStatus::Removed);
128130 am.left_at = Set(Some(now));
129131 am.updated_at = Set(Some(now));
130132 am.update(txn).await?;
133 if was_active {
134 spaces::Entity::update_many()
135 .filter(spaces::Column::Id.eq(space.id))
136 .col_expr(
137 spaces::Column::NumsMembers,
138 sea_orm::sea_query::Expr::cust(
139 "GREATEST(nums_members - 1, 0)",
140 ),
141 )
142 .exec(txn)
143 .await?;
144 }
131145 }
132146
133147 space_member_groups::Entity::delete_many()
Modified lib/space/src/services/dir.rs View lib/space/src/services/dir.rs
@@ -5,7 +5,7 @@
55use models::dir::dirs::{self, DirKind};
66use models::space::space_groups;
77use models::space::space_member_groups;
8use models::users::user;
8use models::space::space_members::{self, MemberStatus};
99use sea_orm::{
1010 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
1111 TransactionTrait,
@@ -745,10 +745,15 @@
745745 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
746746 self.dir_in_space(space.id, dir_id).await?;
747747
748 user::Entity::find_by_id(user_id)
749 .one(self.db().read())
750 .await?
751 .ok_or(SpaceError::NotFound)?;
748 if space.owner_id != user_id {
749 space_members::Entity::find_by_id((space.id, user_id))
750 .one(self.db().read())
751 .await?
752 .filter(|m| m.status == MemberStatus::Active)
753 .ok_or(SpaceError::BadRequest(
754 "target user is not an active member of this space".to_string(),
755 ))?;
756 }
752757
753758 if cmd.allow_mask < 0 || cmd.deny_mask < 0 {
754759 return Err(SpaceError::BadRequest(
Modified lib/space/src/services/groups.rs View lib/space/src/services/groups.rs
@@ -16,7 +16,9 @@
1616use crate::errors::SpaceError;
1717use crate::services::service::SpaceService;
1818use crate::services::spaces::{PageQuery, UserBriefDto};
19use crate::share::authz::{ALL_PERMISSIONS, MANAGE_ROLES, effective_permissions, require_perm};
19use crate::share::authz::{
20 ALL_PERMISSIONS, MANAGE_ROLES, VIEW_SPACE, effective_permissions, require_perm,
21};
2022use crate::share::lookup::lookup_space_by_name;
2123
2224#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -205,13 +207,15 @@
205207 .await?
206208 .ok_or(SpaceError::NotFound)?;
207209
210 let Some(uid) = caller else {
211 return Err(SpaceError::NotFound);
212 };
208213 if space.is_private {
209 let Some(uid) = caller else {
210 return Err(SpaceError::NotFound);
211 };
212214 if require_perm(self, &space, uid, MANAGE_ROLES).await.is_err() {
213215 return Err(SpaceError::NotFound);
214216 }
217 } else if require_perm(self, &space, uid, VIEW_SPACE).await.is_err() {
218 return Err(SpaceError::Forbidden);
215219 }
216220
217221 let perms = space_group_permissions::Entity::find()
Modified lib/space/src/share/lookup.rs View lib/space/src/share/lookup.rs
@@ -13,5 +13,6 @@
1313 .filter(spaces::Column::LowerName.eq(&lower_name))
1414 .one(service.db().read())
1515 .await?
16 .filter(|s| s.is_active)
1617 .ok_or(SpaceError::NotFound)
1718}
Modified lib/storage/src/s3.rs View lib/storage/src/s3.rs
@@ -269,6 +269,17 @@
269269 Ok((200..300).contains(&status))
270270 }
271271
272 pub async fn head(&self, key: &str) -> Result<Option<(u64, String)>, StorageError> {
273 let (meta, status) = self.bucket.head_object(key).await?;
274 if !(200..300).contains(&status) {
275 return Ok(None);
276 }
277 Ok(Some((
278 meta.content_length.unwrap_or(0).max(0) as u64,
279 meta.content_type.unwrap_or_default(),
280 )))
281 }
282
272283 pub async fn bucket_exists(&self) -> Result<bool, StorageError> {
273284 Ok(self.bucket.exists().await?)
274285 }
Modified lib/user/src/services/profile.rs View lib/user/src/services/profile.rs
@@ -95,7 +95,8 @@
9595 pub username: String,
9696 pub display_name: String,
9797 pub avatar_url: String,
98 pub visibility: ProfileVisibility,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub visibility: Option<ProfileVisibility>,
99100 pub real_name: Option<String>,
100101 pub bio: Option<String>,
101102 pub pronouns: Option<String>,
@@ -104,10 +105,14 @@
104105 pub company: Option<String>,
105106 pub banner_url: Option<String>,
106107 pub accent_color: Option<String>,
107 pub show_email: bool,
108 pub show_location: bool,
109 pub show_real_name: bool,
110 pub show_activity: bool,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub show_email: Option<bool>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub show_location: Option<bool>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub show_real_name: Option<bool>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub show_activity: Option<bool>,
111116 pub updated_at: OffsetDateTime,
112117}
113118
@@ -271,6 +276,9 @@
271276 .ok_or(UserError::NotFound)?;
272277
273278 let is_owner = viewer == Some(target_uid);
279 if !user.is_active && !is_owner {
280 return Err(UserError::NotFound);
281 }
274282 let can_view = match user.visibility {
275283 ProfileVisibility::Public => true,
276284 ProfileVisibility::RegisteredOnly => viewer.is_some(),
@@ -293,26 +301,28 @@
293301 .await?;
294302
295303 if let Some(p) = profile {
296 let emit_real_name = is_owner || p.show_real_name;
297 let emit_location = is_owner || p.show_location;
304 let gate = |flag: bool, v: Option<String>| -> Option<String> {
305 if is_owner || flag { v } else { None }
306 };
307 let emit_activity = is_owner || p.show_activity;
298308 Ok(ProfileDetailDto {
299309 user_id: user.uid,
300310 username: user.username,
301311 display_name: user.display_name,
302312 avatar_url: user.avatar_url,
303 visibility: user.visibility,
304 real_name: if emit_real_name { p.real_name } else { None },
305 bio: p.bio,
306 pronouns: p.pronouns,
307 website: p.website,
308 location: if emit_location { p.location } else { None },
309 company: p.company,
310 banner_url: p.banner_url,
311 accent_color: p.accent_color,
312 show_email: p.show_email,
313 show_location: p.show_location,
314 show_real_name: p.show_real_name,
315 show_activity: p.show_activity,
313 visibility: if is_owner { Some(user.visibility) } else { None },
314 real_name: gate(p.show_real_name, p.real_name),
315 bio: gate(emit_activity, p.bio),
316 pronouns: gate(emit_activity, p.pronouns),
317 website: gate(emit_activity, p.website),
318 location: gate(p.show_location, p.location),
319 company: gate(emit_activity, p.company),
320 banner_url: gate(emit_activity, p.banner_url),
321 accent_color: gate(emit_activity, p.accent_color),
322 show_email: if is_owner { Some(p.show_email) } else { None },
323 show_location: if is_owner { Some(p.show_location) } else { None },
324 show_real_name: if is_owner { Some(p.show_real_name) } else { None },
325 show_activity: if is_owner { Some(p.show_activity) } else { None },
316326 updated_at: p.updated_at,
317327 })
318328 } else {
@@ -321,7 +331,7 @@
321331 username: user.username,
322332 display_name: user.display_name,
323333 avatar_url: user.avatar_url,
324 visibility: user.visibility,
334 visibility: if is_owner { Some(user.visibility) } else { None },
325335 real_name: None,
326336 bio: None,
327337 pronouns: None,
@@ -330,10 +340,10 @@
330340 company: None,
331341 banner_url: None,
332342 accent_color: None,
333 show_email: false,
334 show_location: false,
335 show_real_name: false,
336 show_activity: false,
343 show_email: if is_owner { Some(false) } else { None },
344 show_location: if is_owner { Some(false) } else { None },
345 show_real_name: if is_owner { Some(false) } else { None },
346 show_activity: if is_owner { Some(false) } else { None },
337347 updated_at: user.updated_at,
338348 })
339349 }