skills/hack23/cia/input-validation

input-validation

SKILL.md

Input Validation Skill

Purpose

This skill provides systematic approaches to validate, sanitize, and encode all user inputs to prevent injection attacks, XSS, and data corruption. It implements defense-in-depth validation at multiple layers aligned with OWASP best practices and Hack23 ISMS secure coding standards.

When to Use This Skill

Apply this skill when:

  • ✅ Processing any user-provided input (forms, APIs, URL parameters)
  • ✅ Handling data from external systems (Riksdagen API, World Bank)
  • ✅ Constructing database queries or commands
  • ✅ Rendering user-generated content in UI
  • ✅ Processing file uploads
  • ✅ Implementing search functionality
  • ✅ Building dynamic SQL, LDAP, or OS commands

Validation Principles

Principle #1: Validate Early, Validate Often

Multi-Layer Validation:

Client Side (JavaScript)  → Basic UX validation
Server Side (Controller)  → Format and type validation
Service Layer            → Business logic validation
Database Layer           → Constraint enforcement

Principle #2: Allowlist Over Blocklist

INSECURE - Blocklist Approach:

// BAD: Trying to block malicious patterns (incomplete, bypassable)
public boolean isValidInput(String input) {
    String[] badPatterns = {"<script>", "javascript:", "onerror=", "' OR '1'='1"};
    for (String pattern : badPatterns) {
        if (input.toLowerCase().contains(pattern)) {
            return false;
        }
    }
    return true;
}

SECURE - Allowlist Approach:

// GOOD: Define what IS valid
public boolean isValidPoliticianName(String name) {
    // Only allow letters, spaces, hyphens, and apostrophes
    return name != null && 
           name.matches("^[A-Za-zÀ-ÿ\\s'-]{2,50}$") &&
           name.length() >= 2 &&
           name.length() <= 50;
}

public boolean isValidEmail(String email) {
    // Use standard email regex
    String emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
    return email != null && email.matches(emailRegex);
}

public boolean isValidYear(Integer year) {
    // Define valid range
    int currentYear = Year.now().getValue();
    return year != null && 
           year >= 1900 && 
           year <= currentYear;
}

Principle #3: Fail Securely

@ControllerAdvice
public class ValidationExceptionHandler {
    
    private static final Logger log = LoggerFactory.getLogger(ValidationExceptionHandler.class);
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex) {
        
        // Log detailed error for debugging (but don't expose to user)
        log.warn("Validation failed: {}", ex.getBindingResult().getAllErrors());
        
        // Return generic error to user (don't leak validation logic)
        ErrorResponse error = new ErrorResponse(
            "Invalid input",
            "One or more fields contain invalid data"
        );
        
        return ResponseEntity.badRequest().body(error);
    }
    
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(
            ConstraintViolationException ex) {
        
        log.warn("Constraint violation: {}", ex.getMessage());
        
        ErrorResponse error = new ErrorResponse(
            "Invalid input",
            "The provided data does not meet requirements"
        );
        
        return ResponseEntity.badRequest().body(error);
    }
}

Input Validation Patterns

Pattern #1: Bean Validation (JSR-380)

// Domain model with validation constraints
@Entity
@Table(name = "politician")
public class Politician {
    
    @Id
    @NotNull
    @Pattern(regexp = "^[0-9]{12}$", message = "Personal ID must be 12 digits")
    private String personalId;
    
    @NotBlank(message = "First name is required")
    @Size(min = 2, max = 50, message = "First name must be 2-50 characters")
    @Pattern(regexp = "^[A-Za-zÀ-ÿ\\s'-]+$", message = "First name contains invalid characters")
    private String firstName;
    
    @NotBlank(message = "Last name is required")
    @Size(min = 2, max = 50, message = "Last name must be 2-50 characters")
    @Pattern(regexp = "^[A-Za-zÀ-ÿ\\s'-]+$", message = "Last name contains invalid characters")
    private String lastName;
    
