From d8560d889c6fe8e85838ed367c454687a072508d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sun, 15 Mar 2026 10:20:39 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20[security]=20eval()=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20=E2=80=94=20SafeMathEvaluator=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormulaParser의 eval() 2곳 제거 (executeSimpleMath, evaluateCondition) - FormulaEvaluatorService의 eval() 1곳 제거 (calculateExpression) - Shunting-yard 알고리즘 기반 SafeMathEvaluator 신규 추가 - 사칙연산, 비교연산, 단항 마이너스, 괄호, 나머지 연산 지원 --- app/Helpers/SafeMathEvaluator.php | 310 ++++++++++++++++++ app/Services/Calculation/FormulaParser.php | 7 +- .../Quote/FormulaEvaluatorService.php | 3 +- 3 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 app/Helpers/SafeMathEvaluator.php diff --git a/app/Helpers/SafeMathEvaluator.php b/app/Helpers/SafeMathEvaluator.php new file mode 100644 index 0000000..13136f4 --- /dev/null +++ b/app/Helpers/SafeMathEvaluator.php @@ -0,0 +1,310 @@ + 2, + '-' => 2, + '*' => 3, + '/' => 3, + '%' => 3, + 'UNARY_MINUS' => 4, + ]; + + /** + * 산술 수식을 계산하여 float 반환 + */ + public static function calculate(string $expression): float + { + $expression = trim($expression); + + if ($expression === '') { + return 0; + } + + $tokens = self::tokenize($expression); + + if (empty($tokens)) { + return 0; + } + + $rpn = self::toRPN($tokens); + + return self::evaluateRPN($rpn); + } + + /** + * 비교식을 평가하여 bool 반환 + * 예: "3000 <= 6000", "100 == 100", "5 > 3 && 2 < 4" + */ + public static function compare(string $expression): bool + { + $expression = trim($expression); + + // && 논리 AND 처리 + if (str_contains($expression, '&&')) { + $parts = explode('&&', $expression); + + foreach ($parts as $part) { + if (! self::compare(trim($part))) { + return false; + } + } + + return true; + } + + // || 논리 OR 처리 + if (str_contains($expression, '||')) { + $parts = explode('||', $expression); + + foreach ($parts as $part) { + if (self::compare(trim($part))) { + return true; + } + } + + return false; + } + + // 비교 연산자 추출 (2문자 먼저 검사) + $operators = ['>=', '<=', '!=', '==', '>', '<']; + + foreach ($operators as $op) { + $pos = strpos($expression, $op); + if ($pos !== false) { + $left = self::calculate(substr($expression, 0, $pos)); + $right = self::calculate(substr($expression, $pos + strlen($op))); + + return match ($op) { + '>=' => $left >= $right, + '<=' => $left <= $right, + '!=' => $left != $right, + '==' => $left == $right, + '>' => $left > $right, + '<' => $left < $right, + }; + } + } + + // 비교 연산자가 없으면 수치를 boolean으로 평가 + return (bool) self::calculate($expression); + } + + /** + * 수식 문자열을 토큰 배열로 분리 + */ + private static function tokenize(string $expression): array + { + $tokens = []; + $len = strlen($expression); + $i = 0; + + while ($i < $len) { + $char = $expression[$i]; + + // 공백 건너뛰기 + if ($char === ' ' || $char === "\t") { + $i++; + + continue; + } + + // 숫자 (정수, 소수) + if (is_numeric($char) || ($char === '.' && $i + 1 < $len && is_numeric($expression[$i + 1]))) { + $num = ''; + while ($i < $len && (is_numeric($expression[$i]) || $expression[$i] === '.')) { + $num .= $expression[$i]; + $i++; + } + $tokens[] = ['type' => 'number', 'value' => (float) $num]; + + continue; + } + + // 괄호 + if ($char === '(') { + $tokens[] = ['type' => 'lparen']; + $i++; + + continue; + } + + if ($char === ')') { + $tokens[] = ['type' => 'rparen']; + $i++; + + continue; + } + + // 연산자 + if (in_array($char, self::OPERATORS)) { + // 단항 마이너스 판별: 맨 앞이거나, 앞이 연산자 또는 여는 괄호인 경우 + if ($char === '-') { + $isUnary = empty($tokens) + || $tokens[count($tokens) - 1]['type'] === 'operator' + || $tokens[count($tokens) - 1]['type'] === 'lparen'; + + if ($isUnary) { + $tokens[] = ['type' => 'operator', 'value' => 'UNARY_MINUS']; + $i++; + + continue; + } + } + + $tokens[] = ['type' => 'operator', 'value' => $char]; + $i++; + + continue; + } + + throw new InvalidArgumentException("허용되지 않는 문자: '{$char}' (위치 {$i})"); + } + + return $tokens; + } + + /** + * 중위 표기법 토큰 → 후위 표기법(RPN) 변환 (Shunting-yard) + */ + private static function toRPN(array $tokens): array + { + $output = []; + $operatorStack = []; + + foreach ($tokens as $token) { + if ($token['type'] === 'number') { + $output[] = $token; + + continue; + } + + if ($token['type'] === 'operator') { + $op = $token['value']; + $prec = self::PRECEDENCE[$op] ?? 0; + + while (! empty($operatorStack)) { + $top = end($operatorStack); + if ($top['type'] === 'lparen') { + break; + } + $topPrec = self::PRECEDENCE[$top['value']] ?? 0; + + // 단항 연산자는 오른쪽 결합 + if ($op === 'UNARY_MINUS') { + if ($topPrec > $prec) { + $output[] = array_pop($operatorStack); + } else { + break; + } + } else { + // 이항 연산자는 왼쪽 결합 (같은 우선순위면 먼저 pop) + if ($topPrec >= $prec) { + $output[] = array_pop($operatorStack); + } else { + break; + } + } + } + + $operatorStack[] = $token; + + continue; + } + + if ($token['type'] === 'lparen') { + $operatorStack[] = $token; + + continue; + } + + if ($token['type'] === 'rparen') { + while (! empty($operatorStack) && end($operatorStack)['type'] !== 'lparen') { + $output[] = array_pop($operatorStack); + } + if (empty($operatorStack)) { + throw new InvalidArgumentException('괄호 불일치: 여는 괄호 없음'); + } + array_pop($operatorStack); // 여는 괄호 제거 + + continue; + } + } + + while (! empty($operatorStack)) { + $top = array_pop($operatorStack); + if ($top['type'] === 'lparen') { + throw new InvalidArgumentException('괄호 불일치: 닫는 괄호 없음'); + } + $output[] = $top; + } + + return $output; + } + + /** + * 후위 표기법(RPN) 계산 + */ + private static function evaluateRPN(array $rpn): float + { + $stack = []; + + foreach ($rpn as $token) { + if ($token['type'] === 'number') { + $stack[] = $token['value']; + + continue; + } + + if ($token['type'] === 'operator') { + $op = $token['value']; + + // 단항 마이너스 + if ($op === 'UNARY_MINUS') { + if (empty($stack)) { + throw new InvalidArgumentException('수식 오류: 단항 마이너스 피연산자 없음'); + } + $stack[] = -array_pop($stack); + + continue; + } + + // 이항 연산자 + if (count($stack) < 2) { + throw new InvalidArgumentException('수식 오류: 피연산자 부족'); + } + + $right = array_pop($stack); + $left = array_pop($stack); + + $stack[] = match ($op) { + '+' => $left + $right, + '-' => $left - $right, + '*' => $left * $right, + '/' => $right != 0 ? $left / $right : throw new InvalidArgumentException('0으로 나눌 수 없음'), + '%' => $right != 0 ? fmod($left, $right) : throw new InvalidArgumentException('0으로 나눌 수 없음'), + default => throw new InvalidArgumentException("알 수 없는 연산자: {$op}"), + }; + } + } + + if (count($stack) !== 1) { + throw new InvalidArgumentException('수식 오류: 결과가 하나가 아님'); + } + + return (float) $stack[0]; + } +} diff --git a/app/Services/Calculation/FormulaParser.php b/app/Services/Calculation/FormulaParser.php index 9fd0a23..78fe3df 100644 --- a/app/Services/Calculation/FormulaParser.php +++ b/app/Services/Calculation/FormulaParser.php @@ -2,6 +2,7 @@ namespace App\Services\Calculation; +use App\Helpers\SafeMathEvaluator; use Illuminate\Support\Facades\Log; class FormulaParser @@ -230,8 +231,8 @@ protected function executeSimpleMath(string $formula, array $variables): float throw new \InvalidArgumentException("안전하지 않은 수학 표현식: {$expression}"); } - // 계산 실행 - return eval("return {$expression};"); + // 안전한 산술 파서로 계산 실행 + return SafeMathEvaluator::calculate($expression); } /** @@ -276,7 +277,7 @@ protected function evaluateCondition(string $condition, array $variables): bool throw new \InvalidArgumentException("안전하지 않은 조건식: {$expression}"); } - return eval("return {$expression};"); + return SafeMathEvaluator::compare($expression); } /** diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 0659156..853688b 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -312,8 +312,7 @@ private function calculateExpression(string $expression): float } try { - // TODO: 프로덕션에서는 symfony/expression-language 등 안전한 라이브러리 사용 권장 - return (float) eval("return {$expression};"); + return \App\Helpers\SafeMathEvaluator::calculate($expression); } catch (\Throwable $e) { $this->errors[] = __('error.formula_calculation_error', ['expression' => $expression]);