|
1 |
+ |
//! The send path.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! Six things have to happen in a particular order, and the order is a security
|
|
4 |
+ |
//! property rather than a style preference, so it lives here once instead of in
|
|
5 |
+ |
//! each host.
|
|
6 |
+ |
//!
|
|
7 |
+ |
//! 1. **Validate the body.** Free, and rejects the largest class of junk.
|
|
8 |
+ |
//! 2. **Check room state.** Free, and a closed or read-only room ends it.
|
|
9 |
+ |
//! 3. **Take a rate token.** In-memory. Deliberately before authz, so a caller
|
|
10 |
+ |
//! hammering a room they cannot write to is throttled at memory speed instead
|
|
11 |
+ |
//! of putting a database query behind every attempt. The cost is that a
|
|
12 |
+ |
//! refused sender still spends budget, which is the right trade: the budget
|
|
13 |
+ |
//! exists to protect the room and the box, not to be fair to someone who is
|
|
14 |
+ |
//! already being refused.
|
|
15 |
+ |
//! 4. **Check authz.** The first step that touches the host's database.
|
|
16 |
+ |
//! 5. **Store.** The host renders through docengine and assigns the id.
|
|
17 |
+ |
//! 6. **Publish.** Only after the row is durable, so nobody sees a message that
|
|
18 |
+ |
//! a failed insert means will not survive a reconnect.
|
|
19 |
+ |
//!
|
|
20 |
+ |
//! Getting 5 and 6 backwards is the interesting mistake. Publishing first makes
|
|
21 |
+ |
//! chat feel faster and produces a room where a message is visible to everyone
|
|
22 |
+ |
//! present, absent from the backlog, and gone for anyone who reloads.
|
|
23 |
+ |
|
|
24 |
+ |
use crate::chat::SendRequest;
|
|
25 |
+ |
use crate::error::ChatError;
|
|
26 |
+ |
use crate::event::ChatEvent;
|
|
27 |
+ |
use crate::hub::Hub;
|
|
28 |
+ |
use crate::message::{Message, MessageBody};
|
|
29 |
+ |
use crate::rate_limit::{RateDecision, RateLimiter};
|
|
30 |
+ |
use crate::traits::{ChatAuthz, DenyReason, WriteAccess};
|
|
31 |
+ |
|
|
32 |
+ |
/// Why a send was refused, distinguishing the cases a caller must respond to
|
|
33 |
+ |
/// differently.
|
|
34 |
+ |
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
35 |
+ |
pub enum SendRejection {
|
|
36 |
+ |
/// The body did not survive validation.
|
|
37 |
+ |
Invalid(String),
|
|
38 |
+ |
/// The room is not accepting messages.
|
|
39 |
+ |
RoomClosed,
|
|
40 |
+ |
RoomReadOnly,
|
|
41 |
+ |
/// Authz or rate limiting said no.
|
|
42 |
+ |
Denied(DenyReason),
|
|
43 |
+ |
}
|
|
44 |
+ |
|
|
45 |
+ |
/// Validate, authorize, store, and fan out one message.
|
|
46 |
+ |
///
|
|
47 |
+ |
/// Reached through [`crate::Chat::send`]; the module docs above explain the
|
|
48 |
+ |
/// ordering it enforces.
|
|
49 |
+ |
///
|
|
50 |
+ |
/// `store` receives the validated body and must render it (through docengine's
|
|
51 |
+ |
/// chat preset, never raw) and insert it, returning the stored [`Message`]. It is
|
|
52 |
+ |
/// called only after every check has passed.
|
|
53 |
+ |
pub(crate) async fn send<A, F, Fut>(
|
|
54 |
+ |
hub: &Hub,
|
|
55 |
+ |
limiter: &RateLimiter,
|
|
56 |
+ |
authz: &A,
|
|
57 |
+ |
req: SendRequest<'_>,
|
|
58 |
+ |
store: F,
|
|
59 |
+ |
) -> Result<Message, ChatError>
|
|
60 |
+ |
where
|
|
61 |
+ |
A: ChatAuthz + ?Sized,
|
|
62 |
+ |
F: FnOnce(MessageBody) -> Fut,
|
|
63 |
+ |
Fut: Future<Output = Result<Message, ChatError>>,
|
|
64 |
+ |
{
|
|
65 |
+ |
let SendRequest {
|
|
66 |
+ |
room,
|
|
67 |
+ |
author,
|
|
68 |
+ |
body: raw_body,
|
|
69 |
+ |
nonce,
|
|
70 |
+ |
now,
|
|
71 |
+ |
} = req;
|
|
72 |
+ |
|
|
73 |
+ |
// 1. Free.
|
|
74 |
+ |
let body = MessageBody::parse(raw_body)
|
|
75 |
+ |
.map_err(|e| ChatError::Rejected(SendRejection::Invalid(e.to_string())))?;
|
|
76 |
+ |
|
|
77 |
+ |
// 2. Free.
|
|
78 |
+ |
if !room.state.allows_reads() {
|
|
79 |
+ |
return Err(ChatError::Rejected(SendRejection::RoomClosed));
|
|
80 |
+ |
}
|
|
81 |
+ |
if !room.state.allows_writes() {
|
|
82 |
+ |
return Err(ChatError::Rejected(SendRejection::RoomReadOnly));
|
|
83 |
+ |
}
|
|
84 |
+ |
|
|
85 |
+ |
// 3. In-memory, before anything that queries.
|
|
86 |
+ |
if let RateDecision::Deny { retry_after } = limiter.check(author, room.id, now) {
|
|
87 |
+ |
return Err(ChatError::Rejected(SendRejection::Denied(
|
|
88 |
+ |
DenyReason::RateLimited { retry_after },
|
|
89 |
+ |
)));
|
|
90 |
+ |
}
|
|
91 |
+ |
|
|
92 |
+ |
// 4. First database hit.
|
|
93 |
+ |
if let WriteAccess::Deny(reason) = authz.can_write(author, room).await? {
|
|
94 |
+ |
return Err(ChatError::Rejected(SendRejection::Denied(reason)));
|
|
95 |
+ |
}
|
|
96 |
+ |
|
|
97 |
+ |
// 5. Durable before visible.
|
|
98 |
+ |
let mut stored = store(body).await?;
|
|
99 |
+ |
stored.nonce = nonce;
|
|
100 |
+ |
|
|
101 |
+ |
// 6. Fan out. A room with nobody in it is normal and not a failure.
|
|
102 |
+ |
hub.publish(room.id, ChatEvent::Message(stored.clone()));
|
|
103 |
+ |
|
|
104 |
+ |
Ok(stored)
|
|
105 |
+ |
}
|
|
106 |
+ |
|
|
107 |
+ |
#[cfg(test)]
|
|
108 |
+ |
mod tests {
|
|
109 |
+ |
use super::*;
|
|
110 |
+ |
use crate::hub::HubLimits;
|
|
111 |
+ |
use crate::ids::{MessageId, Nonce, RoomId, UserId};
|
|
112 |
+ |
use crate::message::MAX_MESSAGE_LEN;
|
|
113 |
+ |
use crate::rate_limit::RateLimits;
|
|
114 |
+ |
use crate::retention::Retention;
|
|
115 |
+ |
use crate::room::{Room, RoomState};
|
|
116 |
+ |
use async_trait::async_trait;
|
|
117 |
+ |
use std::sync::Arc;
|
|
118 |
+ |
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
119 |
+ |
use std::time::Instant;
|
|
120 |
+ |
use uuid::Uuid;
|
|
121 |
+ |
|
|
122 |
+ |
struct Authz {
|
|
123 |
+ |
decision: WriteAccess,
|
|
124 |
+ |
calls: Arc<AtomicUsize>,
|
|
125 |
+ |
}
|
|
126 |
+ |
|
|
127 |
+ |
#[async_trait]
|
|
128 |
+ |
impl ChatAuthz for Authz {
|
|
129 |
+ |
async fn can_read(&self, _v: Option<UserId>, room: &Room) -> Result<bool, ChatError> {
|
|
130 |
+ |
Ok(room.state.allows_reads())
|
|
131 |
+ |
}
|
|
132 |
+ |
async fn can_write(&self, _u: UserId, _r: &Room) -> Result<WriteAccess, ChatError> {
|
|
133 |
+ |
self.calls.fetch_add(1, Ordering::SeqCst);
|
|
134 |
+ |
Ok(self.decision.clone())
|
|
135 |
+ |
}
|
|
136 |
+ |
async fn is_moderator(&self, _u: UserId, _r: &Room) -> Result<bool, ChatError> {
|
|
137 |
+ |
Ok(false)
|
|
138 |
+ |
}
|
|
139 |
+ |
}
|
|
140 |
+ |
|
|
141 |
+ |
fn authz(decision: WriteAccess) -> (Authz, Arc<AtomicUsize>) {
|
|
142 |
+ |
let calls = Arc::new(AtomicUsize::new(0));
|
|
143 |
+ |
(
|
|
144 |
+ |
Authz {
|
|
145 |
+ |
decision,
|
|
146 |
+ |
calls: calls.clone(),
|
|
147 |
+ |
},
|
|
148 |
+ |
calls,
|
|
149 |
+ |
)
|
|
150 |
+ |
}
|
|
151 |
+ |
|
|
152 |
+ |
fn room(state: RoomState) -> Room {
|
|
153 |
+ |
Room {
|
|
154 |
+ |
id: RoomId(Uuid::new_v4()),
|
|
155 |
+ |
state,
|
|
156 |
+ |
retention: Retention::forum_default(),
|
|
157 |
+ |
}
|
|
158 |
+ |
}
|
|
159 |
+ |
|
|
160 |
+ |
fn stored(room: RoomId, author: UserId, body: &MessageBody) -> Message {
|
|
161 |
+ |
Message {
|
|
162 |
+ |
id: MessageId(1),
|
|
163 |
+ |
room_id: room,
|
|
164 |
+ |
author_id: author,
|
|
165 |
+ |
body_html: format!("<p>{}</p>", body.as_str()),
|
|
166 |
+ |
created_at: 0,
|
|
167 |
+ |
nonce: None,
|
|
168 |
+ |
}
|
|
169 |
+ |
}
|
|
170 |
+ |
|
|
171 |
+ |
fn parts() -> (Hub, RateLimiter) {
|
|
172 |
+ |
(
|
|
173 |
+ |
Hub::new(HubLimits::default()),
|
|
174 |
+ |
RateLimiter::new(RateLimits::default()),
|
|
175 |
+ |
)
|
|
176 |
+ |
}
|
|
177 |
+ |
|
|
178 |
+ |
fn req<'a>(room: &'a Room, author: UserId, body: &'a str) -> SendRequest<'a> {
|
|
179 |
+ |
SendRequest {
|
|
180 |
+ |
room,
|
|
181 |
+ |
author,
|
|
182 |
+ |
body,
|
|
183 |
+ |
nonce: None,
|
|
184 |
+ |
now: Instant::now(),
|
|
185 |
+ |
}
|
|
186 |
+ |
}
|
|
187 |
+ |
|
|
188 |
+ |
#[tokio::test]
|
|
189 |
+ |
async fn a_good_message_is_stored_then_published() {
|
|
190 |
+ |
let (hub, rl) = parts();
|
|
191 |
+ |
let (a, _) = authz(WriteAccess::Allow);
|
|
192 |
+ |
let r = room(RoomState::Open);
|
|
193 |
+ |
let author = UserId(Uuid::new_v4());
|
|
194 |
+ |
let mut listener = hub.subscribe(r.id, UserId(Uuid::new_v4())).unwrap();
|
|
195 |
+ |
|
|
196 |
+ |
let out = send(
|
|
197 |
+ |
&hub,
|
|
198 |
+ |
&rl,
|
|
199 |
+ |
&a,
|
|
200 |
+ |
SendRequest {
|
|
201 |
+ |
nonce: Nonce::parse("n1"),
|
|
202 |
+ |
..req(&r, author, " hello ")
|
|
203 |
+ |
},
|
|
204 |
+ |
|body| async move { Ok(stored(r.id, author, &body)) },
|
|
205 |
+ |
)
|
|
206 |
+ |
.await
|
|
207 |
+ |
.unwrap();
|
|
208 |
+ |
|
|
209 |
+ |
assert_eq!(out.body_html, "<p>hello</p>", "body was trimmed and rendered");
|
|
210 |
+ |
assert_eq!(out.nonce, Nonce::parse("n1"));
|
|
211 |
+ |
|
|
212 |
+ |
match listener.recv().await {
|
|
213 |
+ |
Some(ChatEvent::Message(m)) => {
|
|
214 |
+ |
assert_eq!(m.id, MessageId(1));
|
|
215 |
+ |
assert_eq!(m.nonce, Nonce::parse("n1"), "sender can reconcile");
|
|
216 |
+ |
}
|
|
217 |
+ |
other => panic!("expected the message on the room, got {other:?}"),
|
|
218 |
+ |
}
|
|
219 |
+ |
}
|
|
220 |
+ |
|
|
221 |
+ |
#[tokio::test]
|
|
222 |
+ |
async fn nothing_is_published_when_the_store_fails() {
|
|
223 |
+ |
// The ordering this protects: a message visible to everyone present but
|
|
224 |
+ |
// absent from the backlog and gone on reload.
|
|
225 |
+ |
let (hub, rl) = parts();
|
|
226 |
+ |
let (a, _) = authz(WriteAccess::Allow);
|
|
227 |
+ |
let r = room(RoomState::Open);
|
|
228 |
+ |
let mut listener = hub.subscribe(r.id, UserId(Uuid::new_v4())).unwrap();
|
|
229 |
+ |
|
|
230 |
+ |
let result = send(
|
|
231 |
+ |
&hub,
|
|
232 |
+ |
&rl,
|
|
233 |
+ |
&a,
|
|
234 |
+ |
req(&r, UserId(Uuid::new_v4()), "hi"),
|
|
235 |
+ |
|_| async { Err(ChatError::host(std::io::Error::other("db down"))) },
|
|
236 |
+ |
)
|
|
237 |
+ |
.await;
|
|
238 |
+ |
|
|
239 |
+ |
assert!(result.is_err());
|
|
240 |
+ |
// Probe the room. If a phantom message had been published it would be
|
|
241 |
+ |
// ahead of this in the queue.
|
|
242 |
+ |
assert_eq!(hub.publish(r.id, ChatEvent::Gap), 1, "listener still live");
|
|
243 |
+ |
assert!(
|
|
244 |
+ |
matches!(listener.recv().await, Some(ChatEvent::Gap)),
|
|
245 |
+ |
"a failed store must publish nothing"
|
|
246 |
+ |
);
|
|
247 |
+ |
}
|
|
248 |
+ |
|
|
249 |
+ |
#[tokio::test]
|
|
250 |
+ |
async fn an_empty_body_never_reaches_authz() {
|
|
251 |
+ |
let (hub, rl) = parts();
|
|
252 |
+ |
let (a, calls) = authz(WriteAccess::Allow);
|
|
253 |
+ |
let r = room(RoomState::Open);
|
|
254 |
+ |
|
|
255 |
+ |
let result = send(&hub, &rl, &a, req(&r, UserId(Uuid::new_v4()), " "), |_| async {
|
|
256 |
+ |
panic!("store must not run")
|
|
257 |
+ |
})
|
|
258 |
+ |
.await;
|
|
259 |
+ |
|
|
260 |
+ |
assert!(matches!(
|
|
261 |
+ |
result,
|
|
262 |
+ |
Err(ChatError::Rejected(SendRejection::Invalid(_)))
|
|
263 |
+ |
));
|
|
264 |
+ |
assert_eq!(calls.load(Ordering::SeqCst), 0, "no query for a blank body");
|
|
265 |
+ |
}
|
|
266 |
+ |
|
|
267 |
+ |
#[tokio::test]
|
|
268 |
+ |
async fn an_overlong_body_is_refused() {
|
|
269 |
+ |
let (hub, rl) = parts();
|
|
270 |
+ |
let (a, _) = authz(WriteAccess::Allow);
|
|
271 |
+ |
let r = room(RoomState::Open);
|
|
272 |
+ |
let long = "x".repeat(MAX_MESSAGE_LEN + 1);
|
|
273 |
+ |
|
|
274 |
+ |
let result = send(&hub, &rl, &a, req(&r, UserId(Uuid::new_v4()), &long), |_| async {
|
|
275 |
+ |
panic!("store must not run")
|
|
276 |
+ |
})
|
|
277 |
+ |
.await;
|
|
278 |
+ |
|
|
279 |
+ |
assert!(matches!(
|
|
280 |
+ |
result,
|
|
281 |
+ |
Err(ChatError::Rejected(SendRejection::Invalid(_)))
|
|
282 |
+ |
));
|
|
283 |
+ |
}
|
|
284 |
+ |
|
|
285 |
+ |
#[tokio::test]
|
|
286 |
+ |
async fn a_read_only_room_refuses_before_querying() {
|
|
287 |
+ |
let (hub, rl) = parts();
|
|
288 |
+ |
let (a, calls) = authz(WriteAccess::Allow);
|
|
289 |
+ |
let r = room(RoomState::ReadOnly);
|
|
290 |
+ |
|
|
291 |
+ |
let result = send(&hub, &rl, &a, req(&r, UserId(Uuid::new_v4()), "hi"), |_| async {
|
|
292 |
+ |
panic!("store must not run")
|
|
293 |
+ |
})
|
|
294 |
+ |
.await;
|
|
295 |
+ |
|
|
296 |
+ |
assert!(matches!(
|
|
297 |
+ |
result,
|
|
298 |
+ |
Err(ChatError::Rejected(SendRejection::RoomReadOnly))
|
|
299 |
+ |
));
|
|
300 |
+ |
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
|
301 |
+ |
}
|
|
302 |
+ |
|
|
303 |
+ |
#[tokio::test]
|
|
304 |
+ |
async fn a_closed_room_refuses_as_closed_not_read_only() {
|
|
305 |
+ |
let (hub, rl) = parts();
|
|
306 |
+ |
let (a, _) = authz(WriteAccess::Allow);
|
|
307 |
+ |
let r = room(RoomState::Closed);
|
|
308 |
+ |
|
|
309 |
+ |
let result = send(&hub, &rl, &a, req(&r, UserId(Uuid::new_v4()), "hi"), |_| async {
|
|
310 |
+ |
panic!("store must not run")
|
|
311 |
+ |
})
|
|
312 |
+ |
.await;
|
|
313 |
+ |
|
|
314 |
+ |
assert!(matches!(
|
|
315 |
+ |
result,
|
|
316 |
+ |
Err(ChatError::Rejected(SendRejection::RoomClosed))
|
|
317 |
+ |
));
|
|
318 |
+ |
}
|
|
319 |
+ |
|
|
320 |
+ |
#[tokio::test]
|
|
321 |
+ |
async fn a_denied_sender_gets_the_specific_reason() {
|
|
322 |
+ |
let (hub, rl) = parts();
|
|
323 |
+ |
let (a, _) = authz(WriteAccess::Deny(DenyReason::Muted));
|
|
324 |
+ |
let r = room(RoomState::Open);
|
|
325 |
+ |
|
|
326 |
+ |
let result = send(&hub, &rl, &a, req(&r, UserId(Uuid::new_v4()), "hi"), |_| async {
|
|
327 |
+ |
panic!("store must not run")
|
|
328 |
+ |
})
|
|
329 |
+ |
.await;
|
|
330 |
+ |
|
|
331 |
+ |
assert!(matches!(
|
|
332 |
+ |
result,
|
|
333 |
+ |
Err(ChatError::Rejected(SendRejection::Denied(DenyReason::Muted)))
|
|
334 |
+ |
));
|
|
335 |
+ |
}
|
|
336 |
+ |
|
|
337 |
+ |
#[tokio::test]
|
|
338 |
+ |
async fn the_rate_limit_bites_before_authz_is_queried() {
|
|
339 |
+ |
// A caller hammering a room is throttled in memory rather than putting a
|
|
340 |
+ |
// query behind every attempt.
|
|
341 |
+ |
let hub = Hub::new(HubLimits::default());
|
|
342 |
+ |
let rl = RateLimiter::new(RateLimits {
|
|
343 |
+ |
burst: 2,
|
|
344 |
+ |
sustain_per_min: 60,
|
|
345 |
+ |
});
|
|
346 |
+ |
let (a, calls) = authz(WriteAccess::Allow);
|
|
347 |
+ |
let r = room(RoomState::Open);
|
|
348 |
+ |
let author = UserId(Uuid::new_v4());
|
|
349 |
+ |
let t0 = Instant::now();
|
|
350 |
+ |
|
|
351 |
+ |
let at = |body| SendRequest {
|
|
352 |
+ |
room: &r,
|
|
353 |
+ |
author,
|
|
354 |
+ |
body,
|
|
355 |
+ |
nonce: None,
|
|
356 |
+ |
now: t0,
|
|
357 |
+ |
};
|
|
358 |
+ |
|
|
359 |
+ |
for _ in 0..2 {
|
|
360 |
+ |
send(&hub, &rl, &a, at("hi"), |body| async move {
|
|
361 |
+ |
Ok(stored(r.id, author, &body))
|
|
362 |
+ |
})
|
|
363 |
+ |
.await
|
|
364 |
+ |
.unwrap();
|
|
365 |
+ |
}
|
|
366 |
+ |
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
|
367 |
+ |
|
|
368 |
+ |
for _ in 0..10 {
|
|
369 |
+ |
let result = send(&hub, &rl, &a, at("hi"), |_| async {
|
|
370 |
+ |
panic!("store must not run")
|
|
371 |
+ |
})
|
|
372 |
+ |
.await;
|
|
373 |
+ |
assert!(matches!(
|
|
374 |
+ |
result,
|
|
375 |
+ |
Err(ChatError::Rejected(SendRejection::Denied(
|
|
376 |
+ |
DenyReason::RateLimited { .. }
|
|
377 |
+ |
)))
|
|
378 |
+ |
));
|
|
379 |
+ |
}
|
|
380 |
+ |
assert_eq!(
|
|
381 |
+ |
calls.load(Ordering::SeqCst),
|
|
382 |
+ |
2,
|
|
383 |
+ |
"throttled attempts must not reach the database"
|
|
384 |
+ |
);
|
|
385 |
+ |
}
|
|
386 |
+ |
}
|