001/* 002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de) 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package de.cuioss.http.security.exceptions; 017 018import de.cuioss.http.security.core.UrlSecurityFailureType; 019import de.cuioss.http.security.core.ValidationType; 020import lombok.Builder; 021import lombok.EqualsAndHashCode; 022import lombok.Getter; 023import org.jspecify.annotations.Nullable; 024 025import java.util.Optional; 026import java.util.regex.Pattern; 027 028/** 029 * Main exception for HTTP security validation failures. 030 * Extends RuntimeException to enable clean functional interface usage and fail-fast behavior. 031 * 032 * <h3>Design Principles</h3> 033 * <ul> 034 * <li><strong>Fail Secure</strong> - Throws on any security violation for immediate handling</li> 035 * <li><strong>Rich Context</strong> - Provides detailed failure information for debugging and logging</li> 036 * <li><strong>Builder Pattern</strong> - Fluent API for exception construction</li> 037 * <li><strong>Immutable</strong> - All fields are final and thread-safe</li> 038 * </ul> 039 * 040 * <h3>Usage Examples</h3> 041 * <pre> 042 * // Simple security violation 043 * throw UrlSecurityException.builder() 044 * .failureType(UrlSecurityFailureType.PATH_TRAVERSAL_DETECTED) 045 * .validationType(ValidationType.URL_PATH) 046 * .originalInput("../../../etc/passwd") 047 * .build(); 048 * 049 * // Detailed violation with sanitized input 050 * throw UrlSecurityException.builder() 051 * .failureType(UrlSecurityFailureType.INVALID_CHARACTER) 052 * .validationType(ValidationType.PARAMETER_VALUE) 053 * .originalInput("user<script>test(1)</script>") 054 * .sanitizedInput("userscripttest1script") 055 * .detail("Removed script tags and special characters") 056 * .build(); 057 * 058 * // Chained exception 059 * throw UrlSecurityException.builder() 060 * .failureType(UrlSecurityFailureType.INVALID_ENCODING) 061 * .validationType(ValidationType.URL_PATH) 062 * .originalInput("%ZZ%invalid") 063 * .cause(originalException) 064 * .build(); 065 * </pre> 066 * 067 * Implements: Task B2 from HTTP verification specification 068 * 069 * @since 1.0 070 */ 071@EqualsAndHashCode(callSuper = true) 072public class UrlSecurityException extends RuntimeException { 073 074 /** 075 * Pre-compiled pattern for removing control characters from log output. 076 * Matches control characters (0x00-0x1F) and DEL character (0x7F). 077 */ 078 private static final Pattern CONTROL_CHARS_PATTERN = Pattern.compile("[\\x00-\\x1F\\x7F]"); 079 080 @Getter private final UrlSecurityFailureType failureType; 081 @Getter private final ValidationType validationType; 082 @Getter private final String originalInput; 083 @Nullable private final String sanitizedInput; 084 @Nullable private final String detail; 085 086 /** 087 * Creates a new UrlSecurityException with the specified parameters. 088 * Use the {@link #builder()} method for easier construction. 089 * 090 * @param failureType The type of security failure that occurred 091 * @param validationType The type of HTTP component being validated 092 * @param originalInput The original input that caused the security violation 093 * @param sanitizedInput Optional sanitized version of the input (may be null) 094 * @param detail Optional additional detail about the failure (may be null) 095 * @param cause Optional underlying cause exception (may be null) 096 */ 097 @Builder 098 private UrlSecurityException(UrlSecurityFailureType failureType, 099 ValidationType validationType, 100 String originalInput, 101 @Nullable String sanitizedInput, 102 @Nullable String detail, 103 @Nullable Throwable cause) { 104 super(buildMessage(failureType, validationType, originalInput, detail), cause); 105 this.failureType = failureType; 106 this.validationType = validationType; 107 this.originalInput = originalInput; 108 this.sanitizedInput = sanitizedInput; 109 this.detail = detail; 110 } 111 112 /** 113 * Gets the sanitized version of the input, if available. 114 * 115 * @return The sanitized input wrapped in Optional, or empty if not provided 116 */ 117 public Optional<String> getSanitizedInput() { 118 return Optional.ofNullable(sanitizedInput); 119 } 120 121 /** 122 * Gets additional detail about the security failure. 123 * 124 * @return Additional detail wrapped in Optional, or empty if not provided 125 */ 126 public Optional<String> getDetail() { 127 return Optional.ofNullable(detail); 128 } 129 130 /** 131 * Builds a comprehensive error message from the exception components. 132 * 133 * @param failureType The type of failure 134 * @param validationType The type of validation 135 * @param originalInput The input that caused the failure 136 * @param detail Optional additional detail 137 * @return A formatted error message 138 */ 139 private static String buildMessage(UrlSecurityFailureType failureType, 140 ValidationType validationType, 141 String originalInput, 142 @Nullable String detail) { 143 StringBuilder sb = new StringBuilder(); 144 sb.append("Security validation failed [").append(validationType).append("]: "); 145 sb.append(failureType.getDescription()); 146 147 if (detail != null && !detail.trim().isEmpty()) { 148 sb.append(" - ").append(detail); 149 } 150 151 // Safely truncate input for logging to prevent log injection 152 String truncatedInput = truncateForLogging(originalInput); 153 sb.append(" (input: '").append(truncatedInput).append("')"); 154 155 return sb.toString(); 156 } 157 158 /** 159 * Safely truncates input for logging to prevent log injection attacks. 160 * 161 * @param input The input to truncate 162 * @return Safe truncated input 163 */ 164 private static String truncateForLogging(@Nullable String input) { 165 if (input == null) { 166 return "null"; 167 } 168 169 // Remove control characters and limit length 170 String safe = CONTROL_CHARS_PATTERN.matcher(input).replaceAll("?"); 171 172 if (safe.length() > 200) { 173 return safe.substring(0, 200) + "..."; 174 } 175 176 return safe; 177 } 178 179 180 @Override 181 public String toString() { 182 return getClass().getSimpleName() + "{" + 183 "failureType=" + failureType + 184 ", validationType=" + validationType + 185 ", originalInput='" + truncateForLogging(originalInput) + '\'' + 186 ", sanitizedInput='" + (sanitizedInput != null ? truncateForLogging(sanitizedInput) : null) + '\'' + 187 ", detail='" + detail + '\'' + 188 ", cause=" + getCause() + 189 '}'; 190 } 191 192}