    @NotNull(message = "Birth date is required")
    @Past(message = "Birth date must be in the past")
    private LocalDate birthDate;
    
    @Email(message = "Invalid email format")
    @Pattern(regexp = "^[A-Za-z0-9+_.-]+@riksdagen\\.se$", 
             message = "Only riksdagen.se emails allowed")
    private String officialEmail;
    
    @Pattern(regexp = "^\\+46[0-9]{9}$", message = "Invalid Swedish phone number")
    private String phoneNumber;
    
    @NotNull(message = "Party is required")
    @Enumerated(EnumType.STRING)
    private PoliticalParty party;
}

// DTO for API requests
public class CreatePoliticianRequest {
    
    @NotBlank
    @Size(min = 2, max = 50)
    @Pattern(regexp = "^[A-Za-zÀ-ÿ\\s'-]+$")
    private String firstName;
    
    @NotBlank
    @Size(min = 2, max = 50)
    @Pattern(regexp = "^[A-Za-zÀ-ÿ\\s'-]+$")
    private String lastName;
    
    @NotNull
    @Past
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate birthDate;
    
    @Email
    private String email;
    
    @Pattern(regexp = "^\\+46[0-9]{9}$")
    private String phoneNumber;
    
    @NotNull
    private String partyCode;
    
    // Getters and setters
}

// Controller with validation
@RestController
@RequestMapping("/api/politicians")
@Validated
public class PoliticianController {
    
    @PostMapping
    public ResponseEntity<Politician> createPolitician(
            @Valid @RequestBody CreatePoliticianRequest request) {
        
        // If validation passes, create politician
        Politician politician = politicianService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(politician);
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<Politician> getPolitician(
            @PathVariable 
            @Pattern(regexp = "^[0-9]{12}$", message = "Invalid politician ID") 
            String id) {
        
        Politician politician = politicianService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Politician not found"));
        
        return ResponseEntity.ok(politician);
    }
}

Pattern #2: Custom Validators

// Custom annotation for Swedish Personal ID
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SwedishPersonalIdValidator.class)
public @interface ValidSwedishPersonalId {
    String message() default "Invalid Swedish personal ID";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// Validator implementation with checksum
public class SwedishPersonalIdValidator implements ConstraintValidator<ValidSwedishPersonalId, String> {
    
    @Override
    public boolean isValid(String personalId, ConstraintValidatorContext context) {
        if (personalId == null || personalId.length() != 12) {
            return false;
        }
        
        // Check format: YYYYMMDDNNNN
        if (!personalId.matches("^[0-9]{12}$")) {
            return false;
        }
        
        // Validate date part
        try {
            int year = Integer.parseInt(personalId.substring(0, 4));
            int month = Integer.parseInt(personalId.substring(4, 6));
            int day = Integer.parseInt(personalId.substring(6, 8));
            
            LocalDate.of(year, month, day);
        } catch (DateTimeException e) {
            return false;
        }
        
        // Validate Luhn checksum
        return validateLuhnChecksum(personalId);
    }
    
    private boolean validateLuhnChecksum(String number) {
        int sum = 0;
        boolean alternate = false;
        
        for (int i = number.length() - 1; i >= 0; i--) {
            int digit = Character.getNumericValue(number.charAt(i));
            
            if (alternate) {
                digit *= 2;
                if (digit > 9) {
                    digit = (digit % 10) + 1;
                }
            }
            
            sum += digit;
            alternate = !alternate;
        }
        
        return (sum % 10 == 0);
    }
}

// Usage
@Entity
public class Politician {
    @Id
    @ValidSwedishPersonalId
    private String personalId;
}

Pattern #3: Sanitization for XSS Prevention

@Component
public class InputSanitizer {
    
    private final Policy antiSamyPolicy;
    
    public InputSanitizer() throws PolicyException {
        // Load OWASP AntiSamy policy
        this.antiSamyPolicy = Policy.getInstance(
            getClass().getResourceAsStream("/antisamy-policy.xml")
        );
    }
    
