Skip to content

Add Request Validation Middleware #85

Description

@kelly-musk

Priority: High
Estimated Time: 3-4 hours
Labels: security, validation, middleware, input-sanitization
Dependencies: Issue #8 (Error Handling)


Overview

Implement comprehensive request validation middleware that sanitizes inputs, validates data types, checks formats, prevents injection attacks, and ensures all incoming data meets expected schemas before reaching business logic.


What You're Building

A request validation system that:

  • Validates JSON request bodies
  • Sanitizes string inputs (prevents XSS, SQL injection)
  • Validates wallet addresses (Stellar format)
  • Validates amounts (positive decimals, within limits)
  • Validates phone numbers (Nigerian format)
  • Validates email addresses
  • Checks required fields
  • Enforces data type constraints
  • Returns clear validation error messages

This prevents invalid/malicious data from entering the system.


Key Requirements

1. Input Sanitization

Sanitize All String Inputs:

Remove Dangerous Characters:

  • HTML/JavaScript tags (prevent XSS)
  • SQL special characters (prevent injection)
  • Control characters
  • Excessive whitespace
  • Unicode exploits

Sanitization Rules:

Input: "<script>alert('xss')</script>Hello"
Output: "Hello"

Input: "'; DROP TABLE users; --"
Output: "DROP TABLE users"

Input: "Normal   text   with   spaces"
Output: "Normal text with spaces"

What to Sanitize:

  • Account names
  • Email addresses
  • Narration fields
  • Memo fields
  • Any user-provided text

2. Wallet Address Validation

Stellar Wallet Format:

Validation Rules:

  • Exactly 56 characters
  • Starts with 'G'
  • Contains only valid base32 characters (A-Z, 2-7)
  • Valid checksum

Validation:

fn validate_stellar_address(address: &str) -> Result<()> {
    // Length check
    if address.len() != 56 {
        return Err("Wallet address must be 56 characters");
    }
    
    // Starts with G
    if !address.starts_with('G') {
        return Err("Stellar address must start with 'G'");
    }
    
    // Valid base32
    let valid_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
    if !address.chars().all(|c| valid_chars.contains(c)) {
        return Err("Invalid characters in wallet address");
    }
    
    // Optional: Verify checksum with stellar-sdk
    PublicKey::from_account_id(address)?;
    
    Ok(())
}

3. Amount Validation

Monetary Values:

Validation Rules:

  • Must be positive (> 0)
  • Maximum 2 decimal places for NGN/cNGN
  • Within acceptable range (min/max)
  • Not NaN or Infinity
  • Valid decimal format

Validation:

fn validate_amount(
    amount: &str,
    min: Decimal,
    max: Decimal
) -> Result<Decimal> {
    // Parse as decimal
    let decimal = Decimal::from_str(amount)
        .map_err(|_| "Invalid amount format")?;
    
    // Check positive
    if decimal <= Decimal::ZERO {
        return Err("Amount must be greater than zero");
    }
    
    // Check range
    if decimal < min {
        return Err(format!("Amount below minimum: {}", min));
    }
    if decimal > max {
        return Err(format!("Amount above maximum: {}", max));
    }
    
    // Check decimal places (2 for currency)
    if decimal.scale() > 2 {
        return Err("Amount can have maximum 2 decimal places");
    }
    
    Ok(decimal)
}

4. Phone Number Validation

Nigerian Phone Numbers:

Format Rules:

  • 11 digits starting with 0
  • Or 13 digits starting with +234
  • Valid network prefixes (080, 081, 070, 090, etc.)

Validation:

fn validate_nigerian_phone(phone: &str) -> Result<String> {
    // Remove spaces and dashes
    let cleaned = phone.replace([' ', '-'], "");
    
    // Pattern: 0XXXXXXXXXXX (11 digits)
    if cleaned.starts_with('0') && cleaned.len() == 11 {
        // Validate network prefix
        let prefix = &cleaned[0..4];
        let valid_prefixes = [
            "0803", "0806", "0810", "0813", "0814", "0816", // MTN
            "0802", "0808", "0812", "0901", "0902", "0904", // Airtel
            "0805", "0807", "0811", "0815", "0905", "0915", // Glo
            "0809", "0817", "0818", "0908", "0909"          // 9mobile
        ];
        
        if valid_prefixes.contains(&prefix) {
            return Ok(cleaned);
        }
    }
    
    // Pattern: +234XXXXXXXXXX (13 digits)
    if cleaned.starts_with("+234") && cleaned.len() == 14 {
        // Convert to local format
        let local = format!("0{}", &cleaned[4..]);
        return validate_nigerian_phone(&local);
    }
    
    Err("Invalid Nigerian phone number format")
}

