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