    /**
     * Sanitize HTML input to prevent XSS
     */
    public String sanitizeHtml(String input) {
        if (input == null) return null;
        
        try {
            AntiSamy antiSamy = new AntiSamy();
            CleanResults results = antiSamy.scan(input, antiSamyPolicy);
            
            if (results.getNumberOfErrors() > 0) {
                log.warn("Sanitized HTML input. Errors: {}", 
                    results.getErrorMessages());
            }
            
            return results.getCleanHTML();
        } catch (Exception e) {
            log.error("Error sanitizing HTML", e);
            // Fail securely - return empty string
            return "";
        }
    }
    
    /**
     * Encode for HTML output
     */
    public String encodeForHtml(String input) {
        if (input == null) return null;
        return StringEscapeUtils.escapeHtml4(input);
    }
    
    /**
     * Encode for JavaScript output
     */
    public String encodeForJavaScript(String input) {
        if (input == null) return null;
        return StringEscapeUtils.escapeEcmaScript(input);
    }
    
    /**
     * Encode for URL parameter
     */
    public String encodeForUrl(String input) {
        if (input == null) return null;
        try {
            return URLEncoder.encode(input, StandardCharsets.UTF_8);
        } catch (Exception e) {
            return "";
        }
    }
    
    /**
     * Remove all non-alphanumeric characters
     */
    public String alphanumericOnly(String input) {
        if (input == null) return null;
        return input.replaceAll("[^A-Za-z0-9]", "");
    }
    
    /**
     * Truncate to maximum length
     */
    public String truncate(String input, int maxLength) {
        if (input == null) return null;
        if (input.length() <= maxLength) return input;
        return input.substring(0, maxLength);
    }
}

// Usage in service layer
@Service
public class CommentService {
    
    @Autowired
    private InputSanitizer sanitizer;
    
    public Comment createComment(String authorId, String content) {
        // Sanitize user-generated content
        String sanitizedContent = sanitizer.sanitizeHtml(content);
        
        // Truncate to reasonable length
        sanitizedContent = sanitizer.truncate(sanitizedContent, 5000);
        
        Comment comment = new Comment();
        comment.setAuthorId(authorId);
        comment.setContent(sanitizedContent);
        comment.setCreatedAt(Instant.now());
        
        return commentRepository.save(comment);
    }
}

// Usage in Vaadin UI
@Component
public class CommentView extends VerticalLayout {
    
    @Autowired
    private InputSanitizer sanitizer;
    
    public void displayComment(Comment comment) {
        // Encode for HTML display
        String safeContent = sanitizer.encodeForHtml(comment.getContent());
        
        Html contentHtml = new Html("<div>" + safeContent + "</div>");
        add(contentHtml);
    }
}

Pattern #4: SQL Injection Prevention

SECURE - Parameterized Queries:

@Repository
public interface PoliticianRepository extends JpaRepository<Politician, String> {
    
    // JPA Query with named parameters (SAFE)
    @Query("SELECT p FROM Politician p WHERE " +
           "LOWER(p.firstName) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR " +
           "LOWER(p.lastName) LIKE LOWER(CONCAT('%', :searchTerm, '%'))")
    List<Politician> search(@Param("searchTerm") String searchTerm);
    
    // Spring Data query methods (SAFE)
    List<Politician> findByPartyAndDistrictOrderByLastNameAsc(String party, String district);
}

// Criteria API for dynamic queries (SAFE)
@Repository
public class PoliticianSearchRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    public List<Politician> advancedSearch(PoliticianSearchCriteria criteria) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Politician> query = cb.createQuery(Politician.class);
        Root<Politician> politician = query.from(Politician.class);
        
        List<Predicate> predicates = new ArrayList<>();
        
        // Safe parameter binding
        if (criteria.getFirstName() != null) {
            predicates.add(cb.like(
                cb.lower(politician.get("firstName")),
                "%" + criteria.getFirstName().toLowerCase() + "%"
            ));
        }
        
