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

FieldMinMaxRequired
Tournament Name3100Yes
Round Name3100Yes
Guest Name130Yes
Display Name250No
Description01000No
Notes01000No
Weather Conditions0200No
Rules05000No
Prizes02000No
Location0200No
Equipment Brand250Yes
Equipment Model150Yes
Equipment Notes0500No
Email5100Yes
Password8128Yes

Numeric Limits Reference

FieldMinMaxRationale
Max Participants250Practical tournament size
Number of Ends130Standard archery round limits
Arrows per End112Competition standard
Max Guests-9Reasonable guest limit
Arrow Score (WA)010World Archery scoring
Arrow Score (NFAA)05NFAA 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

FunctionDescription
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("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
            .replace("\"", "&quot;")
            .replace("'", "&#39;")
    }
 
    /**
     * 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

FunctionUse CasePreserves Newlines
sanitizeTextInput()Single-line fields (names, titles)No
sanitizeMultilineInput()Multi-line fields (descriptions, notes)Yes
escapeHtml()Displaying in web viewsYes
trimToMaxLength()Final safety check before DB storageYes

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

VulnerabilityRisk LevelMitigation
Unlimited text fieldsHIGHLength limits in ValidationConstants
Unbounded numeric inputsHIGHRange validation in ValidationRules
Missing email format validationMEDIUMEMAIL_REGEX pattern
XSS via HTML injectionHIGHstripHtmlTags, stripScriptTags
Database bloatHIGHmaxLength constraints
Performance DoSHIGHRealistic 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

ComponentTestsCoverage
ValidationRules25+100%
ValidationPatterns10+100%
InputSanitizer15+100%
ValidationConstantsN/AUsed 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

  1. User Input: Validate immediately on change
  2. Form Submission: Validate all fields before submit
  3. API Calls: Validate before sending to server
  4. 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”