Skip to main content

max / makenotwork

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