| 1 | module main |
| 2 | |
| 3 | import crypto.sha256 |
| 4 | |
| 5 | // Regression tests for the input validation that guards git command |
| 6 | // construction in create_file_in_bare_repo (see repo/file_routes.v). These |
| 7 | // inputs used to be interpolated into shell strings; they are now passed to |
| 8 | // git as plain arguments, but we still reject values git could misread as |
| 9 | // flags/refs or that contain control characters. |
| 10 | |
| 11 | fn test_is_valid_repo_file_path_accepts_normal_paths() { |
| 12 | assert is_valid_repo_file_path('README.md') |
| 13 | assert is_valid_repo_file_path('src/main.v') |
| 14 | assert is_valid_repo_file_path('dir/file,with,commas.txt') |
| 15 | assert is_valid_repo_file_path('a/b/c/d.e') |
| 16 | } |
| 17 | |
| 18 | fn test_is_valid_repo_file_path_rejects_dangerous_paths() { |
| 19 | assert !is_valid_repo_file_path('') |
| 20 | assert !is_valid_repo_file_path('/etc/passwd') // absolute |
| 21 | assert !is_valid_repo_file_path('-rf') // looks like a flag |
| 22 | assert !is_valid_repo_file_path('../../etc/passwd') // traversal |
| 23 | assert !is_valid_repo_file_path('a/../../b') // traversal |
| 24 | assert !is_valid_repo_file_path('file\nname') // newline |
| 25 | assert !is_valid_repo_file_path('file\x00name') // NUL |
| 26 | assert !is_valid_repo_file_path('a\tb') // tab / control char |
| 27 | } |
| 28 | |
| 29 | fn test_is_safe_ref_accepts_normal_branches() { |
| 30 | assert is_safe_ref('master') |
| 31 | assert is_safe_ref('feature/new-thing') |
| 32 | assert is_safe_ref('release-1.2.3') |
| 33 | } |
| 34 | |
| 35 | fn test_is_safe_ref_rejects_injection_attempts() { |
| 36 | assert !is_safe_ref('') |
| 37 | assert !is_safe_ref('--upload-pack=touch /tmp/pwned') // leading dash + space |
| 38 | assert !is_safe_ref('master;rm -rf /') // shell metacharacters |
| 39 | assert !is_safe_ref('master$(whoami)') |
| 40 | assert !is_safe_ref('master`id`') |
| 41 | assert !is_safe_ref('a..b') // ref traversal |
| 42 | assert !is_safe_ref('branch with spaces') |
| 43 | } |
| 44 | |
| 45 | // Webhook SSRF guard: the IP classifiers must reject internal destinations and |
| 46 | // allow public ones. (is_safe_webhook_url itself does DNS and isn't unit-tested.) |
| 47 | |
| 48 | fn test_is_blocked_ipv4_blocks_internal_ranges() { |
| 49 | assert is_blocked_ipv4('127.0.0.1') // loopback |
| 50 | assert is_blocked_ipv4('10.1.2.3') // private |
| 51 | assert is_blocked_ipv4('172.16.5.5') // private |
| 52 | assert is_blocked_ipv4('172.31.255.255') // private (edge) |
| 53 | assert is_blocked_ipv4('192.168.0.1') // private |
| 54 | assert is_blocked_ipv4('169.254.169.254') // link-local / cloud metadata |
| 55 | assert is_blocked_ipv4('0.0.0.0') // unspecified |
| 56 | assert is_blocked_ipv4('100.64.0.1') // CGNAT |
| 57 | assert is_blocked_ipv4('224.0.0.1') // multicast |
| 58 | assert is_blocked_ipv4('garbage') // unparseable -> fail closed |
| 59 | } |
| 60 | |
| 61 | fn test_is_blocked_ipv4_allows_public() { |
| 62 | assert !is_blocked_ipv4('8.8.8.8') |
| 63 | assert !is_blocked_ipv4('1.1.1.1') |
| 64 | assert !is_blocked_ipv4('172.32.0.1') // just outside 172.16/12 |
| 65 | assert !is_blocked_ipv4('172.15.0.1') // just outside 172.16/12 |
| 66 | assert !is_blocked_ipv4('93.184.216.34') |
| 67 | } |
| 68 | |
| 69 | fn test_is_blocked_ipv6() { |
| 70 | assert is_blocked_ipv6('::1') // loopback |
| 71 | assert is_blocked_ipv6('::') // unspecified |
| 72 | assert is_blocked_ipv6('fe80::1') // link-local |
| 73 | assert is_blocked_ipv6('fc00::1') // unique-local |
| 74 | assert is_blocked_ipv6('fd12:3456::1') // unique-local |
| 75 | assert is_blocked_ipv6('::ffff:127.0.0.1') // IPv4-mapped loopback |
| 76 | assert !is_blocked_ipv6('2606:4700:4700::1111') // public |
| 77 | assert !is_blocked_ipv6('::ffff:8.8.8.8') // IPv4-mapped public |
| 78 | } |
| 79 | |
| 80 | // Password hashing: new hashes must be bcrypt, and legacy salted-SHA-256 hashes |
| 81 | // must still verify (so existing users aren't locked out before re-login). |
| 82 | |
| 83 | fn test_new_passwords_are_bcrypt() { |
| 84 | h := hash_password_with_salt('s3cret-pw', 'ignored-salt') |
| 85 | assert h.starts_with('$2') // bcrypt hash |
| 86 | assert !password_hash_is_legacy(h) |
| 87 | assert compare_password_with_hash('s3cret-pw', 'ignored-salt', h) |
| 88 | assert !compare_password_with_hash('wrong-pw', 'ignored-salt', h) |
| 89 | } |
| 90 | |
| 91 | fn test_legacy_sha256_hashes_still_verify() { |
| 92 | salt := 'abc123' |
| 93 | // Legacy scheme was sha256('${password}${salt}'). |
| 94 | legacy := sha256.sum('hunter2${salt}'.bytes()).hex() |
| 95 | assert password_hash_is_legacy(legacy) |
| 96 | assert compare_password_with_hash('hunter2', salt, legacy) |
| 97 | assert !compare_password_with_hash('nope', salt, legacy) |
| 98 | } |
| 99 | |