        if (criteria.getParty() != null) {
            predicates.add(cb.equal(politician.get("party"), criteria.getParty()));
        }
        
        if (criteria.getMinBirthYear() != null) {
            predicates.add(cb.greaterThanOrEqualTo(
                politician.get("birthDate"),
                LocalDate.of(criteria.getMinBirthYear(), 1, 1)
            ));
        }
        
        query.where(predicates.toArray(new Predicate[0]));
        query.orderBy(cb.asc(politician.get("lastName")));
        
        return entityManager.createQuery(query).getResultList();
    }
}

INSECURE - String Concatenation:

// NEVER DO THIS!
public List<Politician> searchUnsafe(String searchTerm) {
    String sql = "SELECT * FROM politician WHERE " +
                 "first_name LIKE '%" + searchTerm + "%'";  // SQL INJECTION!
    
    return jdbcTemplate.query(sql, new PoliticianRowMapper());
}

Pattern #5: File Upload Validation

@Configuration
public class FileUploadConfig {
    
    private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
    private static final Set<String> ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "pdf");
    private static final Set<String> ALLOWED_MIME_TYPES = Set.of(
        "image/jpeg", "image/png", "application/pdf"
    );
    
    @Bean
    public MultipartConfigElement multipartConfigElement() {
        MultipartConfigFactory factory = new MultipartConfigFactory();
        factory.setMaxFileSize(DataSize.ofBytes(MAX_FILE_SIZE));
        factory.setMaxRequestSize(DataSize.ofBytes(MAX_FILE_SIZE));
        return factory.createMultipartConfig();
    }
}

@Service
public class FileValidationService {
    
    private static final Logger log = LoggerFactory.getLogger(FileValidationService.class);
    private static final Set<String> ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "pdf");
    private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
    
    /**
     * Comprehensive file validation
     */
    public void validateFile(MultipartFile file) throws ValidationException {
        if (file == null || file.isEmpty()) {
            throw new ValidationException("File is required");
        }
        
        // 1. Check file size
        if (file.getSize() > MAX_FILE_SIZE) {
            throw new ValidationException("File size exceeds maximum of 10 MB");
        }
        
        // 2. Validate filename
        String originalFilename = file.getOriginalFilename();
        if (originalFilename == null || originalFilename.contains("..")) {
            throw new ValidationException("Invalid filename");
        }
        
        // 3. Check file extension
        String extension = getFileExtension(originalFilename).toLowerCase();
        if (!ALLOWED_EXTENSIONS.contains(extension)) {
            throw new ValidationException(
                "File type not allowed. Allowed types: " + ALLOWED_EXTENSIONS);
        }
        
        // 4. Verify MIME type from content (not just filename)
        try {
            String contentType = detectContentType(file.getInputStream());
            if (!isAllowedContentType(contentType)) {
                throw new ValidationException("File content type not allowed");
            }
        } catch (IOException e) {
            throw new ValidationException("Error reading file");
        }
        
        // 5. Scan for malware (if applicable)
        scanForMalware(file);
    }
    
    private String getFileExtension(String filename) {
        int lastDot = filename.lastIndexOf('.');
        if (lastDot == -1) return "";
        return filename.substring(lastDot + 1);
    }
    
    private String detectContentType(InputStream inputStream) throws IOException {
        return URLConnection.guessContentTypeFromStream(inputStream);
    }
    
    private boolean isAllowedContentType(String contentType) {
        Set<String> allowed = Set.of("image/jpeg", "image/png", "application/pdf");
        return contentType != null && allowed.contains(contentType);
    }
    
    private void scanForMalware(MultipartFile file) {
        // Integrate with malware scanning service if available
        // E.g., ClamAV, VirusTotal API
    }
    
    /**
     * Generate safe filename
     */
    public String generateSafeFilename(String originalFilename) {
        String extension = getFileExtension(originalFilename);
        String uuid = UUID.randomUUID().toString();
        return uuid + "." + extension;
    }
}

