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