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:

BeforeAfter
~15% validation coverage100% coverage
23 critical vulnerabilities0 vulnerabilities
Unlimited text fieldsEnforced limits
No input sanitizationXSS/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

FieldMinMaxUse Case
TOURNAMENT_NAME3100Tournament names
ROUND_NAME3100Round names
GUEST_NAME130Guest participant names
DISPLAY_NAME250User display names
DESCRIPTION-1000Tournament descriptions
NOTES-1000General notes
WEATHER_CONDITIONS-200Weather notes
RULES-5000Tournament rules
PRIZES-2000Prize descriptions
LOCATION-200Location descriptions
ORGANIZER_CONTACT-200Contact info

Equipment Limits

FieldMinMax
EQUIPMENT_BRAND250
EQUIPMENT_MODEL150
EQUIPMENT_MATERIAL-50
EQUIPMENT_NOTES-500
BOW_SETUP_NAME250

Authentication

FieldMinMax
EMAIL-100
PASSWORD8128

Numeric Ranges

FieldMinMaxRationale
MAX_PARTICIPANTS250Realistic tournament size
NUM_ENDS130Standard archery rounds
ARROWS_PER_END112Standard configurations
ARROW_SCORE_WA010World Archery scoring
ARROW_SCORE_NFAA05NFAA scoring
MAX_GUESTS-9Per-tournament guest limit

Format Constants

FieldValue
JOIN_CODE_LENGTH6
GUEST_ID_MIN_LENGTH8

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

FunctionUse CasePreserves Newlines
sanitizeTextInput()Single-line fieldsNo
sanitizeMultilineInput()Multi-line fieldsYes
escapeHtml()Web view displayYes
trimToMaxLength()Final DB safetyYes

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:

  1. Strips <script> tags and content
  2. Removes all HTML tags
  3. Removes control characters
  4. Normalizes whitespace to single spaces
  5. 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:

  1. Strips <script> tags and content
  2. Removes all HTML tags
  3. Removes control characters
  4. Preserves single line breaks
  5. Collapses 3+ consecutive blank lines to double
  6. Trims whitespace from each line

HTML Escaping

Use when displaying user content in web views:

val safeHtml = InputSanitizer.escapeHtml(userInput)
// & → &amp;
// < → &lt;
// > → &gt;
// " → &quot;
// ' → &#39;

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)))
        }
    }

Field TypeSanitizerValidatorUI Constraint
Tournament NamesanitizeTextInput()validateTournamentName()maxLength: 100
Round NamesanitizeTextInput()validateRoundName()maxLength: 100
Guest NamesanitizeTextInput()validateGuestName()maxLength: 30
DescriptionsanitizeMultilineInput()validateDescription()maxLength: 1000
NotessanitizeMultilineInput()validateNotes()maxLength: 1000
Equipment BrandsanitizeTextInput()validateEquipmentBrand()maxLength: 50
Equipment ModelsanitizeTextInput()validateEquipmentModel()maxLength: 50
EmailsanitizeTextInput()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:

  1. Script tag removal: Strips <script>...</script> including content
  2. HTML tag removal: Removes all HTML tags
  3. 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.



Last Updated: 2025-12-21 PRs: #387, #388, #389, #390, #391