fix: [security] eval() 제거 — SafeMathEvaluator로 교체
- FormulaParser의 eval() 2곳 제거 (executeSimpleMath, evaluateCondition) - FormulaEvaluatorService의 eval() 1곳 제거 (calculateExpression) - Shunting-yard 알고리즘 기반 SafeMathEvaluator 신규 추가 - 사칙연산, 비교연산, 단항 마이너스, 괄호, 나머지 연산 지원
This commit is contained in:
310
app/Helpers/SafeMathEvaluator.php
Normal file
310
app/Helpers/SafeMathEvaluator.php
Normal file
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* eval() 없이 산술 수식을 안전하게 계산하는 평가기
|
||||
*
|
||||
* Shunting-yard 알고리즘으로 중위 표기법 → 후위 표기법(RPN) 변환 후 계산
|
||||
* 지원: 숫자, +, -, *, /, %, (, ), 단항 마이너스
|
||||
*/
|
||||
class SafeMathEvaluator
|
||||
{
|
||||
private const OPERATORS = ['+', '-', '*', '/', '%'];
|
||||
|
||||
private const PRECEDENCE = [
|
||||
'+' => 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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user