001package com.streamconverter.command.rule; 002 003import java.sql.Connection; 004import java.sql.DriverManager; 005import java.sql.PreparedStatement; 006import java.sql.ResultSet; 007import java.sql.ResultSetMetaData; 008import java.sql.SQLException; 009import java.util.Objects; 010import java.util.regex.Pattern; 011import org.slf4j.Logger; 012import org.slf4j.LoggerFactory; 013 014/** 015 * データベースからデータを取得するルール 016 * 017 * <p>このクラスは、データベースからデータを取得するためのルールを定義します。 具体的なデータベース接続やクエリ実行のロジックは、このクラスで実装されます。 018 * 019 * <p>使用例: 020 * 021 * <pre>{@code 022 * // 基本的な使用例 023 * DatabaseFetchRule rule = new DatabaseFetchRule( 024 * "jdbc:h2:mem:testdb", 025 * "SELECT name FROM users WHERE id = ?" 026 * ); 027 * String result = rule.apply("123"); // ユーザーID 123 の名前を取得 028 * 029 * // NavigateCommandと組み合わせた使用例 030 * JsonNavigateCommand command = new JsonNavigateCommand("$.userId", rule); 031 * command.execute(inputStream, outputStream); // JSON中のuserIdでDBを検索して置換 032 * }</pre> 033 * 034 * <p>セキュリティ機能: 035 * 036 * <ul> 037 * <li>SELECTクエリのみ許可(INSERT/UPDATE/DELETE等は禁止) 038 * <li>SQLインジェクション攻撃の検出と防止 039 * <li>許可されたデータベーススキーマのみ接続可能 040 * <li>入力パラメータの自動サニタイズ 041 * </ul> 042 */ 043public class DatabaseFetchRule implements IRule { 044 private static final Logger logger = LoggerFactory.getLogger(DatabaseFetchRule.class); 045 046 /** SQLインジェクション攻撃を検出するパターン(SELECT以外の危険なSQL文) */ 047 private static final Pattern SQL_INJECTION_PATTERN = 048 Pattern.compile( 049 "(?i).*(union|insert|update|delete|drop|create|alter|exec|execute|sp_|xp_).*", 050 Pattern.CASE_INSENSITIVE); 051 052 /** 許可されるデータベースURLスキーマ(テスト用のmockも含む) */ 053 private static final Pattern ALLOWED_DB_SCHEME_PATTERN = 054 Pattern.compile( 055 "^jdbc:(h2|hsqldb|sqlite|postgresql|mysql|mock):.*", Pattern.CASE_INSENSITIVE); 056 057 private final String databaseUrl; 058 private final String query; 059 060 /** 061 * コンストラクタ 062 * 063 * <p>データベースのURLとクエリを指定して、DatabaseFetchRuleのインスタンスを初期化します。 064 * クエリは一意な結果を返すように設計されるべきです(例:DISTINCT、LIMIT句の使用など)。 セキュリティのため、URLとクエリの検証を実行します。 065 * 066 * @param databaseUrl データベースのURL(許可されたスキーマのみ) 067 * @param query データベースに対するクエリ(SELECTクエリのみ許可) 068 * @throws IllegalArgumentException 無効なパラメータが指定された場合 069 * @throws SecurityException セキュリティ違反が検出された場合 070 */ 071 public DatabaseFetchRule(String databaseUrl, String query) { 072 Objects.requireNonNull(databaseUrl, "Database URL cannot be null"); 073 Objects.requireNonNull(query, "Query cannot be null"); 074 075 // データベースURLの検証 076 this.databaseUrl = validateDatabaseUrl(databaseUrl.trim()); 077 078 // クエリの検証 079 this.query = validateQuery(query.trim()); 080 081 logger.info( 082 "DatabaseFetchRule initialized with secure validation - URL: {}, Query length: {}", 083 this.databaseUrl, 084 this.query.length()); 085 } 086 087 /** 088 * データベースURLを検証します(セキュリティ対策) 089 * 090 * @param url 検証対象のURL 091 * @return 検証済みのURL 092 * @throws SecurityException 不正なURLが検出された場合 093 */ 094 private String validateDatabaseUrl(String url) { 095 if (url.isEmpty()) { 096 throw new IllegalArgumentException("Database URL cannot be empty"); 097 } 098 099 // 許可されたスキーマのチェック 100 if (!ALLOWED_DB_SCHEME_PATTERN.matcher(url).matches()) { 101 throw new SecurityException( 102 "Database URL uses unsupported or potentially dangerous scheme: " + url); 103 } 104 105 // 危険な文字列の検出 106 if (url.contains("..") || url.contains("file:") || url.contains("javascript:")) { 107 throw new SecurityException("Database URL contains potentially dangerous patterns: " + url); 108 } 109 110 logger.debug("Database URL validation passed: {}", url); 111 return url; 112 } 113 114 /** 115 * クエリを検証します(SQLインジェクション対策) 116 * 117 * @param queryString 検証対象のクエリ 118 * @return 検証済みのクエリ 119 * @throws SecurityException SQLインジェクションが検出された場合 120 */ 121 private String validateQuery(String queryString) { 122 if (queryString.isEmpty()) { 123 throw new IllegalArgumentException("Query cannot be empty"); 124 } 125 126 // SELECTクエリのみ許可 127 if (!queryString.trim().toLowerCase().startsWith("select")) { 128 throw new SecurityException("Only SELECT queries are allowed: " + queryString); 129 } 130 131 // SQLインジェクション攻撃の検出(パターンマッチング使用) 132 if (SQL_INJECTION_PATTERN.matcher(queryString).matches()) { 133 throw new SecurityException( 134 "Query contains potentially dangerous SQL commands: " + queryString); 135 } 136 137 // セミコロンによる複数文の実行を防止 138 if (queryString.contains(";") && !queryString.trim().endsWith(";")) { 139 throw new SecurityException("Multiple SQL statements are not allowed: " + queryString); 140 } 141 142 logger.debug("Query validation passed, length: {}", queryString.length()); 143 return queryString; 144 } 145 146 /** 147 * 入力パラメータをサニタイズします 148 * 149 * @param input サニタイズ対象の入力 150 * @return サニタイズされた入力 151 */ 152 private String sanitizeInput(String input) { 153 if (input == null) { 154 throw new IllegalArgumentException("Input parameter cannot be null"); 155 } 156 157 // 危険な文字の除去/エスケープ 158 String sanitized = 159 input 160 .replace("'", "''") // シングルクォートのエスケープ 161 .replace("--", "") // SQLコメントの除去 162 .replace("/*", "") // ブロックコメント開始の除去 163 .replace("*/", ""); // ブロックコメント終了の除去 164 165 // 極端に長い入力の制限 166 if (sanitized.length() > 1000) { 167 logger.warn("Input parameter is extremely long, truncating: length={}", sanitized.length()); 168 sanitized = sanitized.substring(0, 1000); 169 } 170 171 return sanitized; 172 } 173 174 /** 175 * ルールの適用を実行します。 176 * 177 * <p>このメソッドは、ストリーム変換の際にルールを適用するために使用されます。 データベースからデータを取得するロジックを実装します。 結果セットの先頭行・先頭列の値を返却します。 178 * 結果が1行1列でない場合は警告をログに出力します。 179 * 180 * @param input 変換対象の文字列(クエリパラメータとして使用) 181 * @return String output クエリ結果の先頭値、または空文字列(結果がない場合) 182 */ 183 @Override 184 public String apply(String input) { 185 try (Connection connection = DriverManager.getConnection(databaseUrl); 186 PreparedStatement statement = connection.prepareStatement(query)) { 187 188 // データベース接続 189 logger.debug("データベースに接続: {}", databaseUrl); 190 191 // 入力文字列をパラメータとして設定(クエリに「?」プレースホルダーがある場合) 192 if (query.contains("?") && input != null && !input.isEmpty()) { 193 // 入力値のサニタイズとセキュリティチェック 194 String sanitizedInput = sanitizeInput(input); 195 if (sanitizedInput.isEmpty()) { 196 logger.warn( 197 "Input parameter was sanitized to empty string. Rejecting input for security reasons. Original input: {}", 198 input); 199 return ""; 200 } 201 202 statement.setString(1, sanitizedInput); 203 logger.debug("Parameter set for prepared statement: length={}", sanitizedInput.length()); 204 } 205 206 // クエリ実行 207 logger.debug("クエリを実行: {}", query); 208 try (ResultSet resultSet = statement.executeQuery()) { 209 // 結果の検証と処理 210 ResultSetMetaData metaData = resultSet.getMetaData(); 211 int columnCount = metaData.getColumnCount(); 212 213 // 結果がない場合 214 if (!resultSet.next()) { 215 logger.warn("クエリ結果が空です。"); 216 return ""; 217 } 218 219 // 列数の検証 220 if (columnCount != 1) { 221 logger.warn("クエリ結果が一列ではありません。列数: {}。先頭列の値を使用します。", columnCount); 222 } 223 224 // 先頭行の先頭列の値を取得 225 String value = resultSet.getString(1); 226 227 // 追加の行があるかチェック 228 boolean hasMoreRows = resultSet.next(); 229 if (hasMoreRows) { 230 logger.warn("クエリ結果が複数行あります。先頭行の値を使用します。"); 231 } 232 233 // nullチェック 234 if (value == null) { 235 logger.info("クエリ結果の先頭値がNULLです。"); 236 return ""; // NULLの場合は空文字列を返す 237 } 238 239 // 結果が理想的(1行1列)かどうかをログに記録 240 if (columnCount == 1 && !hasMoreRows) { 241 logger.info("データベースから単一値を取得しました: {}", value); 242 } else { 243 logger.info("データベースから先頭値を取得しました: {}", value); 244 } 245 246 return value; 247 } 248 } catch (SQLException e) { 249 logger.error("データベース操作中にエラーが発生しました: {}", e.getMessage(), e); 250 return "ERROR: " + e.getMessage(); 251 } 252 } 253}