5. Email Validation

Email Format:

Validation Rules:

  • Valid email format (RFC 5322)
  • Contains @ symbol
  • Has domain
  • Reasonable length (< 254 chars)

Validation:

fn validate_email(email: &str) -> Result<String> {
    // Basic format check
    let email_regex = Regex::new(
        r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
    )?;
    
    if !email_regex.is_match(email) {
        return Err("Invalid email format");
    }
    
    // Length check
    if email.len() > 254 {
        return Err("Email too long");
    }
    
    // Normalize (lowercase)
    Ok(email.to_lowercase())
}

6. Schema Validation

Define Request Schemas:

Example Schemas:

#[derive(Deserialize, Validate)]
struct OnrampQuoteRequest {
    #[validate(custom = "validate_stellar_address")]
    wallet_address: String,
    
    #[validate(custom = "validate_currency")]
    from_currency: String,
    
    #[validate(custom = "validate_currency")]
    to_currency: String,
    
    #[validate(range(min = 100, max = 5000000))]
    amount: String,
}

#[derive(Deserialize, Validate)]
struct BillPaymentRequest {
    #[validate(custom = "validate_stellar_address")]
    wallet_address: String,
    
    #[validate(length(min = 1))]
    provider_id: String,
    
    #[serde(flatten)]
    payment_details: HashMap<String, Value>,
}

Validation Rules by Endpoint

Onramp Quote

  • ✓ wallet_address: Stellar format
  • ✓ from_currency: "NGN"
  • ✓ to_currency: "cNGN"
  • ✓ amount: 100 - 5,000,000

Offramp Quote

  • ✓ wallet_address: Stellar format
  • ✓ from_currency: "cNGN"
  • ✓ to_currency: "NGN"
  • ✓ amount: 100 - 5,000,000

Bill Payment

  • ✓ wallet_address: Stellar format
  • ✓ provider_id: Non-empty, exists in providers list
  • ✓ meter_number: 10-13 digits (if electricity)
  • ✓ phone_number: Nigerian format (if airtime)
  • ✓ amount: Positive, within provider limits

Authentication

  • ✓ wallet_address: Stellar format
  • ✓ message: Non-empty
  • ✓ signature: Base64 encoded
  • ✓ nonce: UUID format

Error Response Format

Validation Error (400 Bad Request):

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "errors": [
      {
        "field": "wallet_address",
        "message": "Invalid Stellar wallet address format",
        "value": "GXXX",
        "expected": "56 character Stellar address starting with 'G'"
      },
      {
        "field": "amount",
        "message": "Amount must be greater than minimum",
        "value": "50",
        "minimum": "100"
      }
    ]
  }
}

Single Field Error:

{
  "error": {
    "code": "INVALID_WALLET_ADDRESS",
    "message": "Wallet address must be 56 characters",
    "field": "wallet_address",
    "provided": "GXXX"
  }
}

Acceptance Criteria

  • Validation middleware implemented
  • Stellar wallet address validation works
  • Amount validation prevents negative/zero values
  • Amount validation enforces min/max limits
  • Phone number validation accepts Nigerian formats
  • Email validation accepts valid emails
  • String sanitization removes dangerous characters
  • Schema validation enforces required fields
  • Clear error messages for validation failures
  • Multiple validation errors returned together
  • Validation happens before business logic
  • SQL injection attempts blocked
  • XSS attempts blocked
  • Unicode exploits prevented

Testing Checklist

  • Test valid wallet address passes
  • Test invalid wallet address rejected
  • Test wallet address with wrong length rejected
  • Test amount with negative value rejected
  • Test amount below minimum rejected
  • Test amount above maximum rejected
  • Test valid phone number passes
  • Test invalid phone number rejected
  • Test valid email passes
  • Test invalid email rejected
  • Test SQL injection attempt sanitized
  • Test XSS attempt sanitized
  • Test missing required field rejected
  • Test wrong data type rejected
  • Test multiple errors returned together

Implementation Steps

  1. Create validation module in src/middleware/validation.rs
  2. Implement wallet address validator
  3. Implement amount validator
  4. Implement phone number validator
  5. Implement email validator
  6. Implement string sanitizer
  7. Create validation middleware
  8. Add schema validation with serde
  9. Implement error formatter
  10. Write comprehensive tests

Validation Middleware

Apply to Routes:

// Validate request body
app.route(
    "/api/onramp/quote",
    post(create_quote)
        .layer(middleware::from_fn(validate_request::<OnrampQuoteRequest>))
);