@RestController
@RequestMapping("/api/uploads")
public class FileUploadController {
    
    @Autowired
    private FileValidationService fileValidationService;
    
    @PostMapping("/politician-photo")
    public ResponseEntity<UploadResponse> uploadPhoto(
            @RequestParam("file") MultipartFile file,
            @RequestParam("politicianId") @Pattern(regexp = "^[0-9]{12}$") String politicianId) {
        
        // Validate file
        fileValidationService.validateFile(file);
        
        // Generate safe filename
        String safeFilename = fileValidationService.generateSafeFilename(
            file.getOriginalFilename()
        );
        
        // Store file securely
        Path uploadPath = Paths.get("/secure/uploads/photos/", safeFilename);
        Files.copy(file.getInputStream(), uploadPath, StandardCopyOption.REPLACE_EXISTING);
        
        // Update database
        politicianService.updatePhoto(politicianId, safeFilename);
        
        UploadResponse response = new UploadResponse(safeFilename, file.getSize());
        return ResponseEntity.ok(response);
    }
}

Context-Specific Encoding

HTML Context

// Vaadin/Thymeleaf - Automatic escaping
<div th:text="${politician.firstName}"></div>  <!-- Auto-escaped -->

// Manual encoding when needed
String safe = StringEscapeUtils.escapeHtml4(userInput);

JavaScript Context

// In Vaadin or JavaScript
String safeJs = StringEscapeUtils.escapeEcmaScript(userInput);
String json = objectMapper.writeValueAsString(data); // Proper JSON encoding

URL Context

// URL parameters
String safeUrl = URLEncoder.encode(searchTerm, StandardCharsets.UTF_8);
String url = "/search?q=" + safeUrl;

SQL Context

// ALWAYS use parameterized queries (covered earlier)
// NEVER build SQL strings with user input

Input Validation Checklist

Before deploying code that processes user input:

Data Type Validation:

  • ✅ Verify data type (string, int, date, etc.)
  • ✅ Check for null/empty values
  • ✅ Validate length constraints
  • ✅ Ensure proper encoding (UTF-8)

Format Validation:

  • ✅ Use regex for pattern matching
  • ✅ Validate against allowlist of acceptable values
  • ✅ Check date/time formats
  • ✅ Verify email/phone formats

Business Logic Validation:

  • ✅ Check value ranges (min/max)
  • ✅ Validate relationships between fields
  • ✅ Verify uniqueness constraints
  • ✅ Ensure data consistency

Security Validation:

  • ✅ Sanitize HTML content (XSS prevention)
  • ✅ Use parameterized queries (SQL injection prevention)
  • ✅ Validate file uploads (type, size, content)
  • ✅ Check for path traversal attempts
  • ✅ Prevent command injection

Output Encoding:

  • ✅ Encode for HTML context
  • ✅ Encode for JavaScript context
  • ✅ Encode for URL context
  • ✅ Encode for CSS context

ISMS Compliance Mapping

ISO 27001:2022 Controls

  • A.8.28 - Secure Coding: Input validation as secure coding practice
  • A.14.2.1 - Secure Development Policy: Validation requirements
  • A.8.7 - Protection Against Malware: File upload validation

NIST Cybersecurity Framework

  • PR.DS-5: Protections against data leaks implemented
  • DE.CM-4: Malicious input detected

CIS Controls v8

  • Control 16.11: Establish input validation processes
  • Control 18.5: Validate application input

Hack23 ISMS Policy References

Input Validation & Secure Coding Framework:

All Hack23 ISMS Policies: https://github.com/Hack23/ISMS-PUBLIC

CIA Platform Architecture References

References

Standards & Guidelines

Tools & Libraries

Weekly Installs
2
Repository
hack23/cia
GitHub Stars
214
First Seen
14 days ago
Installed on
amp2
cline2
opencode2
cursor2
kimi-cli2
codex2