All files / src/protocol ProtocolValidator.ts

70.45% Statements 31/44
72.72% Branches 32/44
80% Functions 4/5
70.45% Lines 31/44

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188                                  19x     19x                                       65x 1x 1x             64x 42x     42x 7x 7x 7x                   35x     22x                   22x   22x 1x 1x           21x   21x 4x 6x                   17x   20x                 20x                               8x                   8x 8x                                         2x               2x                 35x           35x    
import { EngineError } from "../errors/EngineError.js";
import {
  EngineErrorCode,
  Move,
  PositionString,
  PositionId,
  I18nKey,
} from "../types.js";
import { truncateLog } from "../utils/Sanitizer.js";
 
/**
 * プロトコルレベルのセキュリティバリデーションを提供するユーティリティ。
 */
export class ProtocolValidator {
  // 2026 Best Practice: 正規表現の事前コンパイルによる高速化
  // STRICT: 改行、ヌル、セミコロン、および主要な ASCII 制御文字を拒否
  // eslint-disable-next-line no-control-regex
  private static readonly STRICT_REGEX = /[\r\n\0;\x01-\x1f\x7f]/;
  // LOOSE: GTP/SGF 用にセミコロンを許可
  // eslint-disable-next-line no-control-regex
  private static readonly LOOSE_REGEX = /[\r\n\0\x01-\x1f\x7f]/;
 
  /**
   * 入力文字列(またはオブジェクト内の全文字列値)に制御文字が含まれていないか検証します。
   * @param input - 検証対象の文字列またはオブジェクト
   * @param context - エラーメッセージに使用するコンテキスト名
   * @param recursive - オブジェクトや配列を再帰的に走査するかどうか
   * @param allowSemicolon - セミコロンを許可するかどうか (GTP/SGF 用)
   * @param depth - 現在の再帰深度 (内部用)
   * @param visited - 循環参照検知用のセット (内部用)
   */
  static assertNoInjection(
    input: unknown,
    context: string,
    recursive = false,
    allowSemicolon = false,
    depth = 0,
    visited: WeakSet<object> = new WeakSet(),
  ): void {
    // 2026: 防止: 無限再帰や深すぎるネストによるスタックオーバーフロー
    if (depth > 10) {
      const i18nKey = createI18nKey("engine.errors.nestedTooDeep");
      throw new EngineError({
        code: EngineErrorCode.SECURITY_ERROR,
        message: `Input nesting too deep in ${context}.`,
        i18nKey,
      });
    }
 
    if (typeof input === "string") {
      const regex = allowSemicolon
        ? ProtocolValidator.LOOSE_REGEX
        : ProtocolValidator.STRICT_REGEX;
      if (regex.test(input)) {
        const i18nKey = createI18nKey("engine.errors.injectionDetected");
        const i18nParams = { context, input: truncateLog(input) };
        throw new EngineError({
          code: EngineErrorCode.SECURITY_ERROR,
          message: `Potential command injection detected in ${context}: "${truncateLog(input)}".`,
          i18nKey,
          i18nParams,
          remediation: allowSemicolon
            ? "Remove control characters (\\r, \\n, \\0, etc.) from input."
            : "Remove control characters (\\r, \\n, \\0, ;, etc.) from input.",
        });
      }
      return;
    }
 
    Iif (!recursive && input !== undefined && input !== null) {
      // 再帰が無効な場合、文字列以外の入力は原則として拒否(インジェクション対策)
      const i18nKey = createI18nKey("engine.errors.illegalCharacters");
      throw new EngineError({
        code: EngineErrorCode.SECURITY_ERROR,
        message: `Invalid non-string input detected in ${context}.`,
        i18nKey,
      });
    }
 
    Eif (recursive && typeof input === "object" && input !== null) {
      // 2026: 循環参照チェック (Zenith 6.4)
      if (visited.has(input)) {
        const i18nKey = createI18nKey("engine.errors.nestedTooDeep");
        throw new EngineError({
          code: EngineErrorCode.SECURITY_ERROR,
          message: `Circular reference detected in ${context}.`,
          i18nKey,
        });
      }
      visited.add(input);
 
      if (Array.isArray(input)) {
        for (const item of input) {
          ProtocolValidator.assertNoInjection(
            item,
            context,
            true,
            allowSemicolon,
            depth + 1,
            visited,
          );
        }
      } else {
        for (const [key, value] of Object.entries(input)) {
          // キー自体もインジェクションチェックの対象とする
          ProtocolValidator.assertNoInjection(
            key,
            `${context} key`,
            false,
            allowSemicolon,
            depth + 1,
            visited,
          );
          // 値を再帰的にチェック
          ProtocolValidator.assertNoInjection(
            value,
            context,
            true,
            allowSemicolon,
            depth + 1,
            visited,
          );
        }
      }
    }
  }
}
 
/** 汎用指し手バリデータ (2026 Zenith Tier: Refuse by Exception) */
export function createMove<T extends string = string>(move: string): Move<T> {
  Iif (typeof move !== "string" || !/^[a-z0-9+*#=/\- ()]+$/i.test(move)) {
    const i18nKey = createI18nKey("engine.errors.invalidMoveFormat");
    const i18nParams = { move: truncateLog(move) };
    throw new EngineError({
      code: EngineErrorCode.SECURITY_ERROR,
      message: `Invalid Move format: "${truncateLog(move)}" contains illegal characters.`,
      i18nKey,
      i18nParams,
    });
  }
  ProtocolValidator.assertNoInjection(move, "Move");
  return move as Move<T>;
}
 
/** 汎用局面バリデータ (2026 Zenith Tier: Refuse by Exception) */
export function createPositionString<T extends string = string>(
  pos: string,
): PositionString<T> {
  if (typeof pos !== "string" || pos.trim().length === 0) {
    const i18nKey = createI18nKey("engine.errors.invalidPositionString");
    throw new EngineError({
      code: EngineErrorCode.SECURITY_ERROR,
      message: "Invalid PositionString: Input must be a non-empty string.",
      i18nKey,
    });
  }
  ProtocolValidator.assertNoInjection(pos, "Position");
  return pos as PositionString<T>;
}
 
/** 局面 ID バリデータ (2026 Zenith Tier: Refuse by Exception) */
export function createPositionId(id: string): PositionId {
  Iif (typeof id !== "string" || !/^[a-zA-Z0-9-_.:]+$/.test(id)) {
    const i18nKey = createI18nKey("engine.errors.invalidPositionId");
    throw new EngineError({
      code: EngineErrorCode.VALIDATION_ERROR,
      message: `Invalid PositionId format: "${truncateLog(id)}".`,
      i18nKey,
    });
  }
  return id as PositionId;
}
 
/**
 * 国際化キー (I18nKey) を生成するためのファクトリ。
 * 直接の型キャストを避け、この関数を経由することで安全性を担保します。
 * (2026 Zenith Tier: Branded Type Validation)
 */
export function createI18nKey(key: string): I18nKey {
  Iif (typeof key !== "string" || key.trim() === "") {
    throw new EngineError({
      code: EngineErrorCode.VALIDATION_ERROR,
      message: `Invalid I18nKey format: "${truncateLog(key)}".`,
    });
  }
  return key as I18nKey;
}