// Custom validation
app.route(
    "/api/bills/pay",
    post(pay_bill)
        .layer(middleware::from_fn(validate_bill_payment))
);

Middleware Implementation:

async fn validate_request<T>(
    State(state): State<AppState>,
    req: Request,
    next: Next,
) -> Result<Response, ValidationError>
where
    T: DeserializeOwned + Validate
{
    // Parse body
    let body: T = extract_json(&req).await?;
    
    // Validate
    body.validate()
        .map_err(|e| ValidationError::from(e))?;
    
    // Continue
    Ok(next.run(req).await)
}

Common Validation Patterns

Required Field:

#[validate(length(min = 1))]
field: String

Numeric Range:

#[validate(range(min = 100, max = 5000000))]
amount: u64

Custom Validator:

#[validate(custom = "validate_stellar_address")]
wallet_address: String

Email:

#[validate(email)]
email: String

Length:

#[validate(length(min = 10, max = 13))]
meter_number: String

Security Considerations

SQL Injection Prevention:

  • Use parameterized queries (SQLx already does this)
  • Sanitize any dynamic SQL (avoid if possible)
  • Never concatenate user input into SQL

XSS Prevention:

  • Sanitize HTML tags from inputs
  • Encode output when rendering
  • Use Content-Security-Policy headers

Path Traversal:

  • Validate file paths
  • Prevent ../ sequences
  • Whitelist allowed paths

Command Injection:

  • Never pass user input to shell commands
  • Use library functions instead of system calls

Unicode Exploits:

  • Normalize unicode strings
  • Reject unusual unicode characters
  • Validate string encoding

Sanitization Functions

Remove HTML Tags:

fn sanitize_html(input: &str) -> String {
    // Remove < and > to prevent tags
    input.replace(['<', '>'], "")
}

Remove SQL Characters:

fn sanitize_sql(input: &str) -> String {
    // Remove potentially dangerous SQL chars
    input.replace(['\'', '"', ';', '-', '\\'], "")
}

Normalize Whitespace:

fn normalize_whitespace(input: &str) -> String {
    input.split_whitespace().collect::<Vec<_>>().join(" ")
}

Complete Sanitization:

fn sanitize_string(input: &str) -> String {
    let mut cleaned = input.to_string();
    cleaned = sanitize_html(&cleaned);
    cleaned = normalize_whitespace(&cleaned);
    cleaned.trim().to_string()
}

Provider-Specific Validation

Electricity (EKEDC):

fn validate_meter_number(meter: &str) -> Result<()> {
    if meter.len() < 10 || meter.len() > 13 {
        return Err("Meter number must be 10-13 digits");
    }
    if !meter.chars().all(|c| c.is_numeric()) {
        return Err("Meter number must contain only digits");
    }
    Ok(())
}

Cable TV (DSTV):

fn validate_smart_card(card: &str) -> Result<()> {
    if card.len() != 10 {
        return Err("Smart card number must be 10 digits");
    }
    if !card.chars().all(|c| c.is_numeric()) {
        return Err("Smart card must contain only digits");
    }
    Ok(())
}

Validation Error Collection

Collect Multiple Errors:

struct ValidationErrors {
    errors: Vec<FieldError>
}

impl ValidationErrors {
    fn new() -> Self {
        Self { errors: Vec::new() }
    }
    
    fn add(&mut self, field: &str, message: &str) {
        self.errors.push(FieldError {
            field: field.to_string(),
            message: message.to_string()
        });
    }
    
    fn is_empty(&self) -> bool {
        self.errors.is_empty()
    }
    
    fn into_response(self) -> Response {
        // Convert to error response
    }
}

Monitoring & Logging

Track Validation Failures:

  • Log validation errors (not values)
  • Track which fields fail most
  • Identify potential attack patterns
  • Monitor validation error rate

Alerts:

  • Sudden spike in validation errors
  • Repeated SQL injection attempts
  • Repeated XSS attempts
  • Unusual input patterns

Notes

  • Validate early (before expensive operations)
  • Return all validation errors at once (better UX)
  • Be specific in error messages
  • Don't leak sensitive info in errors
  • Sanitize both input and output
  • Use established validation libraries
  • Test with malicious inputs
  • Keep validation rules updated
  • Document validation requirements
  • Consider validation as part of API contract

Resources

  • OWASP Input Validation Cheat Sheet
  • validator Rust crate documentation
  • Regex for email/phone validation
  • SQL injection prevention techniques
  • XSS prevention best practices

Success =

✅ All inputs validated before processing
✅ SQL injection prevented
✅ XSS attacks blocked
✅ Clear validation error messages
✅ Robust security layer!

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions