Input Validation & Sanitization
Comprehensive input validation and sanitization strategies to protect against injection attacks and common vulnerabilities.
Overview
Input validation is the first line of defense against injection attacks. Never trust user input - always validate, sanitize, and encode data before processing or storing it.
Never trust client-side validation alone. Always validate on the server. Client-side validation improves user experience; server-side validation provides security.
See also: Security Overview for security principles, Data Protection for protecting validated data, and Security Testing for testing validation logic.
Input Validation Strategy
Validation Layers
Input validation requires multiple layers because each layer serves a different purpose and can fail independently. Client-side validation provides immediate user feedback and reduces unnecessary server requests, improving user experience. However, it can be bypassed by attackers who manipulate browser requests or use API tools. Server-side validation is the security boundary - it's the only validation you can trust because it executes in your controlled environment. Business logic validation applies domain rules (e.g., "this user cannot transfer more than their authorization limit") after confirming the input is structurally valid. Sanitization prepares validated data for safe use in specific contexts like HTML rendering or database queries. This defense-in-depth approach ensures that even if one layer fails, others provide protection.
Validation Principles
- Whitelist over Blacklist: Define what is allowed, not what is forbidden
- Fail Securely: Reject invalid input, don't attempt to sanitize it
- Validate Early: Check input as soon as it enters the system
- Centralize Validation: Reuse validation logic across the application
- Context-Specific: Validation rules depend on how data will be used
Server-Side Validation
Bean Validation (JSR-303/JSR-380)
// DTO with comprehensive validation
public class PaymentRequest {
@NotNull(message = "Customer ID is required")
private UUID customerId;
@NotNull(message = "Amount is required")
@DecimalMin(value = "0.01", message = "Amount must be positive")
@DecimalMax(value = "1000000.00", message = "Amount exceeds maximum")
@Digits(integer = 10, fraction = 2, message = "Invalid amount format")
private BigDecimal amount;
@NotBlank(message = "Currency is required")
@Pattern(regexp = "^[A-Z]{3}$", message = "Invalid currency code (must be 3 uppercase letters)")
private String currency;
@NotBlank(message = "Source account is required")
@Pattern(regexp = "^[A-Z0-9]{8,20}$", message = "Invalid account number format")
private String sourceAccount;
@NotBlank(message = "Recipient account is required")
@Pattern(regexp = "^[A-Z0-9]{8,20}$", message = "Invalid recipient account")
private String recipientAccount;
@Size(max = 500, message = "Description must not exceed 500 characters")
private String description;
@Email(message = "Invalid email format")
private String recipientEmail;
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$", message = "Invalid phone number (E.164 format)")
private String recipientPhone;
@Future(message = "Scheduled date must be in the future")
private LocalDateTime scheduledDate;
// Custom validator
@ValidPaymentRecipient
private String recipientAccount;
@ValidTransactionType
private String transactionType;
}
// Controller with validation
@RestController
@RequestMapping("/api/payments")
@Validated
public class PaymentController {
@PostMapping
public ResponseEntity<PaymentResponse> createPayment(
@Valid @RequestBody PaymentRequest request) {
// If validation fails, Spring automatically returns 400
// with validation error details
PaymentResponse response = paymentService.createPayment(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping
public ResponseEntity<Page<PaymentResponse>> getPayments(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(required = false) @Pattern(regexp = "^[A-Z_]+$") String status) {
// Query parameter validation
Page<PaymentResponse> payments = paymentService.getPayments(page, size, status);
return ResponseEntity.ok(payments);
}
}
Custom Validators
// Custom constraint annotation
@Constraint(validatedBy = PaymentRecipientValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ValidPaymentRecipient {
String message() default "Invalid payment recipient";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Validator implementation
@Component
public class PaymentRecipientValidator
implements ConstraintValidator<ValidPaymentRecipient, String> {
@Autowired
private BlacklistService blacklistService;
@Autowired
private AccountService accountService;
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return false;
}
// Validate format
if (!value.matches("^[A-Z0-9]{8,20}$")) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"Account number must be 8-20 alphanumeric characters"
).addConstraintViolation();
return false;
}
// Check blacklist
if (blacklistService.isBlacklisted(value)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"This account is not eligible for transactions"
).addConstraintViolation();
return false;
}
// Verify account exists and is active
if (!accountService.isActiveAccount(value)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"Recipient account not found or inactive"
).addConstraintViolation();
return false;
}
return true;
}
}
Validation Error Handling
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
ErrorResponse response = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Validation Failed")
.message("Invalid input parameters")
.validationErrors(errors)
.build();
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(
ConstraintViolationException ex) {
Map<String, String> errors = new HashMap<>();
ex.getConstraintViolations().forEach(violation ->
errors.put(violation.getPropertyPath().toString(),
violation.getMessage())
);
ErrorResponse response = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Constraint Violation")
.message("Invalid request parameters")
.validationErrors(errors)
.build();
return ResponseEntity.badRequest().body(response);
}
}
SQL Injection Prevention
SQL injection occurs when untrusted data is sent to an interpreter as part of a command or query.
SQL injection occurs when untrusted input is concatenated into SQL strings. The database cannot distinguish between query structure and injected commands. For example, input ' OR '1'='1 creates an always-true condition, bypassing authentication. Input '; DROP TABLE users; -- terminates the query and executes a destructive command. Parameterized queries prevent this by treating user input as data, never as executable SQL.
SQL Injection Attack Flow
Safe: Parameterized Queries
// GOOD: Use parameterized queries (prevents SQL injection)
@Repository
public class PaymentRepositoryImpl {
@Autowired
private JdbcTemplate jdbcTemplate;
public List<Payment> findByCustomerAndStatus(UUID customerId, String status) {
// Parameterized query - safe from SQL injection
String sql = "SELECT * FROM payments WHERE customer_id = ? AND status = ?";
return jdbcTemplate.query(
sql,
new Object[]{customerId, status},
new PaymentRowMapper()
);
}
public Payment findById(UUID id) {
String sql = "SELECT * FROM payments WHERE id = ?";
return jdbcTemplate.queryForObject(
sql,
new Object[]{id},
new PaymentRowMapper()
);
}
public int updateStatus(UUID paymentId, String status) {
String sql = "UPDATE payments SET status = ?, updated_at = ? WHERE id = ?";
return jdbcTemplate.update(
sql,
status,
LocalDateTime.now(),
paymentId
);
}
}
// GOOD: JPA/Hibernate uses parameterized queries by default
@Repository
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
// Safe - uses parameterized query
List<Payment> findByCustomerId(UUID customerId);
// Safe - uses parameterized query
@Query("SELECT p FROM Payment p WHERE p.status = :status AND p.amount > :amount")
List<Payment> findByStatusAndMinAmount(
@Param("status") PaymentStatus status,
@Param("amount") BigDecimal amount
);
// Safe - native query with parameters
@Query(value = "SELECT * FROM payments WHERE customer_id = ?1 AND status = ?2",
nativeQuery = true)
List<Payment> findByCustomerAndStatus(UUID customerId, String status);
}
Unsafe: String Concatenation
// BAD: String concatenation (SQL injection vulnerability!)
@Repository
public class UnsafePaymentRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
// DANGEROUS! User input directly in SQL string
public List<Payment> findByCustomerAndStatus(UUID customerId, String status) {
String sql = "SELECT * FROM payments " +
"WHERE customer_id = '" + customerId + "' " +
"AND status = '" + status + "'";
// If status = "'; DROP TABLE payments; --"
// SQL becomes: SELECT * FROM payments WHERE customer_id = '...' AND status = ''; DROP TABLE payments; --'
return jdbcTemplate.query(sql, new PaymentRowMapper());
}
// DANGEROUS! String formatting with user input
public Payment findByEmail(String email) {
String sql = String.format(
"SELECT * FROM customers WHERE email = '%s'",
email
);
return jdbcTemplate.queryForObject(sql, new CustomerRowMapper());
}
}
Cross-Site Scripting (XSS) Prevention
XSS occurs when an application includes untrusted data in a web page without proper escaping.
XSS Attack Types
Reflected XSS is non-persistent - malicious script comes from the current request and is immediately reflected in the response. Stored XSS is persistent - script is stored in the database and executed for every user viewing the compromised content. DOM-based XSS occurs entirely in the browser through client-side script manipulation. All types can steal tokens, redirect users, or perform unauthorized actions. Prevention requires server-side sanitization for Reflected and Stored XSS, plus careful client-side coding for DOM-based XSS.
Backend: HTML Sanitization
// Backend sanitization
@Service
public class HtmlSanitizationService {
private final PolicyFactory policy;
public HtmlSanitizationService() {
// Define allowed HTML elements and attributes
this.policy = new HtmlPolicyBuilder()
.allowElements("p", "br", "strong", "em", "ul", "ol", "li", "a")
.allowAttributes("href").onElements("a")
.allowUrlProtocols("https")
.requireRelNofollowOnLinks()
.toFactory();
}
public String sanitize(String input) {
if (input == null) {
return null;
}
// Remove any dangerous HTML/JavaScript
return policy.sanitize(input);
}
public String sanitizeAndValidate(String input, int maxLength) {
if (input == null) {
return null;
}
if (input.length() > maxLength) {
throw new ValidationException("Input exceeds maximum length");
}
String sanitized = sanitize(input);
// Check if sanitization removed content (possible XSS attempt)
if (sanitized.length() < input.length() * 0.5) {
log.warn("Significant content removed during sanitization: {} -> {}",
input.length(), sanitized.length());
}
return sanitized;
}
}
// Use in service layer
@Service
public class CommentService {
@Autowired
private HtmlSanitizationService sanitizationService;
@Autowired
private CommentRepository commentRepository;
public Comment createComment(CommentRequest request) {
// Sanitize HTML input
String sanitizedContent = sanitizationService.sanitizeAndValidate(
request.getContent(),
5000
);
Comment comment = new Comment();
comment.setContent(sanitizedContent);
comment.setAuthorId(request.getAuthorId());
comment.setCreatedAt(LocalDateTime.now());
return commentRepository.save(comment);
}
}
Frontend: React Automatic Escaping
// GOOD: React automatically escapes content
function PaymentDetails({ payment }: { payment: Payment }) {
return (
<div>
{/* Safe: React escapes by default */}
<p>Description: {payment.description}</p>
<p>Recipient: {payment.recipientName}</p>
{/* Safe: Attributes are escaped */}
<input type="text" value={payment.reference} />
</div>
);
}
// BAD: DANGEROUS: dangerouslySetInnerHTML bypasses escaping
function UnsafeComponent({ content }: { content: string }) {
return (
<div dangerouslySetInnerHTML={{ __html: content }} />
// If content = "<script>alert('XSS')</script>", it will execute!
);
}
// GOOD: Sanitize before using dangerouslySetInnerHTML
import DOMPurify from 'dompurify';
function SafeHtmlComponent({ content }: { content: string }) {
const sanitizedContent = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href'],
});
return <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
}
Content Security Policy (CSP)
// Configure CSP headers
@Configuration
public class SecurityHeadersConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
)
)
// X-XSS-Protection omitted: removed in Spring Security 6.1+;
// modern browsers dropped support. Use Content-Security-Policy instead.
.frameOptions(frame -> frame
.deny()
)
);
return http.build();
}
}
Cross-Site Request Forgery (CSRF) Prevention
CSRF tricks a user's browser into making unwanted requests to a site where they're authenticated.
CSRF Attack Flow
CSRF tokens prevent attacks by requiring proof that requests originated from your application. Every state-changing request must include a secret token issued by the server and tied to the user's session. Malicious sites cannot access this token due to browser same-origin policy - JavaScript on attacker.com cannot read tokens from banking-app.com. Without the token, the server rejects requests even with valid session cookies.
CSRF Protection Implementation
// Spring Security CSRF protection
@Configuration
@EnableWebSecurity
public class CsrfSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
// Cookie-based CSRF token
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// Custom token handler
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
)
.authorizeHttpRequests(authz -> authz
// Exclude public endpoints from CSRF (GET requests are safe)
.requestMatchers(HttpMethod.GET, "/api/**").permitAll()
// Require CSRF token for state-changing operations
.requestMatchers(HttpMethod.POST, "/api/**").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/**").authenticated()
.anyRequest().authenticated()
);
return http.build();
}
}
// Custom CSRF token handler for SPAs
public class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {
private final CsrfTokenRequestHandler delegate =
new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
Supplier<CsrfToken> csrfToken) {
// Use XOR encoding for CSRF tokens
this.delegate.handle(request, response, csrfToken);
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
// Support both header and parameter
String headerValue = request.getHeader(csrfToken.getHeaderName());
return headerValue != null ? headerValue :
this.delegate.resolveCsrfTokenValue(request, csrfToken);
}
}
Frontend CSRF Token Usage
// React: Include CSRF token in requests
import axios from 'axios';
// Configure axios to include CSRF token
axios.interceptors.request.use((config) => {
// Get CSRF token from cookie
const csrfToken = getCookie('XSRF-TOKEN');
if (csrfToken) {
// Include in header
config.headers['X-XSRF-TOKEN'] = csrfToken;
}
return config;
});
function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(';').shift() || null;
}
return null;
}
// Usage
async function createPayment(paymentData: PaymentRequest) {
// CSRF token automatically included by interceptor
const response = await axios.post('/api/payments', paymentData);
return response.data;
}
OWASP Top 10 Vulnerabilities
1. Broken Access Control
See Authorization for detailed guidance.
// GOOD: Always verify ownership
@GetMapping("/api/payments/{id}")
public PaymentResponse getPayment(@PathVariable UUID id) {
Payment payment = paymentService.getPayment(id);
UUID currentUserId = getCurrentUserId();
if (!payment.getCustomerId().equals(currentUserId) &&
!currentUserHasRole("ADMIN")) {
throw new AccessDeniedException("Cannot access payment");
}
return PaymentMapper.toResponse(payment);
}
// BAD: No ownership check (IDOR vulnerability)
@GetMapping("/api/payments/{id}")
public PaymentResponse getPayment(@PathVariable UUID id) {
Payment payment = paymentService.getPayment(id);
return PaymentMapper.toResponse(payment);
// Any authenticated user can view any payment by guessing IDs!
}
2. Cryptographic Failures
See Data Protection for encryption guidance.
3. Injection Attacks
Covered in SQL Injection section above.
4. Insecure Design
- Threat model during design phase
- Security requirements in Definition of Ready
- See Technical Design
5. Security Misconfiguration
# GOOD: Secure production configuration
spring:
security:
require-ssl: true
server:
error:
include-message: never # Don't expose internals
include-stacktrace: never
include-binding-errors: never
management:
endpoints:
web:
exposure:
include: health,info,metrics # Only necessary endpoints
endpoint:
health:
show-details: when-authorized # Hide details from public
logging:
level:
root: INFO
com.company: INFO
org.springframework.security: WARN # Not DEBUG in production!
# BAD: Insecure configuration
spring:
security:
require-ssl: false # Allows HTTP!
server:
error:
include-stacktrace: always # Exposes internal details
management:
endpoints:
web:
exposure:
include: "*" # Exposes all actuator endpoints!
logging:
level:
root: DEBUG # Verbose logging in production
org.springframework.security: DEBUG # Logs authentication details
6. Vulnerable and Outdated Components
// build.gradle
plugins {
id 'org.owasp.dependencycheck' version '9.0.0'
}
dependencyCheck {
format = 'ALL'
failBuildOnCVSS = 7 // Fail on high severity
suppressionFile = 'dependency-check-suppressions.xml'
analyzers {
assemblyEnabled = false
nugetconfEnabled = false
}
}
// Run regularly in CI/CD
tasks.register('securityScan') {
dependsOn 'dependencyCheckAnalyze'
}
7. Identification and Authentication Failures
See Authentication for comprehensive guidance.
8. Software and Data Integrity Failures
// GOOD: Verify data integrity
@Service
public class PaymentIntegrityService {
public String calculateHash(Payment payment) {
// Include all critical fields
String data = String.join("|",
payment.getId().toString(),
payment.getAmount().toString(),
payment.getCurrency(),
payment.getCustomerId().toString(),
payment.getRecipientAccount(),
payment.getStatus().name()
);
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
public boolean verifyIntegrity(Payment payment, String expectedHash) {
String actualHash = calculateHash(payment);
return MessageDigest.isEqual(
actualHash.getBytes(StandardCharsets.UTF_8),
expectedHash.getBytes(StandardCharsets.UTF_8)
);
}
@EventListener
public void onPaymentCreated(PaymentCreatedEvent event) {
Payment payment = event.getPayment();
String hash = calculateHash(payment);
payment.setIntegrityHash(hash);
paymentRepository.save(payment);
}
@EventListener
public void onPaymentRetrieved(PaymentRetrievedEvent event) {
Payment payment = event.getPayment();
if (!verifyIntegrity(payment, payment.getIntegrityHash())) {
log.error("Payment integrity check failed: {}", payment.getId());
auditService.logSecurityEvent("INTEGRITY_VIOLATION", payment.getId());
throw new IntegrityException("Payment data has been tampered with");
}
}
}
9. Security Logging and Monitoring Failures
// GOOD: Comprehensive security logging
@Aspect
@Component
public class SecurityAuditAspect {
@Autowired
private AuditLogRepository auditLogRepository;
@Autowired
private SecurityAlertService securityAlertService;
@Around("@annotation(org.springframework.security.access.prepost.PreAuthorize)")
public Object logSecurityEvent(ProceedingJoinPoint joinPoint) throws Throwable {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth != null ? auth.getName() : "anonymous";
String method = joinPoint.getSignature().toShortString();
String ipAddress = getClientIpAddress();
try {
Object result = joinPoint.proceed();
// Log successful access
auditLog(username, method, "SUCCESS", null, ipAddress);
return result;
} catch (AccessDeniedException e) {
// Log access denied
auditLog(username, method, "ACCESS_DENIED", e.getMessage(), ipAddress);
// Check for suspicious patterns
if (hasRepeatedFailures(username, ipAddress, 5, Duration.ofMinutes(10))) {
securityAlertService.sendAlert(SecurityAlert.builder()
.severity("HIGH")
.username(username)
.ipAddress(ipAddress)
.message("Multiple access denied attempts detected")
.timestamp(LocalDateTime.now())
.build());
}
throw e;
} catch (Exception e) {
auditLog(username, method, "ERROR", e.getMessage(), ipAddress);
throw e;
}
}
private void auditLog(String username, String method, String result,
String error, String ipAddress) {
AuditLog log = AuditLog.builder()
.username(username)
.action(method)
.result(result)
.errorMessage(error)
.ipAddress(ipAddress)
.timestamp(LocalDateTime.now())
.build();
auditLogRepository.save(log);
}
private boolean hasRepeatedFailures(String username, String ipAddress,
int threshold, Duration window) {
LocalDateTime since = LocalDateTime.now().minus(window);
int failureCount = auditLogRepository.countFailures(
username, ipAddress, since
);
return failureCount >= threshold;
}
}
10. Server-Side Request Forgery (SSRF)
Server-Side Request Forgery (SSRF) tricks your server into making requests to unintended destinations. Attackers can access cloud metadata services (AWS at 169.254.169.254) to steal credentials, or reach internal services not exposed to the internet. The attack bypasses firewalls because requests come from your trusted server. Prevention requires strict URL validation: whitelist allowed domains, block private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.1), enforce HTTPS-only, and resolve hostnames to IPs to check against blocked ranges (preventing DNS rebinding).
// GOOD: Validate and whitelist URLs
@Service
public class WebhookService {
private static final Set<String> ALLOWED_DOMAINS = Set.of(
"api.partner.com",
"webhook.trusted-service.com"
);
private static final Set<String> BLOCKED_IP_RANGES = Set.of(
"127.0.0.0/8", // Loopback
"10.0.0.0/8", // Private
"172.16.0.0/12", // Private
"192.168.0.0/16", // Private
"169.254.0.0/16" // Link-local
);
public void sendWebhook(String urlString, PaymentEvent event) {
// Validate URL before making request
if (!isAllowedUrl(urlString)) {
throw new SecurityException("Webhook URL not allowed: " + urlString);
}
// Make request with timeout
restTemplate.postForEntity(urlString, event, Void.class);
}
private boolean isAllowedUrl(String urlString) {
try {
URL url = new URL(urlString);
// Check protocol (only HTTPS)
if (!"https".equals(url.getProtocol())) {
log.warn("Non-HTTPS webhook URL rejected: {}", urlString);
return false;
}
// Check domain whitelist
if (!ALLOWED_DOMAINS.contains(url.getHost())) {
log.warn("Non-whitelisted domain rejected: {}", url.getHost());
return false;
}
// Resolve IP and check for internal addresses
InetAddress addr = InetAddress.getByName(url.getHost());
if (addr.isSiteLocalAddress()) {
log.warn("Private IP address rejected: {}", addr.getHostAddress());
return false;
}
if (addr.isLoopbackAddress()) {
log.warn("Loopback address rejected: {}", addr.getHostAddress());
return false;
}
if (addr.isLinkLocalAddress()) {
log.warn("Link-local address rejected: {}", addr.getHostAddress());
return false;
}
return true;
} catch (MalformedURLException e) {
log.warn("Malformed webhook URL: {}", urlString);
return false;
} catch (UnknownHostException e) {
log.warn("Cannot resolve webhook URL: {}", urlString);
return false;
}
}
}
// BAD: No URL validation (SSRF vulnerability)
public void sendWebhookUnsafe(String url, PaymentEvent event) {
// Direct request without validation
// Attacker could use: http://localhost:8080/actuator/shutdown
// Or: http://169.254.169.254/latest/meta-data/ (AWS metadata)
restTemplate.postForEntity(url, event, Void.class);
}
Input Sanitization Utilities
@Component
public class InputSanitizationUtils {
/**
* Remove all special characters except alphanumeric and spaces
*/
public static String sanitizeAlphanumeric(String input) {
if (input == null) {
return null;
}
return input.replaceAll("[^a-zA-Z0-9\\s]", "");
}
/**
* Remove SQL special characters
*/
public static String sanitizeSql(String input) {
if (input == null) {
return null;
}
// Note: This is NOT a replacement for parameterized queries!
// Use only as additional defense layer
return input.replaceAll("[';\"\\-\\-]", "");
}
/**
* Sanitize filename to prevent path traversal
*/
public static String sanitizeFilename(String filename) {
if (filename == null) {
return null;
}
// Remove path separators and special characters
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
// Prevent path traversal
sanitized = sanitized.replace("..", "");
sanitized = sanitized.replace("./", "");
sanitized = sanitized.replace("../", "");
// Limit length
if (sanitized.length() > 255) {
sanitized = sanitized.substring(0, 255);
}
return sanitized;
}
/**
* Validate and sanitize email
*/
public static String sanitizeEmail(String email) {
if (email == null) {
return null;
}
email = email.trim().toLowerCase();
// Basic email validation
if (!email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
throw new ValidationException("Invalid email format");
}
return email;
}
/**
* Sanitize phone number (keep only digits and +)
*/
public static String sanitizePhoneNumber(String phone) {
if (phone == null) {
return null;
}
// Remove all except digits, +, and spaces
String sanitized = phone.replaceAll("[^0-9+\\s]", "");
// Validate E.164 format
if (!sanitized.matches("^\\+?[1-9]\\d{1,14}$")) {
throw new ValidationException("Invalid phone number format");
}
return sanitized;
}
}
Related Topics
- Security Overview - Security principles and threat landscape
- Authentication - Protecting authentication inputs
- Authorization - Validating access after input validation
- Data Protection - Protecting validated data
- Security Testing - Testing validation and injection prevention
- Code Review - Input validation review checklist
Summary
Key Takeaways:
- Never Trust Input: Always validate server-side, client-side validation is UX only
- Parameterized Queries: Always use parameterized queries, never string concatenation for SQL
- Escape Output: Use framework escaping (React auto-escapes), sanitize HTML when needed
- CSRF Protection: Enable CSRF tokens for all state-changing operations
- Whitelist Validation: Define what is allowed, not what is forbidden
- Fail Securely: Reject invalid input, don't attempt to "fix" it
- Security Headers: Use CSP, X-Frame-Options, X-Content-Type-Options
- OWASP Top 10: Know and mitigate the most critical vulnerabilities
- Audit Failures: Log validation failures to detect attack patterns
- Keep Dependencies Updated: Regularly scan for vulnerable components
- String concatenation in SQL queries (SQL injection)
- Missing server-side validation (trusting client)
- Using
dangerouslySetInnerHTMLwithout sanitization (XSS) - Disabled CSRF protection for "convenience"
- Verbose error messages exposing internal details
- No rate limiting on authentication endpoints (brute force)
- Blacklist validation instead of whitelist
- Logging sensitive data in error messages
- Using GET requests for state-changing operations
- Not validating file uploads (path traversal, malware)