]> Untitled Git - lemmy.git/blob - crates/utils/src/utils/validation.rs
b42fe1adda3e945f38c71648715f25e061710b4f
[lemmy.git] / crates / utils / src / utils / validation.rs
1 use crate::error::{LemmyError, LemmyErrorExt, LemmyErrorType, 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(LemmyErrorType::InvalidName.into())
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(LemmyErrorType::InvalidDisplayName.into())
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(LemmyErrorType::InvalidMatrixId.into())
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(LemmyErrorType::InvalidPostTitle.into())
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(LemmyErrorType::InvalidBodyField.into())
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, LemmyErrorType::BioLengthOverflow)
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     LemmyErrorType::SiteNameRequired,
161     LemmyErrorType::SiteNameLengthOverflow,
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     LemmyErrorType::SiteDescriptionLengthOverflow,
171   )
172 }
173
174 fn max_length_check(item: &str, max_length: usize, error_type: LemmyErrorType) -> LemmyResult<()> {
175   if item.len() > max_length {
176     Err(error_type.into())
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: LemmyErrorType,
187   max_msg: LemmyErrorType,
188 ) -> LemmyResult<()> {
189   if item.len() > max_length {
190     Err(max_msg.into())
191   } else if item.len() < min_length {
192     Err(min_msg.into())
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         .with_lemmy_type(LemmyErrorType::InvalidRegex)
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(LemmyErrorType::PermissiveRegex.into());
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(LemmyErrorType::MissingTotpToken)?;
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(LemmyErrorType::IncorrectTotpToken.into());
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(|_| LemmyErrorType::CouldntParseTotpSecret)?;
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   .with_lemmy_type(LemmyErrorType::CouldntGenerateTotp)
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(LemmyErrorType::CantEnablePrivateInstanceAndFederationTogether.into());
298   }
299
300   Ok(())
301 }
302
303 pub fn check_url_scheme(url: &Option<Url>) -> LemmyResult<()> {
304   if let Some(url) = url {
305     if url.scheme() != "http" && url.scheme() != "https" {
306       return Err(LemmyErrorType::InvalidUrlScheme.into());
307     }
308   }
309   Ok(())
310 }
311
312 #[cfg(test)]
313 mod tests {
314   use super::build_totp_2fa;
315   use crate::{
316     error::LemmyErrorType,
317     utils::validation::{
318       build_and_check_regex,
319       check_site_visibility_valid,
320       check_url_scheme,
321       clean_url_params,
322       generate_totp_2fa_secret,
323       is_valid_actor_name,
324       is_valid_bio_field,
325       is_valid_display_name,
326       is_valid_matrix_id,
327       is_valid_post_title,
328       site_description_length_check,
329       site_name_length_check,
330       BIO_MAX_LENGTH,
331       SITE_DESCRIPTION_MAX_LENGTH,
332       SITE_NAME_MAX_LENGTH,
333     },
334   };
335   use url::Url;
336
337   #[test]
338   fn test_clean_url_params() {
339     let url = Url::parse("https://example.com/path/123?utm_content=buffercf3b2&utm_medium=social&username=randomuser&id=123").unwrap();
340     let cleaned = clean_url_params(&url);
341     let expected = Url::parse("https://example.com/path/123?username=randomuser&id=123").unwrap();
342     assert_eq!(expected.to_string(), cleaned.to_string());
343
344     let url = Url::parse("https://example.com/path/123").unwrap();
345     let cleaned = clean_url_params(&url);
346     assert_eq!(url.to_string(), cleaned.to_string());
347   }
348
349   #[test]
350   fn regex_checks() {
351     assert!(is_valid_post_title("hi").is_err());
352     assert!(is_valid_post_title("him").is_ok());
353     assert!(is_valid_post_title("n\n\n\n\nanother").is_err());
354     assert!(is_valid_post_title("hello there!\n this is a test.").is_err());
355     assert!(is_valid_post_title("hello there! this is a test.").is_ok());
356   }
357
358   #[test]
359   fn test_valid_actor_name() {
360     let actor_name_max_length = 20;
361     assert!(is_valid_actor_name("Hello_98", actor_name_max_length).is_ok());
362     assert!(is_valid_actor_name("ten", actor_name_max_length).is_ok());
363     assert!(is_valid_actor_name("Hello-98", actor_name_max_length).is_err());
364     assert!(is_valid_actor_name("a", actor_name_max_length).is_err());
365     assert!(is_valid_actor_name("", actor_name_max_length).is_err());
366   }
367
368   #[test]
369   fn test_valid_display_name() {
370     let actor_name_max_length = 20;
371     assert!(is_valid_display_name("hello @there", actor_name_max_length).is_ok());
372     assert!(is_valid_display_name("@hello there", actor_name_max_length).is_err());
373
374     // Make sure zero-space with an @ doesn't work
375     assert!(
376       is_valid_display_name(&format!("{}@my name is", '\u{200b}'), actor_name_max_length).is_err()
377     );
378   }
379
380   #[test]
381   fn test_valid_post_title() {
382     assert!(is_valid_post_title("Post Title").is_ok());
383     assert!(is_valid_post_title("   POST TITLE ðŸ˜ƒðŸ˜ƒðŸ˜ƒðŸ˜ƒðŸ˜ƒ").is_ok());
384     assert!(is_valid_post_title("\n \n \n \n                    ").is_err()); // tabs/spaces/newlines
385   }
386
387   #[test]
388   fn test_valid_matrix_id() {
389     assert!(is_valid_matrix_id("@dess:matrix.org").is_ok());
390     assert!(is_valid_matrix_id("dess:matrix.org").is_err());
391     assert!(is_valid_matrix_id(" @dess:matrix.org").is_err());
392     assert!(is_valid_matrix_id("@dess:matrix.org t").is_err());
393   }
394
395   #[test]
396   fn test_build_totp() {
397     let generated_secret = generate_totp_2fa_secret();
398     let totp = build_totp_2fa("lemmy", "my_name", &generated_secret);
399     assert!(totp.is_ok());
400   }
401
402   #[test]
403   fn test_valid_site_name() {
404     let valid_names = [
405       (0..SITE_NAME_MAX_LENGTH).map(|_| 'A').collect::<String>(),
406       String::from("A"),
407     ];
408     let invalid_names = [
409       (
410         &(0..SITE_NAME_MAX_LENGTH + 1)
411           .map(|_| 'A')
412           .collect::<String>(),
413         LemmyErrorType::SiteNameLengthOverflow,
414       ),
415       (&String::new(), LemmyErrorType::SiteNameRequired),
416     ];
417
418     valid_names.iter().for_each(|valid_name| {
419       assert!(
420         site_name_length_check(valid_name).is_ok(),
421         "Expected {} of length {} to be Ok.",
422         valid_name,
423         valid_name.len()
424       )
425     });
426
427     invalid_names
428       .iter()
429       .for_each(|(invalid_name, expected_err)| {
430         let result = site_name_length_check(invalid_name);
431
432         assert!(result.is_err());
433         assert!(
434           result.unwrap_err().error_type.eq(&expected_err.clone()),
435           "Testing {}, expected error {}",
436           invalid_name,
437           expected_err
438         );
439       });
440   }
441
442   #[test]
443   fn test_valid_bio() {
444     assert!(is_valid_bio_field(&(0..BIO_MAX_LENGTH).map(|_| 'A').collect::<String>()).is_ok());
445
446     let invalid_result =
447       is_valid_bio_field(&(0..BIO_MAX_LENGTH + 1).map(|_| 'A').collect::<String>());
448
449     assert!(
450       invalid_result.is_err()
451         && invalid_result
452           .unwrap_err()
453           .error_type
454           .eq(&LemmyErrorType::BioLengthOverflow)
455     );
456   }
457
458   #[test]
459   fn test_valid_site_description() {
460     assert!(site_description_length_check(
461       &(0..SITE_DESCRIPTION_MAX_LENGTH)
462         .map(|_| 'A')
463         .collect::<String>()
464     )
465     .is_ok());
466
467     let invalid_result = site_description_length_check(
468       &(0..SITE_DESCRIPTION_MAX_LENGTH + 1)
469         .map(|_| 'A')
470         .collect::<String>(),
471     );
472
473     assert!(
474       invalid_result.is_err()
475         && invalid_result
476           .unwrap_err()
477           .error_type
478           .eq(&LemmyErrorType::SiteDescriptionLengthOverflow)
479     );
480   }
481
482   #[test]
483   fn test_valid_slur_regex() {
484     let valid_regexes = [&None, &Some(""), &Some("(foo|bar)")];
485
486     valid_regexes.iter().for_each(|regex| {
487       let result = build_and_check_regex(regex);
488
489       assert!(result.is_ok(), "Testing regex: {:?}", regex);
490     });
491   }
492
493   #[test]
494   fn test_too_permissive_slur_regex() {
495     let match_everything_regexes = [
496       (&Some("["), LemmyErrorType::InvalidRegex),
497       (&Some("(foo|bar|)"), LemmyErrorType::PermissiveRegex),
498       (&Some(".*"), LemmyErrorType::PermissiveRegex),
499     ];
500
501     match_everything_regexes
502       .iter()
503       .for_each(|(regex_str, expected_err)| {
504         let result = build_and_check_regex(regex_str);
505
506         assert!(result.is_err());
507         assert!(
508           result.unwrap_err().error_type.eq(&expected_err.clone()),
509           "Testing regex {:?}, expected error {}",
510           regex_str,
511           expected_err
512         );
513       });
514   }
515
516   #[test]
517   fn test_check_site_visibility_valid() {
518     assert!(check_site_visibility_valid(true, true, &None, &None).is_err());
519     assert!(check_site_visibility_valid(true, false, &None, &Some(true)).is_err());
520     assert!(check_site_visibility_valid(false, true, &Some(true), &None).is_err());
521     assert!(check_site_visibility_valid(false, false, &Some(true), &Some(true)).is_err());
522     assert!(check_site_visibility_valid(true, false, &None, &None).is_ok());
523     assert!(check_site_visibility_valid(false, true, &None, &None).is_ok());
524     assert!(check_site_visibility_valid(false, false, &Some(true), &None).is_ok());
525     assert!(check_site_visibility_valid(false, false, &None, &Some(true)).is_ok());
526   }
527
528   #[test]
529   fn test_check_url_scheme() {
530     assert!(check_url_scheme(&None).is_ok());
531     assert!(check_url_scheme(&Some(Url::parse("http://example.com").unwrap())).is_ok());
532     assert!(check_url_scheme(&Some(Url::parse("https://example.com").unwrap())).is_ok());
533     assert!(check_url_scheme(&Some(Url::parse("ftp://example.com").unwrap())).is_err());
534     assert!(check_url_scheme(&Some(Url::parse("javascript:void").unwrap())).is_err());
535   }
536 }