- name
- input-validation
- description
- Implement comprehensive input validation to prevent XSS, SQL injection, and command injection attacks with sanitization strategies
- license
- Apache-2.0
# 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:**
```java
// 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:**
```java
// 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
```java
@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)
```java
// 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
```java
// 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
```java
@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:**
```java
@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)
));
}
GitHub에서 보기