Skip to main content

max / makenotwork

21.3 KB · 582 lines History Blame Raw
1 //! Input validation utilities
2 //!
3 //! Provides validation functions for user input with consistent error messages.
4
5 mod items;
6 mod payments;
7 mod projects;
8 mod users;
9
10 pub use items::*;
11 pub use payments::*;
12 pub use projects::*;
13 pub use users::*;
14
15 use crate::error::AppError;
16
17 /// Reject control characters in a single-line field.
18 ///
19 /// Single-line titles/labels (item, chapter, blog-post, collection, section,
20 /// project, issue, link) must not contain newlines, carriage returns, tabs, or
21 /// other control characters. Besides being malformed, a `\r`/`\n` in a value
22 /// that reaches an email subject makes Postmark reject the send (the JSON API
23 /// refuses CRLF in headers). Multi-line fields (bio, description, body) use
24 /// their own looser validators that permit `\n`/`\r`/`\t`.
25 pub fn reject_control_chars(field: &str, value: &str) -> Result<(), AppError> {
26 if value.chars().any(char::is_control) {
27 return Err(AppError::validation(format!(
28 "{field} cannot contain line breaks or control characters"
29 )));
30 }
31 Ok(())
32 }
33
34 /// Reject control characters in a multi-line field while permitting normal
35 /// whitespace (`\n`, `\r`, `\t`). Descriptions, bodies, and changelogs are
36 /// legitimately multi-line but should never carry NUL/escape/etc., those
37 /// corrupt logs and tooling and can smuggle bytes into downstream contexts.
38 /// Mirrors `validate_bio`.
39 pub fn reject_control_chars_multiline(field: &str, value: &str) -> Result<(), AppError> {
40 if value
41 .chars()
42 .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
43 {
44 return Err(AppError::validation(format!(
45 "{field} cannot contain control characters"
46 )));
47 }
48 Ok(())
49 }
50
51 /// Maximum lengths for various fields
52 pub mod limits {
53 pub const DISPLAY_NAME_MAX: usize = 100;
54 pub const BIO_MAX: usize = 2000;
55 pub const LINK_URL_MAX: usize = 500;
56 pub const LINK_TITLE_MAX: usize = 100;
57 pub const ITEM_TITLE_MAX: usize = 200;
58 pub const ITEM_DESCRIPTION_MAX: usize = 5000;
59 pub const TAG_MAX: usize = 50;
60 pub const PROJECT_TITLE_MAX: usize = 200;
61 pub const PROJECT_DESCRIPTION_MAX: usize = 2000;
62 pub const PROJECT_SLUG_MAX: usize = 100;
63 pub const VERSION_NUMBER_MAX: usize = 50;
64 pub const CHANGELOG_MAX: usize = 10000;
65 pub const WAITLIST_PITCH_MIN: usize = 20;
66 pub const WAITLIST_PITCH_MAX: usize = 500;
67 pub const BLOG_POST_TITLE_MAX: usize = 200;
68 pub const BLOG_POST_SLUG_MAX: usize = 100;
69 pub const BLOG_POST_BODY_MAX: usize = 100_000;
70 pub const CHAPTER_TITLE_MAX: usize = 200;
71 pub const ITEM_TEXT_BODY_MAX: usize = 500_000;
72 pub const KEY_CODE_MAX: usize = 50;
73 pub const MACHINE_ID_MAX: usize = 255;
74 pub const ACTIVATION_LABEL_MAX: usize = 100;
75 // Subscriptions
76 pub const TIER_NAME_MAX: usize = 100;
77 pub const TIER_DESCRIPTION_MAX: usize = 2000;
78 // SyncKit
79 pub const SYNC_APP_NAME_MAX: usize = 100;
80 pub const SYNC_DEVICE_NAME_MAX: usize = 100;
81 pub const SYNC_TABLE_NAME_MAX: usize = 100;
82 pub const SYNC_ROW_ID_MAX: usize = 255;
83 pub const SYNC_BLOB_HASH_LEN: usize = 64; // SHA-256 hex
84 pub const SYNC_KEY_MAX: usize = 255;
85 // SSH keys
86 pub const SSH_KEY_LABEL_MAX: usize = 128;
87 // Git issues
88 pub const ISSUE_TITLE_MAX: usize = 200;
89 pub const ISSUE_BODY_MAX: usize = 50_000;
90 pub const ISSUE_COMMENT_BODY_MAX: usize = 50_000;
91 pub const ISSUE_LABEL_NAME_MAX: usize = 50;
92 // Git repo settings
93 pub const REPO_DESCRIPTION_MAX: usize = 500;
94 // Git notes. A note is prose about one commit, so the cap is generous
95 // rather than tight; the reason to have one at all is that every note in a
96 // namespace is read into memory whenever the tree is flattened.
97 pub const NOTE_CONTENT_MAX: usize = 50_000;
98 pub const NOTE_NAMESPACE_MAX: usize = 64;
99 // Collections
100 pub const COLLECTION_TITLE_MAX: usize = 100;
101 pub const COLLECTION_DESCRIPTION_MAX: usize = 500;
102 // Item sections
103 pub const SECTION_TITLE_MAX: usize = 100;
104 pub const SECTION_BODY_MAX: usize = 100_000;
105 // Passwords. Measured in Unicode scalar values (`chars().count()`), NOT
106 // bytes. Every password path, signup, reset, profile change, login, OAuth
107 // login, and SyncKit auth, MUST cap by this same metric and value; a
108 // byte-based cap on the login side rejects (permanently locks out) any
109 // account whose password is <=128 chars but >128 bytes, which multibyte
110 // passphrases routinely are. Route the upper-bound check through
111 // `super::password_too_long`.
112 pub const PASSWORD_MIN: usize = 8;
113 pub const PASSWORD_MAX: usize = 128;
114 }
115
116 /// True when a password exceeds the maximum length.
117 ///
118 /// Measured in Unicode scalar values (`chars().count()`), never bytes, see
119 /// [`limits::PASSWORD_MAX`]. This is the single upper-bound check shared by the
120 /// set-side paths (signup / reset / profile) and the credential-verification
121 /// paths (login / OAuth / SyncKit) so the metric can never drift back apart.
122 pub fn password_too_long(password: &str) -> bool {
123 password.chars().count() > limits::PASSWORD_MAX
124 }
125
126 /// Validate a slug: 2-100 chars, alphanumeric + hyphens.
127 ///
128 /// Shared rule for project slugs, blog post slugs, and tag slugs.
129 /// Also used by the `Slug` newtype's `Deserialize` impl.
130 pub fn validate_slug(slug: &str) -> Result<(), AppError> {
131 let len = slug.chars().count();
132 if len < 2 {
133 return Err(AppError::validation(
134 "URL name must be at least 2 characters".to_string(),
135 ));
136 }
137 if len > limits::PROJECT_SLUG_MAX {
138 return Err(AppError::validation(format!(
139 "URL name must be {} characters or less",
140 limits::PROJECT_SLUG_MAX
141 )));
142 }
143 if !slug
144 .chars()
145 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
146 {
147 return Err(AppError::validation(
148 "URL name can only contain lowercase letters, numbers, and hyphens".to_string(),
149 ));
150 }
151 if !slug.chars().any(|c| c.is_ascii_alphanumeric()) {
152 return Err(AppError::validation(
153 "URL name must contain at least one letter or number".to_string(),
154 ));
155 }
156 Ok(())
157 }
158
159 // ── SyncKit validation ──
160
161 pub fn validate_sync_app_name(name: &str) -> Result<(), AppError> {
162 if name.is_empty() {
163 return Err(AppError::validation("App name is required".to_string()));
164 }
165 if name.chars().count() > limits::SYNC_APP_NAME_MAX {
166 return Err(AppError::validation(format!(
167 "App name must be {} characters or less",
168 limits::SYNC_APP_NAME_MAX
169 )));
170 }
171 // Reject control characters (NUL, newlines, bidi/RTL overrides) so the one
172 // user-controlled string with no other content rules can't carry a payload
173 // into any future non-escaping sink. Mirrors `validate_synckit_key`.
174 if name.chars().any(char::is_control) {
175 return Err(AppError::validation(
176 "App name must not contain control characters".to_string(),
177 ));
178 }
179 Ok(())
180 }
181
182 pub fn validate_sync_device_name(name: &str) -> Result<(), AppError> {
183 if name.is_empty() {
184 return Err(AppError::validation("Device name is required".to_string()));
185 }
186 if name.chars().count() > limits::SYNC_DEVICE_NAME_MAX {
187 return Err(AppError::validation(format!(
188 "Device name must be {} characters or less",
189 limits::SYNC_DEVICE_NAME_MAX
190 )));
191 }
192 // Reject control characters, mirroring `validate_sync_app_name`.
193 if name.chars().any(char::is_control) {
194 return Err(AppError::validation(
195 "Device name must not contain control characters".to_string(),
196 ));
197 }
198 Ok(())
199 }
200
201 /// Validate a sync group name. Same shape as an app/device name: non-empty,
202 /// bounded by the `VARCHAR(100)` column, no control characters.
203 pub fn validate_sync_group_name(name: &str) -> Result<(), AppError> {
204 if name.is_empty() {
205 return Err(AppError::validation("Group name is required".to_string()));
206 }
207 if name.chars().count() > limits::SYNC_APP_NAME_MAX {
208 return Err(AppError::validation(format!(
209 "Group name must be {} characters or less",
210 limits::SYNC_APP_NAME_MAX
211 )));
212 }
213 if name.chars().any(char::is_control) {
214 return Err(AppError::validation(
215 "Group name must not contain control characters".to_string(),
216 ));
217 }
218 Ok(())
219 }
220
221 pub fn validate_sync_table_name(name: &str) -> Result<(), AppError> {
222 if name.is_empty() {
223 return Err(AppError::validation("Table name is required".to_string()));
224 }
225 if name.chars().count() > limits::SYNC_TABLE_NAME_MAX {
226 return Err(AppError::validation(format!(
227 "Table name must be {} characters or less",
228 limits::SYNC_TABLE_NAME_MAX
229 )));
230 }
231 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
232 return Err(AppError::validation(
233 "Table name can only contain letters, numbers, and underscores".to_string(),
234 ));
235 }
236 Ok(())
237 }
238
239 pub fn validate_sync_row_id(row_id: &str) -> Result<(), AppError> {
240 if row_id.is_empty() {
241 return Err(AppError::validation("Row ID is required".to_string()));
242 }
243 if row_id.chars().count() > limits::SYNC_ROW_ID_MAX {
244 return Err(AppError::validation(format!(
245 "Row ID must be {} characters or less",
246 limits::SYNC_ROW_ID_MAX
247 )));
248 }
249 // Reject null bytes and control characters, these can cause issues in
250 // DB queries, file paths, and log output downstream.
251 if row_id.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) {
252 return Err(AppError::validation(
253 "Row ID contains invalid characters".to_string(),
254 ));
255 }
256 Ok(())
257 }
258
259 /// Validate a sync blob hash (must be exactly 64 lowercase hex characters)
260 pub fn validate_sync_blob_hash(hash: &str) -> Result<(), AppError> {
261 if hash.len() != limits::SYNC_BLOB_HASH_LEN {
262 return Err(AppError::validation(format!(
263 "Blob hash must be exactly {} hex characters",
264 limits::SYNC_BLOB_HASH_LEN
265 )));
266 }
267 if !hash
268 .chars()
269 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
270 {
271 return Err(AppError::validation(
272 "Blob hash must be lowercase hexadecimal".to_string(),
273 ));
274 }
275 Ok(())
276 }
277
278 /// Validate a base64-encoded SHA-256 digest, the form S3 wants for
279 /// `x-amz-checksum-sha256`.
280 ///
281 /// Bound into a presigned URL, so a malformed value would produce a URL that
282 /// fails at S3 with an opaque error long after the client committed to it.
283 /// Reject it here, where the message can say what was wrong.
284 pub fn validate_sha256_base64(value: &str) -> Result<(), AppError> {
285 use base64::Engine;
286 let decoded = base64::engine::general_purpose::STANDARD
287 .decode(value)
288 .map_err(|_| AppError::validation("Checksum must be base64".to_string()))?;
289 if decoded.len() != 32 {
290 return Err(AppError::validation(format!(
291 "Checksum must decode to 32 bytes (SHA-256), got {}",
292 decoded.len()
293 )));
294 }
295 Ok(())
296 }
297
298 /// Validate a developer-defined SDK key. Opaque string identifying which
299 /// workspace/org/end-user a JWT session belongs to. Rejects empty, oversize,
300 /// null bytes, and control characters, same character rules as `validate_sync_row_id`
301 /// since downstream goes through SQL and log lines.
302 pub fn validate_synckit_key(key: &str) -> Result<(), AppError> {
303 if key.is_empty() {
304 return Err(AppError::validation("SDK key is required".to_string()));
305 }
306 if key.len() > limits::SYNC_KEY_MAX {
307 return Err(AppError::validation(format!(
308 "SDK key must be {} bytes or less",
309 limits::SYNC_KEY_MAX
310 )));
311 }
312 if key.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) {
313 return Err(AppError::validation(
314 "SDK key contains invalid characters".to_string(),
315 ));
316 }
317 Ok(())
318 }
319
320 #[cfg(test)]
321 mod tests {
322 use super::*;
323
324 #[test]
325 fn test_validate_sync_app_name() {
326 assert!(validate_sync_app_name("GoingsOn").is_ok());
327 assert!(validate_sync_app_name("").is_err()); // empty
328 assert!(validate_sync_app_name(&"a".repeat(100)).is_ok()); // at limit
329 assert!(validate_sync_app_name(&"a".repeat(101)).is_err()); // over limit
330 assert!(validate_sync_app_name("bad\nname").is_err()); // newline (control)
331 assert!(validate_sync_app_name("bad\0name").is_err()); // NUL
332 assert!(validate_sync_app_name("ok name 123").is_ok()); // spaces/digits fine
333 }
334
335 #[test]
336 fn test_validate_sync_device_name() {
337 assert!(validate_sync_device_name("Max's MacBook").is_ok());
338 assert!(validate_sync_device_name("").is_err());
339 assert!(validate_sync_device_name(&"a".repeat(101)).is_err());
340 // Control characters (NUL, escape) are rejected.
341 assert!(validate_sync_device_name("laptop\u{0}").is_err());
342 assert!(validate_sync_device_name("laptop\u{1b}evil").is_err());
343 }
344
345 #[test]
346 fn test_validate_sync_table_name() {
347 assert!(validate_sync_table_name("tasks").is_ok());
348 assert!(validate_sync_table_name("calendar_events").is_ok());
349 assert!(validate_sync_table_name("").is_err());
350 assert!(validate_sync_table_name("bad-name").is_err()); // hyphens
351 assert!(validate_sync_table_name("bad name").is_err()); // spaces
352 assert!(validate_sync_table_name(&"a".repeat(101)).is_err());
353 }
354
355 #[test]
356 fn test_validate_sync_row_id() {
357 assert!(validate_sync_row_id("uuid-123").is_ok());
358 assert!(validate_sync_row_id("").is_err());
359 assert!(validate_sync_row_id(&"a".repeat(255)).is_ok());
360 assert!(validate_sync_row_id(&"a".repeat(256)).is_err());
361 }
362
363 #[test]
364 fn test_password_too_long_measures_chars_not_bytes() {
365 // Regression (audit Run 22): the cap is Unicode scalar count, not bytes.
366 // A 128-char password is accepted even when its UTF-8 encoding exceeds
367 // 128 bytes, otherwise multibyte passphrases that pass signup would be
368 // rejected at login (permanent self-lockout).
369 assert!(!password_too_long(&"a".repeat(limits::PASSWORD_MAX)));
370 assert!(password_too_long(&"a".repeat(limits::PASSWORD_MAX + 1)));
371
372 // 128 CJK chars = 128 chars (at the cap) but 384 bytes, must NOT be
373 // flagged too long.
374 let cjk = "\u{4f60}".repeat(limits::PASSWORD_MAX);
375 assert_eq!(cjk.chars().count(), limits::PASSWORD_MAX);
376 assert!(
377 cjk.len() > limits::PASSWORD_MAX,
378 "test string must exceed the byte cap"
379 );
380 assert!(!password_too_long(&cjk));
381
382 // One char over the cap is rejected regardless of byte width.
383 assert!(password_too_long(
384 &"\u{4f60}".repeat(limits::PASSWORD_MAX + 1)
385 ));
386 }
387
388 // ── Edge cases (test-fuzz) ──
389
390 #[test]
391 fn test_validate_slug_only_hyphens() {
392 // A slug with NO alphanumeric chars is rejected by the
393 // "must contain at least one letter or number" rule (added later).
394 assert!(validate_slug("--").is_err());
395 }
396
397 #[test]
398 fn test_validate_slug_leading_trailing_hyphens() {
399 assert!(validate_slug("-ab-").is_ok()); // hyphens at boundaries
400 }
401
402 #[test]
403 fn test_validate_slug_with_unicode() {
404 // Non-ASCII is rejected by is_ascii_alphanumeric
405 assert!(validate_slug("caf\u{00e9}").is_err());
406 }
407
408 #[test]
409 fn test_validate_sync_blob_hash_uppercase() {
410 let hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
411 assert!(validate_sync_blob_hash(hash).is_err()); // must be lowercase
412 }
413
414 #[test]
415 fn test_validate_sync_blob_hash_mixed_case() {
416 let hash = "aAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaA";
417 assert!(validate_sync_blob_hash(hash).is_err());
418 }
419
420 #[test]
421 fn test_validate_sync_blob_hash_valid() {
422 let hash = "a".repeat(64);
423 assert!(validate_sync_blob_hash(&hash).is_ok());
424 }
425
426 #[test]
427 fn test_validate_sync_blob_hash_wrong_length() {
428 assert!(validate_sync_blob_hash(&"a".repeat(63)).is_err());
429 assert!(validate_sync_blob_hash(&"a".repeat(65)).is_err());
430 }
431
432 #[test]
433 fn test_validate_sync_table_name_with_unicode() {
434 assert!(validate_sync_table_name("\u{00e9}vents").is_err());
435 }
436
437 // ── Adversarial tests (test-fuzz) ──
438
439 #[test]
440 fn test_validate_slug_null_bytes() {
441 assert!(validate_slug("ab\0cd").is_err());
442 }
443
444 #[test]
445 fn test_validate_slug_zero_width_chars() {
446 // Zero-width space (U+200B) should be rejected
447 assert!(validate_slug("ab\u{200B}cd").is_err());
448 // Zero-width joiner
449 assert!(validate_slug("ab\u{200D}cd").is_err());
450 }
451
452 #[test]
453 fn test_validate_slug_rtl_override() {
454 // Right-to-left override (U+202E) should be rejected
455 assert!(validate_slug("ab\u{202E}cd").is_err());
456 }
457
458 #[test]
459 fn test_validate_slug_at_exact_max() {
460 assert!(validate_slug(&"a".repeat(100)).is_ok());
461 assert!(validate_slug(&"a".repeat(101)).is_err());
462 }
463
464 #[test]
465 fn test_validate_slug_single_char() {
466 assert!(validate_slug("a").is_err()); // min is 2
467 }
468
469 #[test]
470 fn test_validate_slug_empty() {
471 assert!(validate_slug("").is_err());
472 }
473
474 #[test]
475 fn test_validate_sync_blob_hash_non_hex() {
476 // 64 chars but contains 'g' which is not hex
477 let hash = format!("{}g", "a".repeat(63));
478 assert!(validate_sync_blob_hash(&hash).is_err());
479 }
480
481 #[test]
482 fn test_validate_sync_blob_hash_empty() {
483 assert!(validate_sync_blob_hash("").is_err());
484 }
485
486 #[test]
487 fn test_validate_sync_table_name_sql_injection() {
488 assert!(validate_sync_table_name("users; DROP TABLE users").is_err());
489 assert!(validate_sync_table_name("users'--").is_err());
490 }
491
492 #[test]
493 fn test_validate_sync_row_id_null_bytes() {
494 // Null bytes and control characters are rejected
495 assert!(validate_sync_row_id("a\0b").is_err());
496 assert!(validate_sync_row_id("a\x01b").is_err());
497 // Tabs are allowed (some ID schemes use them)
498 assert!(validate_sync_row_id("a\tb").is_ok());
499 // Normal IDs pass
500 assert!(validate_sync_row_id("row-123-abc").is_ok());
501 }
502
503 // ── SDK key validation (test-fuzz) ──
504
505 #[test]
506 fn test_validate_synckit_key_basic() {
507 assert!(validate_synckit_key("user-42").is_ok());
508 assert!(validate_synckit_key("workspace/team-1").is_ok());
509 assert!(validate_synckit_key("a").is_ok()); // single char is fine
510 assert!(validate_synckit_key("").is_err());
511 }
512
513 #[test]
514 fn test_validate_synckit_key_length_boundaries() {
515 let at_max = "k".repeat(limits::SYNC_KEY_MAX);
516 let over_max = "k".repeat(limits::SYNC_KEY_MAX + 1);
517 assert!(validate_synckit_key(&at_max).is_ok(), "exact max must pass");
518 assert!(
519 validate_synckit_key(&over_max).is_err(),
520 "over max must fail"
521 );
522 }
523
524 #[test]
525 fn test_validate_synckit_key_null_and_controls() {
526 assert!(validate_synckit_key("a\0b").is_err());
527 assert!(validate_synckit_key("a\x01b").is_err());
528 assert!(validate_synckit_key("a\x1Fb").is_err()); // unit separator
529 // Tabs are permitted, mirroring validate_sync_row_id.
530 assert!(validate_synckit_key("a\tb").is_ok());
531 }
532
533 #[test]
534 fn test_validate_synckit_key_unicode_allowed() {
535 // SDK keys are opaque, non-ASCII is fine as long as it's not a control char.
536 assert!(validate_synckit_key("café").is_ok());
537 assert!(validate_synckit_key("ユーザー1").is_ok());
538 }
539
540 #[test]
541 fn test_validate_synckit_key_oversize_uses_byte_length() {
542 // SYNC_KEY_MAX is in BYTES (key.len()), not chars. Multibyte chars eat
543 // more budget. A 100-char emoji string easily exceeds 255 bytes.
544 let many_emoji = "🦀".repeat(100); // 4 bytes per emoji → 400 bytes
545 assert!(validate_synckit_key(&many_emoji).is_err());
546 }
547
548 // ── Property-based tests (test-fuzz) ──
549
550 proptest::proptest! {
551 #[test]
552 fn prop_slug_valid_inputs_never_panic(s in "[a-z0-9\\-]{0,200}") {
553 let _ = validate_slug(&s);
554 }
555
556 #[test]
557 fn prop_slug_valid_always_accepted(s in "[a-z0-9\\-]{2,100}") {
558 // The regex doesn't ensure at least one alphanumeric char; the validator
559 // (correctly) rejects hyphen-only strings, so filter to inputs that meet
560 // both rules.
561 if s.chars().any(|c| c.is_ascii_alphanumeric()) {
562 proptest::prop_assert!(validate_slug(&s).is_ok(), "Valid slug rejected: {:?}", s);
563 }
564 }
565
566 #[test]
567 fn prop_slug_rejects_non_ascii(s in "[a-z]{2,10}\u{00e9}[a-z]{2,10}") {
568 proptest::prop_assert!(validate_slug(&s).is_err(), "Non-ASCII slug accepted: {:?}", s);
569 }
570
571 #[test]
572 fn prop_sync_table_name_valid_always_accepted(s in "[a-zA-Z_][a-zA-Z0-9_]{0,99}") {
573 proptest::prop_assert!(validate_sync_table_name(&s).is_ok(), "Valid table name rejected: {:?}", s);
574 }
575
576 #[test]
577 fn prop_blob_hash_valid_always_accepted(s in "[0-9a-f]{64}") {
578 proptest::prop_assert!(validate_sync_blob_hash(&s).is_ok(), "Valid hash rejected: {:?}", s);
579 }
580 }
581 }
582