Data Validation Guard Rails
Comprehensive input validation and sanitization system implemented across the Archery Apprentice codebase to prevent data quality issues, security vulnerabilities, and performance degradation.
Overview
The validation system was implemented in 4 phases (PRs 387-391) to address 23 critical vulnerabilities identified during a security assessment. The implementation provides:
- Centralized constants for all validation limits
- Regex patterns for format validation
- Validation rules with consistent error messages
- Input sanitization for XSS prevention
- UI layer constraints for immediate feedback
Architecture
┌─────────────────────────────────────────────────────────────┐
│ UI Layer │
│ maxLength constraints, character counters, visual feedback │
└─────────────────────────────────┬───────────────────────────┘
│
┌─────────────────────────────────▼───────────────────────────┐
│ Presenter/ViewModel Layer │
│ ValidationRules, InputSanitizer, error messages │
└─────────────────────────────────┬───────────────────────────┘
│
┌─────────────────────────────────▼───────────────────────────┐
│ Domain Layer │
│ ValidationConstants, ValidationPatterns, rules │
└─────────────────────────────────────────────────────────────┘
Core Components
ValidationConstants.kt
Location: shared/domain/src/commonMain/kotlin/com/archeryapprentice/domain/validation/ValidationConstants.kt
Defines all validation limits used throughout the app:
object ValidationConstants {
// Text Length Limits
const val TOURNAMENT_NAME_MIN = 3
const val TOURNAMENT_NAME_MAX = 100
const val ROUND_NAME_MIN = 3
const val ROUND_NAME_MAX = 100
const val GUEST_NAME_MIN = 1
const val GUEST_NAME_MAX = 30
const val DISPLAY_NAME_MIN = 2
const val DISPLAY_NAME_MAX = 50
const val DESCRIPTION_MAX = 1000
const val NOTES_MAX = 1000
const val WEATHER_CONDITIONS_MAX = 200
// Numeric Range Limits
const val MAX_PARTICIPANTS_MIN = 2
const val MAX_PARTICIPANTS_MAX = 50
const val NUM_ENDS_MIN = 1
const val NUM_ENDS_MAX = 30
const val ARROWS_PER_END_MIN = 1
const val ARROWS_PER_END_MAX = 12
// Authentication
const val EMAIL_MAX = 100
const val PASSWORD_MIN = 8
const val PASSWORD_MAX = 128
// Format Patterns
const val JOIN_CODE_LENGTH = 6
}Validation Limits Reference
| Field | Min | Max | Required |
|---|---|---|---|
| Tournament Name | 3 | 100 | Yes |
| Round Name | 3 | 100 | Yes |
| Guest Name | 1 | 30 | Yes |
| Display Name | 2 | 50 | No |
| Description | 0 | 1000 | No |
| Notes | 0 | 1000 | No |
| Weather Conditions | 0 | 200 | No |
| Rules | 0 | 5000 | No |
| Prizes | 0 | 2000 | No |
| Location | 0 | 200 | No |
| Equipment Brand | 2 | 50 | Yes |
| Equipment Model | 1 | 50 | Yes |
| Equipment Notes | 0 | 500 | No |
| 5 | 100 | Yes | |
| Password | 8 | 128 | Yes |
Numeric Limits Reference
| Field | Min | Max | Rationale |
|---|---|---|---|
| Max Participants | 2 | 50 | Practical tournament size |
| Number of Ends | 1 | 30 | Standard archery round limits |
| Arrows per End | 1 | 12 | Competition standard |
| Max Guests | - | 9 | Reasonable guest limit |
| Arrow Score (WA) | 0 | 10 | World Archery scoring |
| Arrow Score (NFAA) | 0 | 5 | NFAA indoor scoring |
ValidationPatterns.kt
Location: shared/domain/src/commonMain/kotlin/com/archeryapprentice/domain/validation/ValidationPatterns.kt
Regex patterns for format validation:
object ValidationPatterns {
val EMAIL_REGEX = "^[a-zA-Z0-9_%+-]+(?:\\.[a-zA-Z0-9_%+-]+)*@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*\\.[a-zA-Z]{2,}$".toRegex()
val JOIN_CODE_REGEX = "^[A-Z0-9]{6}$".toRegex()
val GUEST_ID_REGEX = "^guest_[a-z0-9]{8,}$".toRegex()
val SAFE_TEXT_REGEX = "^[a-zA-Z0-9\\s.,!?:;\\-_()'\"]+$".toRegex()
}ValidationRules.kt
Location: shared/domain/src/commonMain/kotlin/com/archeryapprentice/domain/validation/ValidationRules.kt
Validation functions with consistent error messages:
object ValidationRules {
fun validateTournamentName(name: String): ValidationResult {
return when {
name.isBlank() -> ValidationResult.Invalid("Tournament name is required")
name.length < TOURNAMENT_NAME_MIN ->
ValidationResult.Invalid("Tournament name must be at least $TOURNAMENT_NAME_MIN characters")
name.length > TOURNAMENT_NAME_MAX ->
ValidationResult.Invalid("Tournament name must be $TOURNAMENT_NAME_MAX characters or fewer")
else -> ValidationResult.Valid
}
}
fun validateEmail(email: String): ValidationResult {
return when {
email.isBlank() -> ValidationResult.Invalid("Email is required")
email.length > EMAIL_MAX -> ValidationResult.Invalid("Email must be $EMAIL_MAX characters or fewer")
!ValidationPatterns.EMAIL_REGEX.matches(email) ->
ValidationResult.Invalid("Please enter a valid email address")
else -> ValidationResult.Valid
}
}
fun validatePassword(password: String): ValidationResult {
return when {
password.isBlank() -> ValidationResult.Invalid("Password is required")
password.length < PASSWORD_MIN ->
ValidationResult.Invalid("Password must be at least $PASSWORD_MIN characters")
password.length > PASSWORD_MAX ->
ValidationResult.Invalid("Password must be $PASSWORD_MAX characters or fewer")
else -> ValidationResult.Valid
}
}
// ... additional validation functions for all field types
}
sealed class ValidationResult {
object Valid : ValidationResult()
data class Invalid(val error: String) : ValidationResult()
}Available Validation Functions
| Function | Description |
|---|---|
validateTournamentName() | Name length and blank check |
validateRoundName() | Round name validation |
validateGuestName() | Guest participant name |
validateDisplayName() | User display name |
validateDescription() | Optional description field |
validateNotes() | Optional notes field |
validateEmail() | Email format and length |
validatePassword() | Password length requirements |
validatePasswordMatch() | Confirm password matching |
validatePasswordStrength() | Letter + number requirement |
validateJoinCode() | 6-character alphanumeric |
validateNumericRange() | Generic range validation |
validateMaxParticipants() | Participant count limits |
validateNumEnds() | End count limits |
validateArrowsPerEnd() | Arrow count limits |
validateArrowNumber() | Arrow within end bounds |
validateEndNumber() | End within round bounds |
validateDateRange() | Start/end date ordering |
validateFutureDate() | Date not in past |
validateParticipantCount() | Against max participants |
validateGuestCount() | Against max guests |
InputSanitizer.kt
Location: shared/domain/src/commonMain/kotlin/com/archeryapprentice/domain/validation/InputSanitizer.kt
Input sanitization utilities for XSS prevention and data cleanup:
object InputSanitizer {
// HTML tag pattern
private val HTML_TAG_REGEX = Regex("<[^>]*>")
// Script tag pattern (handles multi-line)
private val SCRIPT_TAG_REGEX = Regex(
"<script[^>]*>.*?</script>",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
/**
* Full sanitization for single-line user text input.
* Use for: Tournament names, round names, guest names, equipment brand/model.
*/
fun sanitizeTextInput(input: String): String {
return input
.let { stripScriptTags(it) }
.let { stripHtmlTags(it) }
.let { stripControlCharacters(it) }
.let { normalizeWhitespace(it) }
}
/**
* Sanitization for multi-line text fields.
* Use for: Descriptions, notes, weather conditions, tournament rules.
*/
fun sanitizeMultilineInput(input: String): String {
return input
.let { stripScriptTags(it) }
.let { stripHtmlTags(it) }
.let { stripControlCharacters(it) }
.lines()
.map { line -> line.replace(Regex("[ \\t]+"), " ").trim() }
.joinToString("\n")
.replace(Regex("\n{3,}"), "\n\n")
.trim()
}
/**
* Escapes HTML special characters for safe display in HTML context.
*/
fun escapeHtml(input: String): String {
return input
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
}
/**
* Trims and limits string to max length.
* Final safety check before database storage.
*/
fun trimToMaxLength(input: String, maxLength: Int): String {
val trimmed = input.trim()
return if (trimmed.length > maxLength) trimmed.take(maxLength) else trimmed
}
}Sanitizer Function Reference
| Function | Use Case | Preserves Newlines |
|---|---|---|
sanitizeTextInput() | Single-line fields (names, titles) | No |
sanitizeMultilineInput() | Multi-line fields (descriptions, notes) | Yes |
escapeHtml() | Displaying in web views | Yes |
trimToMaxLength() | Final safety check before DB storage | Yes |
Usage Examples
Android/Kotlin Integration
// In ViewModel or Presenter
fun onTournamentNameChange(name: String) {
val sanitized = InputSanitizer.sanitizeTextInput(name)
val validation = ValidationRules.validateTournamentName(sanitized)
when (validation) {
is ValidationResult.Valid -> {
_uiState.update { it.copy(name = sanitized, nameError = null) }
}
is ValidationResult.Invalid -> {
_uiState.update { it.copy(name = sanitized, nameError = validation.error) }
}
}
}
fun onDescriptionChange(description: String) {
val sanitized = InputSanitizer.sanitizeMultilineInput(description)
_uiState.update { it.copy(description = sanitized) }
}iOS/Swift Integration
// In SwiftUI ViewModel
func updateTournamentName(_ name: String) {
let sanitized = InputSanitizer.shared.sanitizeTextInput(input: name)
self.name = sanitized
}
func updateDescription(_ description: String) {
let sanitized = InputSanitizer.shared.sanitizeMultilineInput(input: description)
self.description = sanitized
}
// In SwiftUI View with onChange
TextField("Tournament Name", text: $viewModel.name)
.onChange(of: viewModel.name) { newValue in
viewModel.name = InputSanitizer.shared.sanitizeTextInput(input: newValue)
}UI Layer maxLength (Android)
OutlinedTextField(
value = name,
onValueChange = { if (it.length <= ValidationConstants.TOURNAMENT_NAME_MAX) onNameChange(it) },
label = { Text("Tournament Name") },
isError = nameError != null,
supportingText = nameError?.let { { Text(it) } }
)Security Considerations
Vulnerabilities Addressed
| Vulnerability | Risk Level | Mitigation |
|---|---|---|
| Unlimited text fields | HIGH | Length limits in ValidationConstants |
| Unbounded numeric inputs | HIGH | Range validation in ValidationRules |
| Missing email format validation | MEDIUM | EMAIL_REGEX pattern |
| XSS via HTML injection | HIGH | stripHtmlTags, stripScriptTags |
| Database bloat | HIGH | maxLength constraints |
| Performance DoS | HIGH | Realistic numeric limits |
Attack Prevention
Database Bloat Attack:
Before: User could create tournament with 10MB description
After: Description limited to 1000 characters
Performance DoS:
Before: User could create round with 999,999 ends × 999,999 arrows
After: Limited to 30 ends × 12 arrows (360 arrows max)
XSS Attack:
Before: "<script>alert('xss')</script>" stored and displayed
After: Script tags stripped, HTML escaped for display
Implementation Phases
Phase 1: Setup & Constants (PR #387)
- Created ValidationConstants.kt
- Created ValidationPatterns.kt
- Added DEV_ENV setup guide
Phase 2: Presenter/ViewModel Layer (PR 388-389)
- Created ValidationRules.kt
- Updated TournamentCreationPresenter
- Updated equipment presenters
- Added validation to ViewModels
Phase 3: UI Layer Constraints (PR #390)
- Added maxLength to text fields
- Added error message display
- Updated numeric input components
Phase 4: Advanced Validation (PR #391)
- Created InputSanitizer.kt
- Added cross-field validation
- Added password strength validation
- Added boundary tests
Testing
Test Coverage
| Component | Tests | Coverage |
|---|---|---|
| ValidationRules | 25+ | 100% |
| ValidationPatterns | 10+ | 100% |
| InputSanitizer | 15+ | 100% |
| ValidationConstants | N/A | Used by above |
Test Examples
@Test
fun `validateTournamentName rejects too short`() {
val result = ValidationRules.validateTournamentName("AB")
assertIs<ValidationResult.Invalid>(result)
assertContains(result.error, "at least 3 characters")
}
@Test
fun `sanitizeTextInput removes script tags`() {
val input = "Hello<script>alert('xss')</script>World"
val result = InputSanitizer.sanitizeTextInput(input)
assertEquals("Hello World", result)
}
@Test
fun `validateEmail rejects invalid format`() {
val result = ValidationRules.validateEmail("notanemail")
assertIs<ValidationResult.Invalid>(result)
}Best Practices
When to Validate
- User Input: Validate immediately on change
- Form Submission: Validate all fields before submit
- API Calls: Validate before sending to server
- Database Storage: Final sanitization before write
Validation Order
// 1. Sanitize first
val sanitized = InputSanitizer.sanitizeTextInput(rawInput)
// 2. Then validate
val validation = ValidationRules.validateTournamentName(sanitized)
// 3. Apply length limit as final safety
val final = InputSanitizer.trimToMaxLength(sanitized, TOURNAMENT_NAME_MAX)Error Message Guidelines
- Be specific: “Tournament name must be at least 3 characters”
- Avoid generic: “Invalid input”
- Include limits: “Must be 100 characters or fewer”
- User-friendly: “Please enter a valid email address”
Related Documentation
- technical-debt - Original vulnerability assessment
- production-readiness-gaps - Security gap analysis
- contributing-guide - Development standards