Implement comprehensive input validation to prevent XSS, SQL injection, and command injection attacks with sanitization strategies
Install
npx skillscat add hack23/cia/input-validation Install via the SkillsCat registry.
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 enforcementPrinciple #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 encodingURL 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 inputInput 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:
- Secure Development Policy - Comprehensive input validation requirements and OWASP Top 10 mitigation
- Information Security Policy - Security principles and defense-in-depth
- Data Classification Policy - Data protection requirements
- Threat Modeling - Input attack vectors and STRIDE analysis
- Vulnerability Management - Input validation vulnerability lifecycle
All Hack23 ISMS Policies: https://github.com/Hack23/ISMS-PUBLIC
CIA Platform Architecture References
- Security Architecture: CIA SECURITY_ARCHITECTURE.md - Input validation implementation
- Threat Model: CIA THREAT_MODEL.md - Input-based attack scenarios
References
Standards & Guidelines
- OWASP Input Validation: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
- OWASP XSS Prevention: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- OWASP SQL Injection Prevention: https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
- ISO 27001:2022 A.8.28: Secure coding requirements
- CWE-20: Improper Input Validation
Tools & Libraries
- Bean Validation (JSR-380): https://beanvalidation.org/
- OWASP AntiSamy: https://owasp.org/www-project-antisamy/
- ESAPI: https://owasp.org/www-project-enterprise-security-api/