🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: validate emails and class names

+449 -21
+108 -21
src/index.ts
··· 87 87 type TranscriptionUpdate, 88 88 WhisperServiceManager, 89 89 } from "./lib/transcription"; 90 + import { 91 + validateClassId, 92 + validateCourseCode, 93 + validateCourseName, 94 + validateEmail, 95 + validateName, 96 + validatePasswordHash, 97 + validateSemester, 98 + validateYear, 99 + } from "./lib/validation"; 90 100 import adminHTML from "./pages/admin.html"; 91 101 import checkoutHTML from "./pages/checkout.html"; 92 102 import classHTML from "./pages/class.html"; ··· 318 328 { status: 400 }, 319 329 ); 320 330 } 321 - // Password is client-side hashed (PBKDF2), should be 64 char hex 322 - if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 331 + // Validate password format (client-side hashed PBKDF2) 332 + const passwordValidation = validatePasswordHash(password); 333 + if (!passwordValidation.valid) { 323 334 return Response.json( 324 - { error: "Invalid password format" }, 335 + { error: passwordValidation.error }, 325 336 { status: 400 }, 326 337 ); 327 338 } ··· 414 425 }); 415 426 if (rateLimitError) return rateLimitError; 416 427 417 - // Password is client-side hashed (PBKDF2), should be 64 char hex 418 - if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 428 + // Validate password format (client-side hashed PBKDF2) 429 + const passwordValidation = validatePasswordHash(password); 430 + if (!passwordValidation.valid) { 419 431 return Response.json( 420 - { error: "Invalid password format" }, 432 + { error: passwordValidation.error }, 421 433 { status: 400 }, 422 434 ); 423 435 } ··· 787 799 } 788 800 789 801 // Validate password format (client-side hashed PBKDF2) 790 - if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 802 + const passwordValidation = validatePasswordHash(password); 803 + if (!passwordValidation.valid) { 791 804 return Response.json( 792 - { error: "Invalid password format" }, 805 + { error: passwordValidation.error }, 793 806 { status: 400 }, 794 807 ); 795 808 } ··· 1229 1242 if (!password) { 1230 1243 return Response.json({ error: "Password required" }, { status: 400 }); 1231 1244 } 1232 - // Password is client-side hashed (PBKDF2), should be 64 char hex 1233 - if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 1245 + // Validate password format (client-side hashed PBKDF2) 1246 + const passwordValidation = validatePasswordHash(password); 1247 + if (!passwordValidation.valid) { 1234 1248 return Response.json( 1235 - { error: "Invalid password format" }, 1249 + { error: passwordValidation.error }, 1236 1250 { status: 400 }, 1237 1251 ); 1238 1252 } ··· 2433 2447 const body = await req.json(); 2434 2448 const { name } = body as { name: string }; 2435 2449 2436 - if (!name || name.trim().length === 0) { 2450 + const nameValidation = validateName(name); 2451 + if (!nameValidation.valid) { 2437 2452 return Response.json( 2438 - { error: "Name cannot be empty" }, 2453 + { error: nameValidation.error }, 2439 2454 { status: 400 }, 2440 2455 ); 2441 2456 } ··· 2462 2477 skipVerification?: boolean; 2463 2478 }; 2464 2479 2465 - if (!email || !email.includes("@")) { 2480 + const emailValidation = validateEmail(email); 2481 + if (!emailValidation.valid) { 2466 2482 return Response.json( 2467 - { error: "Invalid email address" }, 2483 + { error: emailValidation.error }, 2468 2484 { status: 400 }, 2469 2485 ); 2470 2486 } ··· 2660 2676 meeting_times, 2661 2677 } = body; 2662 2678 2663 - if (!course_code || !name || !professor || !semester || !year) { 2679 + // Validate all required fields 2680 + const courseCodeValidation = validateCourseCode(course_code); 2681 + if (!courseCodeValidation.valid) { 2682 + return Response.json( 2683 + { error: courseCodeValidation.error }, 2684 + { status: 400 }, 2685 + ); 2686 + } 2687 + 2688 + const nameValidation = validateCourseName(name); 2689 + if (!nameValidation.valid) { 2690 + return Response.json( 2691 + { error: nameValidation.error }, 2692 + { status: 400 }, 2693 + ); 2694 + } 2695 + 2696 + const professorValidation = validateName(professor, "Professor name"); 2697 + if (!professorValidation.valid) { 2664 2698 return Response.json( 2665 - { error: "Missing required fields" }, 2699 + { error: professorValidation.error }, 2700 + { status: 400 }, 2701 + ); 2702 + } 2703 + 2704 + const semesterValidation = validateSemester(semester); 2705 + if (!semesterValidation.valid) { 2706 + return Response.json( 2707 + { error: semesterValidation.error }, 2708 + { status: 400 }, 2709 + ); 2710 + } 2711 + 2712 + const yearValidation = validateYear(year); 2713 + if (!yearValidation.valid) { 2714 + return Response.json( 2715 + { error: yearValidation.error }, 2666 2716 { status: 400 }, 2667 2717 ); 2668 2718 } ··· 2722 2772 const body = await req.json(); 2723 2773 const classId = body.class_id; 2724 2774 2725 - if (!classId || typeof classId !== "string") { 2775 + const classIdValidation = validateClassId(classId); 2776 + if (!classIdValidation.valid) { 2726 2777 return Response.json( 2727 - { error: "Class ID required" }, 2778 + { error: classIdValidation.error }, 2728 2779 { status: 400 }, 2729 2780 ); 2730 2781 } ··· 2757 2808 meetingTimes, 2758 2809 } = body; 2759 2810 2760 - if (!courseCode || !courseName || !professor || !semester || !year) { 2811 + // Validate all required fields 2812 + const courseCodeValidation = validateCourseCode(courseCode); 2813 + if (!courseCodeValidation.valid) { 2761 2814 return Response.json( 2762 - { error: "Missing required fields" }, 2815 + { error: courseCodeValidation.error }, 2816 + { status: 400 }, 2817 + ); 2818 + } 2819 + 2820 + const nameValidation = validateCourseName(courseName); 2821 + if (!nameValidation.valid) { 2822 + return Response.json( 2823 + { error: nameValidation.error }, 2824 + { status: 400 }, 2825 + ); 2826 + } 2827 + 2828 + const professorValidation = validateName(professor, "Professor name"); 2829 + if (!professorValidation.valid) { 2830 + return Response.json( 2831 + { error: professorValidation.error }, 2832 + { status: 400 }, 2833 + ); 2834 + } 2835 + 2836 + const semesterValidation = validateSemester(semester); 2837 + if (!semesterValidation.valid) { 2838 + return Response.json( 2839 + { error: semesterValidation.error }, 2840 + { status: 400 }, 2841 + ); 2842 + } 2843 + 2844 + const yearValidation = validateYear( 2845 + typeof year === "string" ? Number.parseInt(year, 10) : year, 2846 + ); 2847 + if (!yearValidation.valid) { 2848 + return Response.json( 2849 + { error: yearValidation.error }, 2763 2850 { status: 400 }, 2764 2851 ); 2765 2852 }
+118
src/lib/validation.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + import { 3 + validateClassId, 4 + validateCourseCode, 5 + validateCourseName, 6 + validateEmail, 7 + validateName, 8 + validatePasswordHash, 9 + validateSemester, 10 + validateYear, 11 + } from "./validation"; 12 + 13 + test("validateEmail accepts valid emails", () => { 14 + expect(validateEmail("test@example.com").valid).toBe(true); 15 + expect(validateEmail("user.name+tag@example.co.uk").valid).toBe(true); 16 + expect(validateEmail("test@subdomain.example.com").valid).toBe(true); 17 + }); 18 + 19 + test("validateEmail rejects invalid emails", () => { 20 + expect(validateEmail("").valid).toBe(false); 21 + expect(validateEmail("not-an-email").valid).toBe(false); 22 + expect(validateEmail("@example.com").valid).toBe(false); 23 + expect(validateEmail("test@").valid).toBe(false); 24 + expect(validateEmail("a".repeat(321)).valid).toBe(false); // Too long 25 + expect(validateEmail(123).valid).toBe(false); // Not a string 26 + }); 27 + 28 + test("validateName accepts valid names", () => { 29 + expect(validateName("John Doe").valid).toBe(true); 30 + expect(validateName("Alice").valid).toBe(true); 31 + expect(validateName("María García").valid).toBe(true); 32 + }); 33 + 34 + test("validateName rejects invalid names", () => { 35 + expect(validateName("").valid).toBe(false); 36 + expect(validateName(" ").valid).toBe(false); // Whitespace only 37 + expect(validateName("a".repeat(256)).valid).toBe(false); // Too long 38 + expect(validateName(123).valid).toBe(false); // Not a string 39 + }); 40 + 41 + test("validatePasswordHash accepts valid PBKDF2 hashes", () => { 42 + const validHash = "a".repeat(64); // 64 char hex string 43 + expect(validatePasswordHash(validHash).valid).toBe(true); 44 + expect(validatePasswordHash("0123456789abcdef".repeat(4)).valid).toBe(true); 45 + }); 46 + 47 + test("validatePasswordHash rejects invalid hashes", () => { 48 + expect(validatePasswordHash("short").valid).toBe(false); 49 + expect(validatePasswordHash("a".repeat(63)).valid).toBe(false); // Too short 50 + expect(validatePasswordHash("a".repeat(65)).valid).toBe(false); // Too long 51 + expect(validatePasswordHash("g".repeat(64)).valid).toBe(false); // Invalid hex 52 + expect(validatePasswordHash(123).valid).toBe(false); // Not a string 53 + }); 54 + 55 + test("validateCourseCode accepts valid course codes", () => { 56 + expect(validateCourseCode("CS101").valid).toBe(true); 57 + expect(validateCourseCode("MATH 2410").valid).toBe(true); 58 + expect(validateCourseCode("BIO-101").valid).toBe(true); 59 + }); 60 + 61 + test("validateCourseCode rejects invalid course codes", () => { 62 + expect(validateCourseCode("").valid).toBe(false); 63 + expect(validateCourseCode(" ").valid).toBe(false); 64 + expect(validateCourseCode("a".repeat(51)).valid).toBe(false); // Too long 65 + expect(validateCourseCode(123).valid).toBe(false); // Not a string 66 + }); 67 + 68 + test("validateCourseName accepts valid course names", () => { 69 + expect(validateCourseName("Introduction to Computer Science").valid).toBe( 70 + true, 71 + ); 72 + expect(validateCourseName("Calculus I").valid).toBe(true); 73 + }); 74 + 75 + test("validateCourseName rejects invalid course names", () => { 76 + expect(validateCourseName("").valid).toBe(false); 77 + expect(validateCourseName(" ").valid).toBe(false); 78 + expect(validateCourseName("a".repeat(501)).valid).toBe(false); // Too long 79 + expect(validateCourseName(123).valid).toBe(false); // Not a string 80 + }); 81 + 82 + test("validateSemester accepts valid semesters", () => { 83 + expect(validateSemester("Fall").valid).toBe(true); 84 + expect(validateSemester("spring").valid).toBe(true); // Case insensitive 85 + expect(validateSemester("SUMMER").valid).toBe(true); 86 + expect(validateSemester("Winter").valid).toBe(true); 87 + }); 88 + 89 + test("validateSemester rejects invalid semesters", () => { 90 + expect(validateSemester("").valid).toBe(false); 91 + expect(validateSemester("Invalid").valid).toBe(false); 92 + expect(validateSemester("Autumn").valid).toBe(false); 93 + expect(validateSemester(123).valid).toBe(false); // Not a string 94 + }); 95 + 96 + test("validateYear accepts valid years", () => { 97 + const currentYear = new Date().getFullYear(); 98 + expect(validateYear(currentYear).valid).toBe(true); 99 + expect(validateYear(2024).valid).toBe(true); 100 + expect(validateYear(currentYear + 1).valid).toBe(true); 101 + }); 102 + 103 + test("validateYear rejects invalid years", () => { 104 + expect(validateYear(1999).valid).toBe(false); // Too old 105 + expect(validateYear(2050).valid).toBe(false); // Too far in future 106 + expect(validateYear("2024").valid).toBe(false); // Not a number 107 + }); 108 + 109 + test("validateClassId accepts valid class IDs", () => { 110 + expect(validateClassId("abc123").valid).toBe(true); 111 + expect(validateClassId("class-2024-fall").valid).toBe(true); 112 + }); 113 + 114 + test("validateClassId rejects invalid class IDs", () => { 115 + expect(validateClassId("").valid).toBe(false); 116 + expect(validateClassId("a".repeat(101)).valid).toBe(false); // Too long 117 + expect(validateClassId(123).valid).toBe(false); // Not a string 118 + });
+223
src/lib/validation.ts
··· 1 + /** 2 + * Input validation utilities 3 + */ 4 + 5 + // RFC 5322 compliant email regex (simplified but comprehensive) 6 + const EMAIL_REGEX = 7 + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; 8 + 9 + // Validation limits 10 + export const VALIDATION_LIMITS = { 11 + EMAIL_MAX: 320, // RFC 5321 12 + NAME_MAX: 255, 13 + PASSWORD_HASH_LENGTH: 64, // PBKDF2 hex output 14 + COURSE_CODE_MAX: 50, 15 + COURSE_NAME_MAX: 500, 16 + PROFESSOR_NAME_MAX: 255, 17 + SEMESTER_MAX: 50, 18 + CLASS_ID_MAX: 100, 19 + }; 20 + 21 + export interface ValidationResult { 22 + valid: boolean; 23 + error?: string; 24 + } 25 + 26 + /** 27 + * Validate email address 28 + */ 29 + export function validateEmail(email: unknown): ValidationResult { 30 + if (typeof email !== "string") { 31 + return { valid: false, error: "Email must be a string" }; 32 + } 33 + 34 + const trimmed = email.trim(); 35 + 36 + if (trimmed.length === 0) { 37 + return { valid: false, error: "Email is required" }; 38 + } 39 + 40 + if (trimmed.length > VALIDATION_LIMITS.EMAIL_MAX) { 41 + return { 42 + valid: false, 43 + error: `Email must be less than ${VALIDATION_LIMITS.EMAIL_MAX} characters`, 44 + }; 45 + } 46 + 47 + if (!EMAIL_REGEX.test(trimmed)) { 48 + return { valid: false, error: "Invalid email format" }; 49 + } 50 + 51 + return { valid: true }; 52 + } 53 + 54 + /** 55 + * Validate name (user name, professor name, etc.) 56 + */ 57 + export function validateName( 58 + name: unknown, 59 + fieldName = "Name", 60 + ): ValidationResult { 61 + if (typeof name !== "string") { 62 + return { valid: false, error: `${fieldName} must be a string` }; 63 + } 64 + 65 + const trimmed = name.trim(); 66 + 67 + if (trimmed.length === 0) { 68 + return { valid: false, error: `${fieldName} is required` }; 69 + } 70 + 71 + if (trimmed.length > VALIDATION_LIMITS.NAME_MAX) { 72 + return { 73 + valid: false, 74 + error: `${fieldName} must be less than ${VALIDATION_LIMITS.NAME_MAX} characters`, 75 + }; 76 + } 77 + 78 + return { valid: true }; 79 + } 80 + 81 + /** 82 + * Validate password hash format (client-side PBKDF2) 83 + */ 84 + export function validatePasswordHash(password: unknown): ValidationResult { 85 + if (typeof password !== "string") { 86 + return { valid: false, error: "Password must be a string" }; 87 + } 88 + 89 + // Client sends PBKDF2 as hex string 90 + if ( 91 + password.length !== VALIDATION_LIMITS.PASSWORD_HASH_LENGTH || 92 + !/^[0-9a-f]+$/.test(password) 93 + ) { 94 + return { valid: false, error: "Invalid password format" }; 95 + } 96 + 97 + return { valid: true }; 98 + } 99 + 100 + /** 101 + * Validate course code 102 + */ 103 + export function validateCourseCode(courseCode: unknown): ValidationResult { 104 + if (typeof courseCode !== "string") { 105 + return { valid: false, error: "Course code must be a string" }; 106 + } 107 + 108 + const trimmed = courseCode.trim(); 109 + 110 + if (trimmed.length === 0) { 111 + return { valid: false, error: "Course code is required" }; 112 + } 113 + 114 + if (trimmed.length > VALIDATION_LIMITS.COURSE_CODE_MAX) { 115 + return { 116 + valid: false, 117 + error: `Course code must be less than ${VALIDATION_LIMITS.COURSE_CODE_MAX} characters`, 118 + }; 119 + } 120 + 121 + return { valid: true }; 122 + } 123 + 124 + /** 125 + * Validate course/class name 126 + */ 127 + export function validateCourseName(courseName: unknown): ValidationResult { 128 + if (typeof courseName !== "string") { 129 + return { valid: false, error: "Course name must be a string" }; 130 + } 131 + 132 + const trimmed = courseName.trim(); 133 + 134 + if (trimmed.length === 0) { 135 + return { valid: false, error: "Course name is required" }; 136 + } 137 + 138 + if (trimmed.length > VALIDATION_LIMITS.COURSE_NAME_MAX) { 139 + return { 140 + valid: false, 141 + error: `Course name must be less than ${VALIDATION_LIMITS.COURSE_NAME_MAX} characters`, 142 + }; 143 + } 144 + 145 + return { valid: true }; 146 + } 147 + 148 + /** 149 + * Validate semester 150 + */ 151 + export function validateSemester(semester: unknown): ValidationResult { 152 + if (typeof semester !== "string") { 153 + return { valid: false, error: "Semester must be a string" }; 154 + } 155 + 156 + const trimmed = semester.trim(); 157 + 158 + if (trimmed.length === 0) { 159 + return { valid: false, error: "Semester is required" }; 160 + } 161 + 162 + if (trimmed.length > VALIDATION_LIMITS.SEMESTER_MAX) { 163 + return { 164 + valid: false, 165 + error: `Semester must be less than ${VALIDATION_LIMITS.SEMESTER_MAX} characters`, 166 + }; 167 + } 168 + 169 + // Optional: validate it's a known semester value 170 + const validSemesters = ["fall", "spring", "summer", "winter"]; 171 + if (!validSemesters.includes(trimmed.toLowerCase())) { 172 + return { 173 + valid: false, 174 + error: "Semester must be Fall, Spring, Summer, or Winter", 175 + }; 176 + } 177 + 178 + return { valid: true }; 179 + } 180 + 181 + /** 182 + * Validate year 183 + */ 184 + export function validateYear(year: unknown): ValidationResult { 185 + if (typeof year !== "number") { 186 + return { valid: false, error: "Year must be a number" }; 187 + } 188 + 189 + const currentYear = new Date().getFullYear(); 190 + const minYear = 2000; 191 + const maxYear = currentYear + 5; 192 + 193 + if (year < minYear || year > maxYear) { 194 + return { 195 + valid: false, 196 + error: `Year must be between ${minYear} and ${maxYear}`, 197 + }; 198 + } 199 + 200 + return { valid: true }; 201 + } 202 + 203 + /** 204 + * Validate class ID format 205 + */ 206 + export function validateClassId(classId: unknown): ValidationResult { 207 + if (typeof classId !== "string") { 208 + return { valid: false, error: "Class ID must be a string" }; 209 + } 210 + 211 + if (classId.length === 0) { 212 + return { valid: false, error: "Class ID is required" }; 213 + } 214 + 215 + if (classId.length > VALIDATION_LIMITS.CLASS_ID_MAX) { 216 + return { 217 + valid: false, 218 + error: `Class ID must be less than ${VALIDATION_LIMITS.CLASS_ID_MAX} characters`, 219 + }; 220 + } 221 + 222 + return { valid: true }; 223 + }