]> Untitled Git - lemmy.git/blob - crates/utils/src/utils/validation.rs
Dont compare db string errors (fixes #1393) (#3424)
[lemmy.git] / crates / utils / src / utils / validation.rs
1 use crate::error::{LemmyError, LemmyResult};
2 use itertools::Itertools;
3 use once_cell::sync::Lazy;
4 use regex::{Regex, RegexBuilder};
5 use totp_rs::{Secret, TOTP};
6 use url::Url;
7
8 static VALID_ACTOR_NAME_REGEX: Lazy<Regex> =
9   Lazy::new(|| Regex::new(r"^[a-zA-Z0-9_]{3,}$").expect("compile regex"));
10 static VALID_POST_TITLE_REGEX: Lazy<Regex> =
11   Lazy::new(|| Regex::new(r".*\S{3,200}.*").expect("compile regex"));
12 static VALID_MATRIX_ID_REGEX: Lazy<Regex> = Lazy::new(|| {
13   Regex::new(r"^@[A-Za-z0-9._=-]+:[A-Za-z0-9.-]+\.[A-Za-z]{2,}$").expect("compile regex")
14 });
15 // taken from https://en.wikipedia.org/wiki/UTM_parameters
16 static CLEAN_URL_PARAMS_REGEX: Lazy<Regex> = Lazy::new(|| {
17   Regex::new(r"^utm_source|utm_medium|utm_campaign|utm_term|utm_content|gclid|gclsrc|dclid|fbclid$")
18     .expect("compile regex")
19 });
20
21 const BODY_MAX_LENGTH: usize = 10000;
22 const POST_BODY_MAX_LENGTH: usize = 50000;
23 const BIO_MAX_LENGTH: usize = 300;
24 const SITE_NAME_MAX_LENGTH: usize = 20;
25 const SITE_NAME_MIN_LENGTH: usize = 1;
26 const SITE_DESCRIPTION_MAX_LENGTH: usize = 150;
27
28 fn has_newline(name: &str) -> bool {
29   name.contains('\n')
30 }
31
32 pub fn is_valid_actor_name(name: &str, actor_name_max_length: usize) -> LemmyResult<()> {
33   let check = name.chars().count() <= actor_name_max_length
34     && VALID_ACTOR_NAME_REGEX.is_match(name)
35     && !has_newline(name);
36   if !check {
37     Err(LemmyError::from_message("invalid_name"))
38   } else {
39     Ok(())
40   }
41 }
42
43 // Can't do a regex here, reverse lookarounds not supported
44 pub fn is_valid_display_name(name: &str, actor_name_max_length: usize) -> LemmyResult<()> {
45   let check = !name.starts_with('@')
46     && !name.starts_with('\u{200b}')
47     && name.chars().count() >= 3
48     && name.chars().count() <= actor_name_max_length
49     && !has_newline(name);
50   if !check {
51     Err(LemmyError::from_message("invalid_username"))
52   } else {
53     Ok(())
54   }
55 }
56
57 pub fn is_valid_matrix_id(matrix_id: &str) -> LemmyResult<()> {
58   let check = VALID_MATRIX_ID_REGEX.is_match(matrix_id) && !has_newline(matrix_id);
59   if !check {
60     Err(LemmyError::from_message("invalid_matrix_id"))
61   } else {
62     Ok(())
63   }
64 }
65
66 pub fn is_valid_post_title(title: &str) -> LemmyResult<()> {
67   let check = VALID_POST_TITLE_REGEX.is_match(title) && !has_newline(title);
68   if !check {
69     Err(LemmyError::from_message("invalid_post_title"))
70   } else {
71     Ok(())
72   }
73 }
74
75 /// This could be post bodies, comments, or any description field
76 pub fn is_valid_body_field(body: &Option<String>, post: bool) -> LemmyResult<()> {
77   if let Some(body) = body {
78     let check = if post {
79       body.chars().count() <= POST_BODY_MAX_LENGTH
80     } else {
81       body.chars().count() <= BODY_MAX_LENGTH
82     };
83
84     if !check {
85       Err(LemmyError::from_message("invalid_body_field"))
86     } else {
87       Ok(())
88     }
89   } else {
90     Ok(())
91   }
92 }
93
94 pub fn is_valid_bio_field(bio: &str) -> LemmyResult<()> {
95   max_length_check(bio, BIO_MAX_LENGTH, String::from("bio_length_overflow"))
96 }
97
98 /// Checks the site name length, the limit as defined in the DB.
99 pub fn site_name_length_check(name: &str) -> LemmyResult<()> {
100   min_max_length_check(
101     name,
102     SITE_NAME_MIN_LENGTH,
103     SITE_NAME_MAX_LENGTH,
104     String::from("site_name_required"),
105     String::from("site_name_length_overflow"),
106   )
107 }
108
109 /// Checks the site description length, the limit as defined in the DB.
110 pub fn site_description_length_check(description: &str) -> LemmyResult<()> {
111   max_length_check(
112     description,
113     SITE_DESCRIPTION_MAX_LENGTH,
114     String::from("site_description_length_overflow"),
115   )
116 }
117
118 fn max_length_check(item: &str, max_length: usize, msg: String) -> LemmyResult<()> {
119   if item.len() > max_length {
120     Err(LemmyError::from_message(&msg))
121   } else {
122     Ok(())
123   }
124 }
125
126 fn min_max_length_check(
127   item: &str,
128   min_length: usize,
129   max_length: usize,
130   min_msg: String,
131   max_msg: String,
132 ) -> LemmyResult<()> {
133   if item.len() > max_length {
134     Err(LemmyError::from_message(&max_msg))
135   } else if item.len() < min_length {
136     Err(LemmyError::from_message(&min_msg))
137   } else {
138     Ok(())
139   }
140 }
141
142 /// Attempts to build a regex and check it for common errors before inserting into the DB.
143 pub fn build_and_check_regex(regex_str_opt: &Option<&str>) -> LemmyResult<Option<Regex>> {
144   regex_str_opt.map_or_else(
145     || Ok(None::<Regex>),
146     |regex_str| {
147       if regex_str.is_empty() {
148         // If the proposed regex is empty, return as having no regex at all; this is the same
149         // behavior that happens downstream before the write to the database.
150         return Ok(None::<Regex>);
151       }
152
153       RegexBuilder::new(regex_str)
154         .case_insensitive(true)
155         .build()
156         .map_err(|e| LemmyError::from_error_message(e, "invalid_regex"))
157         .and_then(|regex| {
158           // NOTE: It is difficult to know, in the universe of user-crafted regex, which ones
159           // may match against any string text. To keep it simple, we'll match the regex
160           // against an innocuous string - a single number - which should help catch a regex
161           // that accidentally matches against all strings.
162           if regex.is_match("1") {
163             return Err(LemmyError::from_message("permissive_regex"));
164           }
165
166           Ok(Some(regex))
167         })
168     },
169   )
170 }
171
172 pub fn clean_url_params(url: &Url) -> Url {
173   let mut url_out = url.clone();
174   if url.query().is_some() {
175     let new_query = url
176       .query_pairs()
177       .filter(|q| !CLEAN_URL_PARAMS_REGEX.is_match(&q.0))
178       .map(|q| format!("{}={}", q.0, q.1))
179       .join("&");
180     url_out.set_query(Some(&new_query));
181   }
182   url_out
183 }
184
185 pub fn check_totp_2fa_valid(
186   totp_secret: &Option<String>,
187   totp_token: &Option<String>,
188   site_name: &str,
189   username: &str,
190 ) -> LemmyResult<()> {
191   // Check only if they have a totp_secret in the DB
192   if let Some(totp_secret) = totp_secret {
193     // Throw an error if their token is missing
194     let token = totp_token
195       .as_deref()
196       .ok_or_else(|| LemmyError::from_message("missing_totp_token"))?;
197
198     let totp = build_totp_2fa(site_name, username, totp_secret)?;
199
200     let check_passed = totp.check_current(token)?;
201     if !check_passed {
202       return Err(LemmyError::from_message("incorrect_totp token"));
203     }
204   }
205
206   Ok(())
207 }
208
209 pub fn generate_totp_2fa_secret() -> String {
210   Secret::generate_secret().to_string()
211 }
212
213 pub fn build_totp_2fa(site_name: &str, username: &str, secret: &str) -> Result<TOTP, LemmyError> {
214   let sec = Secret::Raw(secret.as_bytes().to_vec());
215   let sec_bytes = sec
216     .to_bytes()
217     .map_err(|_| LemmyError::from_message("Couldnt parse totp secret"))?;
218
219   TOTP::new(
220     totp_rs::Algorithm::SHA256,
221     6,
222     1,
223     30,
224     sec_bytes,
225     Some(site_name.to_string()),
226     username.to_string(),
227   )
228   .map_err(|e| LemmyError::from_error_message(e, "Couldnt generate TOTP"))
229 }
230
231 pub fn check_site_visibility_valid(
232   current_private_instance: bool,
233   current_federation_enabled: bool,
234   new_private_instance: &Option<bool>,
235   new_federation_enabled: &Option<bool>,
236 ) -> LemmyResult<()> {
237   let private_instance = new_private_instance.unwrap_or(current_private_instance);
238   let federation_enabled = new_federation_enabled.unwrap_or(current_federation_enabled);
239
240   if private_instance && federation_enabled {
241     return Err(LemmyError::from_message(
242       "cant_enable_private_instance_and_federation_together",
243     ));
244   }
245
246   Ok(())
247 }
248
249 #[cfg(test)]
250 mod tests {
251   use super::build_totp_2fa;
252   use crate::utils::validation::{
253     build_and_check_regex,
254     check_site_visibility_valid,
255     clean_url_params,
256     generate_totp_2fa_secret,
257     is_valid_actor_name,
258     is_valid_bio_field,
259     is_valid_display_name,
260     is_valid_matrix_id,
261     is_valid_post_title,
262     site_description_length_check,
263     site_name_length_check,
264     BIO_MAX_LENGTH,
265     SITE_DESCRIPTION_MAX_LENGTH,
266     SITE_NAME_MAX_LENGTH,
267   };
268   use url::Url;
269
270   #[test]
271   fn test_clean_url_params() {
272     let url = Url::parse("https://example.com/path/123?utm_content=buffercf3b2&utm_medium=social&username=randomuser&id=123").unwrap();
273     let cleaned = clean_url_params(&url);
274     let expected = Url::parse("https://example.com/path/123?username=randomuser&id=123").unwrap();
275     assert_eq!(expected.to_string(), cleaned.to_string());
276
277     let url = Url::parse("https://example.com/path/123").unwrap();
278     let cleaned = clean_url_params(&url);
279     assert_eq!(url.to_string(), cleaned.to_string());
280   }
281
282   #[test]
283   fn regex_checks() {
284     assert!(is_valid_post_title("hi").is_err());
285     assert!(is_valid_post_title("him").is_ok());
286     assert!(is_valid_post_title("n\n\n\n\nanother").is_err());
287     assert!(is_valid_post_title("hello there!\n this is a test.").is_err());
288     assert!(is_valid_post_title("hello there! this is a test.").is_ok());
289   }
290
291   #[test]
292   fn test_valid_actor_name() {
293     let actor_name_max_length = 20;
294     assert!(is_valid_actor_name("Hello_98", actor_name_max_length).is_ok());
295     assert!(is_valid_actor_name("ten", actor_name_max_length).is_ok());
296     assert!(is_valid_actor_name("Hello-98", actor_name_max_length).is_err());
297     assert!(is_valid_actor_name("a", actor_name_max_length).is_err());
298     assert!(is_valid_actor_name("", actor_name_max_length).is_err());
299   }
300
301   #[test]
302   fn test_valid_display_name() {
303     let actor_name_max_length = 20;
304     assert!(is_valid_display_name("hello @there", actor_name_max_length).is_ok());
305     assert!(is_valid_display_name("@hello there", actor_name_max_length).is_err());
306
307     // Make sure zero-space with an @ doesn't work
308     assert!(
309       is_valid_display_name(&format!("{}@my name is", '\u{200b}'), actor_name_max_length).is_err()
310     );
311   }
312
313   #[test]
314   fn test_valid_post_title() {
315     assert!(is_valid_post_title("Post Title").is_ok());
316     assert!(is_valid_post_title("   POST TITLE ðŸ˜ƒðŸ˜ƒðŸ˜ƒðŸ˜ƒðŸ˜ƒ").is_ok());
317     assert!(is_valid_post_title("\n \n \n \n                    ").is_err()); // tabs/spaces/newlines
318   }
319
320   #[test]
321   fn test_valid_matrix_id() {
322     assert!(is_valid_matrix_id("@dess:matrix.org").is_ok());
323     assert!(is_valid_matrix_id("dess:matrix.org").is_err());
324     assert!(is_valid_matrix_id(" @dess:matrix.org").is_err());
325     assert!(is_valid_matrix_id("@dess:matrix.org t").is_err());
326   }
327
328   #[test]
329   fn test_build_totp() {
330     let generated_secret = generate_totp_2fa_secret();
331     let totp = build_totp_2fa("lemmy", "my_name", &generated_secret);
332     assert!(totp.is_ok());
333   }
334
335   #[test]
336   fn test_valid_site_name() {
337     let valid_names = [
338       (0..SITE_NAME_MAX_LENGTH).map(|_| 'A').collect::<String>(),
339       String::from("A"),
340     ];
341     let invalid_names = [
342       (
343         &(0..SITE_NAME_MAX_LENGTH + 1)
344           .map(|_| 'A')
345           .collect::<String>(),
346         "site_name_length_overflow",
347       ),
348       (&String::new(), "site_name_required"),
349     ];
350
351     valid_names.iter().for_each(|valid_name| {
352       assert!(
353         site_name_length_check(valid_name).is_ok(),
354         "Expected {} of length {} to be Ok.",
355         valid_name,
356         valid_name.len()
357       )
358     });
359
360     invalid_names
361       .iter()
362       .for_each(|&(invalid_name, expected_err)| {
363         let result = site_name_length_check(invalid_name);
364
365         assert!(result.is_err());
366         assert!(
367           result
368             .unwrap_err()
369             .message
370             .eq(&Some(String::from(expected_err))),
371           "Testing {}, expected error {}",
372           invalid_name,
373           expected_err
374         );
375       });
376   }
377
378   #[test]
379   fn test_valid_bio() {
380     assert!(is_valid_bio_field(&(0..BIO_MAX_LENGTH).map(|_| 'A').collect::<String>()).is_ok());
381
382     let invalid_result =
383       is_valid_bio_field(&(0..BIO_MAX_LENGTH + 1).map(|_| 'A').collect::<String>());
384
385     assert!(
386       invalid_result.is_err()
387         && invalid_result
388           .unwrap_err()
389           .message
390           .eq(&Some(String::from("bio_length_overflow")))
391     );
392   }
393
394   #[test]
395   fn test_valid_site_description() {
396     assert!(site_description_length_check(
397       &(0..SITE_DESCRIPTION_MAX_LENGTH)
398         .map(|_| 'A')
399         .collect::<String>()
400     )
401     .is_ok());
402
403     let invalid_result = site_description_length_check(
404       &(0..SITE_DESCRIPTION_MAX_LENGTH + 1)
405         .map(|_| 'A')
406         .collect::<String>(),
407     );
408
409     assert!(
410       invalid_result.is_err()
411         && invalid_result
412           .unwrap_err()
413           .message
414           .eq(&Some(String::from("site_description_length_overflow")))
415     );
416   }
417
418   #[test]
419   fn test_valid_slur_regex() {
420     let valid_regexes = [&None, &Some(""), &Some("(foo|bar)")];
421
422     valid_regexes.iter().for_each(|regex| {
423       let result = build_and_check_regex(regex);
424
425       assert!(result.is_ok(), "Testing regex: {:?}", regex);
426     });
427   }
428
429   #[test]
430   fn test_too_permissive_slur_regex() {
431     let match_everything_regexes = [
432       (&Some("["), "invalid_regex"),
433       (&Some("(foo|bar|)"), "permissive_regex"),
434       (&Some(".*"), "permissive_regex"),
435     ];
436
437     match_everything_regexes
438       .iter()
439       .for_each(|&(regex_str, expected_err)| {
440         let result = build_and_check_regex(regex_str);
441
442         assert!(result.is_err());
443         assert!(
444           result
445             .unwrap_err()
446             .message
447             .eq(&Some(String::from(expected_err))),
448           "Testing regex {:?}, expected error {}",
449           regex_str,
450           expected_err
451         );
452       });
453   }
454
455   #[test]
456   fn test_check_site_visibility_valid() {
457     assert!(check_site_visibility_valid(true, true, &None, &None).is_err());
458     assert!(check_site_visibility_valid(true, false, &None, &Some(true)).is_err());
459     assert!(check_site_visibility_valid(false, true, &Some(true), &None).is_err());
460     assert!(check_site_visibility_valid(false, false, &Some(true), &Some(true)).is_err());
461     assert!(check_site_visibility_valid(true, false, &None, &None).is_ok());
462     assert!(check_site_visibility_valid(false, true, &None, &None).is_ok());
463     assert!(check_site_visibility_valid(false, false, &Some(true), &None).is_ok());
464     assert!(check_site_visibility_valid(false, false, &None, &Some(true)).is_ok());
465   }
466 }