Data Validation Guard Rails
Comprehensive input validation and sanitization system for preventing database bloat, ensuring data quality, and protecting against security vulnerabilities.
Status: ✅ Implemented (PRs 387-391)
Location: shared/domain/src/commonMain/kotlin/com/archeryapprentice/domain/validation/
Overview
The validation system provides a centralized, cross-platform approach to input validation and sanitization across both Android and iOS. It addresses critical vulnerabilities identified in the December 2025 security audit:
| Before | After |
|---|---|
| ~15% validation coverage | 100% coverage |
| 23 critical vulnerabilities | 0 vulnerabilities |
| Unlimited text fields | Enforced limits |
| No input sanitization | XSS/injection protection |
Architecture
Components
shared/domain/validation/
├── ValidationConstants.kt # All limits and constraints
├── ValidationPatterns.kt # Regex patterns (email, join code, etc.)
├── ValidationRules.kt # Validation functions and error messages
├── ValidationResult.kt # Result type (Valid/Invalid)
└── InputSanitizer.kt # XSS prevention, HTML stripping
Data Flow
User Input → UI maxLength → Sanitizer → ValidationRules → Presenter → Database
│ │ │
▼ ▼ ▼
First line Strip HTML Validate
of defense and scripts constraints
ValidationConstants
All limits are defined in a single source of truth:
Text Length Limits
| Field | Min | Max | Use Case |
|---|---|---|---|
TOURNAMENT_NAME | 3 | 100 | Tournament names |
ROUND_NAME | 3 | 100 | Round names |
GUEST_NAME | 1 | 30 | Guest participant names |
DISPLAY_NAME | 2 | 50 | User display names |
DESCRIPTION | - | 1000 | Tournament descriptions |
NOTES | - | 1000 | General notes |
WEATHER_CONDITIONS | - | 200 | Weather notes |
RULES | - | 5000 | Tournament rules |
PRIZES | - | 2000 | Prize descriptions |
LOCATION | - | 200 | Location descriptions |
ORGANIZER_CONTACT | - | 200 | Contact info |
Equipment Limits
| Field | Min | Max |
|---|---|---|
EQUIPMENT_BRAND | 2 | 50 |
EQUIPMENT_MODEL | 1 | 50 |
EQUIPMENT_MATERIAL | - | 50 |
EQUIPMENT_NOTES | - | 500 |
BOW_SETUP_NAME | 2 | 50 |
Authentication
| Field | Min | Max |
|---|---|---|
EMAIL | - | 100 |
PASSWORD | 8 | 128 |
Numeric Ranges
| Field | Min | Max | Rationale |
|---|---|---|---|
MAX_PARTICIPANTS | 2 | 50 | Realistic tournament size |
NUM_ENDS | 1 | 30 | Standard archery rounds |
ARROWS_PER_END | 1 | 12 | Standard configurations |
ARROW_SCORE_WA | 0 | 10 | World Archery scoring |
ARROW_SCORE_NFAA | 0 | 5 | NFAA scoring |
MAX_GUESTS | - | 9 | Per-tournament guest limit |
Format Constants
| Field | Value |
|---|---|
JOIN_CODE_LENGTH | 6 |
GUEST_ID_MIN_LENGTH | 8 |
ValidationPatterns
Regex patterns for format validation:
object ValidationPatterns {
// Email: RFC 5321 compliant
val EMAIL_REGEX = "^[a-zA-Z0-9_%+-]+(?:\\.[a-zA-Z0-9_%+-]+)*@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*\\.[a-zA-Z]{2,}$".toRegex()
// Join Code: 6 uppercase alphanumeric characters
val JOIN_CODE_REGEX = "^[A-Z0-9]{6}$".toRegex()
// Guest ID: "guest_" + 8+ lowercase alphanumeric
val GUEST_ID_REGEX = "^guest_[a-z0-9]{8,}$".toRegex()
// Safe text: Letters, numbers, basic punctuation
val SAFE_TEXT_REGEX = "^[a-zA-Z0-9\\s.,!?:;\\-_()'\"]+$".toRegex()
}ValidationRules
Text Validation
// Required field with min/max length
val result = ValidationRules.validateTournamentName("My Tournament")
// Returns ValidationResult.Valid or ValidationResult.Invalid("error message")
// Optional field with max length only
val result = ValidationRules.validateDescription(longText)Available validators:
validateTournamentName(name: String)validateRoundName(name: String)validateGuestName(name: String)validateDisplayName(name: String)validateDescription(description: String)validateNotes(notes: String)validateWeatherConditions(weather: String)validateLocation(location: String)
Equipment Validation
val result = ValidationRules.validateEquipmentBrand("Hoyt")
val result = ValidationRules.validateEquipmentModel("RX8")
val result = ValidationRules.validateEquipmentMaterial("Carbon")
val result = ValidationRules.validateEquipmentNotes("Setup notes here")Numeric Validation
// Generic range validation
val result = ValidationRules.validateNumericRange(
value = 5,
min = 1,
max = 10,
fieldName = "Arrows per end"
)
// Specific validators
val result = ValidationRules.validateMaxParticipants(25)
val result = ValidationRules.validateNumEnds(12)
val result = ValidationRules.validateArrowsPerEnd(6)
val result = ValidationRules.validateArrowScoreWA(10)Format Validation
// Email
val result = ValidationRules.validateEmail("user@example.com")
// Password (length only)
val result = ValidationRules.validatePassword("securepass")
// Password with strength (letter + number required)
val result = ValidationRules.validatePasswordStrength("secure123")
// Password strict (letter + number + special char)
val result = ValidationRules.validatePasswordStrengthStrict("Secure123!")
// Password match
val result = ValidationRules.validatePasswordMatch(password, confirmPassword)
// Join code
val result = ValidationRules.validateJoinCode("ABC123")
// Guest ID
val result = ValidationRules.validateGuestId("guest_a1b2c3d4")
// Safe text (no special characters)
val result = ValidationRules.validateSafeText(text, "Field name")Cross-Field Validation
// Arrow number within end configuration
val result = ValidationRules.validateArrowNumber(
arrowNumber = 3,
arrowsPerEnd = 6
)
// End number within round configuration
val result = ValidationRules.validateEndNumber(
endNumber = 5,
numEnds = 12
)
// Score valid for scoring system
val result = ValidationRules.validateScoreForScoringSystem(
score = 10,
validScores = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10),
scoringSystemName = "World Archery"
)
// Date range
val result = ValidationRules.validateDateRange(startMillis, endMillis)
// Future date
val result = ValidationRules.validateFutureDate(dateMillis, currentTimeMillis)Business Logic Validation
// Check tournament capacity
val result = ValidationRules.validateParticipantCount(
currentCount = 24,
maxParticipants = 25
)
// Check guest limit
val result = ValidationRules.validateGuestCount(guestCount = 8)InputSanitizer
When to Use Each Function
| Function | Use Case | Preserves Newlines |
|---|---|---|
sanitizeTextInput() | Single-line fields | No |
sanitizeMultilineInput() | Multi-line fields | Yes |
escapeHtml() | Web view display | Yes |
trimToMaxLength() | Final DB safety | Yes |
Single-Line Text
Use for names, titles, and other single-line inputs:
// Android/Kotlin
fun onTournamentNameChange(name: String) {
val sanitized = InputSanitizer.sanitizeTextInput(name)
_uiState.update { it.copy(name = sanitized) }
}// iOS/Swift
func updateTournamentName(_ name: String) {
self.name = InputSanitizer.shared.sanitizeTextInput(input: name)
}What it does:
- Strips
<script>tags and content - Removes all HTML tags
- Removes control characters
- Normalizes whitespace to single spaces
- Trims leading/trailing whitespace
Multi-Line Text
Use for descriptions, notes, and other multi-line inputs:
// Android/Kotlin
fun onDescriptionChange(description: String) {
val sanitized = InputSanitizer.sanitizeMultilineInput(description)
_uiState.update { it.copy(description = sanitized) }
}// iOS/Swift
func updateDescription(_ description: String) {
self.description = InputSanitizer.shared.sanitizeMultilineInput(input: description)
}What it does:
- Strips
<script>tags and content - Removes all HTML tags
- Removes control characters
- Preserves single line breaks
- Collapses 3+ consecutive blank lines to double
- Trims whitespace from each line
HTML Escaping
Use when displaying user content in web views:
val safeHtml = InputSanitizer.escapeHtml(userInput)
// & → &
// < → <
// > → >
// " → "
// ' → 'Length Limiting
Final safety check before database storage:
val safeName = InputSanitizer.trimToMaxLength(
input = name,
maxLength = ValidationConstants.TOURNAMENT_NAME_MAX
)Integration Guide
Presenter Layer
class TournamentCreationPresenter {
fun onNameChanged(name: String) {
val sanitized = InputSanitizer.sanitizeTextInput(name)
val validation = ValidationRules.validateTournamentName(sanitized)
_state.update { current ->
current.copy(
name = sanitized,
nameError = when (validation) {
is ValidationResult.Valid -> null
is ValidationResult.Invalid -> validation.message
}
)
}
}
fun validateForm(): Boolean {
val nameResult = ValidationRules.validateTournamentName(state.name)
val endsResult = ValidationRules.validateNumEnds(state.numEnds)
val arrowsResult = ValidationRules.validateArrowsPerEnd(state.arrowsPerEnd)
// Update error states
_state.update { current ->
current.copy(
nameError = (nameResult as? ValidationResult.Invalid)?.message,
numEndsError = (endsResult as? ValidationResult.Invalid)?.message,
arrowsPerEndError = (arrowsResult as? ValidationResult.Invalid)?.message
)
}
return listOf(nameResult, endsResult, arrowsResult)
.all { it is ValidationResult.Valid }
}
}UI Layer (Android Compose)
OutlinedTextField(
value = state.name,
onValueChange = { presenter.onNameChanged(it) },
label = { Text("Tournament Name") },
isError = state.nameError != null,
supportingText = state.nameError?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth()
)With maxLength constraint:
OutlinedTextField(
value = state.name.take(ValidationConstants.TOURNAMENT_NAME_MAX),
onValueChange = { newValue ->
if (newValue.length <= ValidationConstants.TOURNAMENT_NAME_MAX) {
presenter.onNameChanged(newValue)
}
},
// ...
)UI Layer (iOS SwiftUI)
TextField("Tournament Name", text: $viewModel.name)
.onChange(of: viewModel.name) { newValue in
viewModel.name = InputSanitizer.shared.sanitizeTextInput(input: newValue)
}With maxLength constraint:
TextField("Tournament Name", text: $viewModel.name)
.onChange(of: viewModel.name) { newValue in
let sanitized = InputSanitizer.shared.sanitizeTextInput(input: newValue)
if sanitized.count <= ValidationConstants.shared.TOURNAMENT_NAME_MAX {
viewModel.name = sanitized
} else {
viewModel.name = String(sanitized.prefix(Int(ValidationConstants.shared.TOURNAMENT_NAME_MAX)))
}
}Recommended Field Mappings
| Field Type | Sanitizer | Validator | UI Constraint |
|---|---|---|---|
| Tournament Name | sanitizeTextInput() | validateTournamentName() | maxLength: 100 |
| Round Name | sanitizeTextInput() | validateRoundName() | maxLength: 100 |
| Guest Name | sanitizeTextInput() | validateGuestName() | maxLength: 30 |
| Description | sanitizeMultilineInput() | validateDescription() | maxLength: 1000 |
| Notes | sanitizeMultilineInput() | validateNotes() | maxLength: 1000 |
| Equipment Brand | sanitizeTextInput() | validateEquipmentBrand() | maxLength: 50 |
| Equipment Model | sanitizeTextInput() | validateEquipmentModel() | maxLength: 50 |
sanitizeTextInput() | validateEmail() | maxLength: 100 | |
| Password | (none) | validatePasswordStrength() | maxLength: 128 |
Testing
Unit tests are provided for all validation components:
shared/domain/src/commonTest/kotlin/com/archeryapprentice/domain/validation/
├── ValidationConstantsTest.kt
├── ValidationPatternsTest.kt
├── ValidationRulesTest.kt
└── InputSanitizerTest.kt
Running Tests
# All validation tests
./gradlew :shared:domain:testDebugUnitTest --tests "*.validation.*"
# Specific test file
./gradlew :shared:domain:testDebugUnitTest --tests "*.InputSanitizerTest"UI Validation Tests (Android)
# Instrumented tests for maxLength constraints
./gradlew :app:connectedDebugAndroidTest --tests "*.MaxLengthValidationUITest"Security Considerations
XSS Prevention
The InputSanitizer provides defense-in-depth against XSS:
- Script tag removal: Strips
<script>...</script>including content - HTML tag removal: Removes all HTML tags
- HTML escaping: For web view display contexts
Control Character Filtering
Control characters (ASCII 0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F) are stripped to prevent:
- Display corruption
- Log injection
- Terminal escape sequences
Length Limiting
All text inputs have maximum lengths to prevent:
- Database bloat
- Memory exhaustion
- Performance degradation
Migration Notes
Existing Data
The validation system uses a “grandfathered” approach:
- Existing data that exceeds limits is preserved
- Limits are enforced only on INSERT/UPDATE operations
- No data loss during migration
Breaking Changes
None. The validation layer adds constraints but doesn’t reject existing data.
Related Documentation
Last Updated: 2025-12-21 PRs: #387, #388, #389, #390, #391