From adbf8b62712c94994eb523735c384cd5f0f49831 Mon Sep 17 00:00:00 2001 From: GUARDiA Date: Sun, 14 Jun 2026 01:07:07 +0900 Subject: [PATCH] =?UTF-8?q?fix(security):=20GlobalExceptionHandler=20SQL/?= =?UTF-8?q?=EC=8A=A4=ED=83=9D=20=EB=85=B8=EC=B6=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mall/common/GlobalExceptionHandler.java | 74 +++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java b/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java index 149cd12..c5cbabc 100644 --- a/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java @@ -1,23 +1,46 @@ package com.zioinfo.mall.common; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.servlet.resource.NoResourceFoundException; +import java.sql.SQLException; + /** * 전역 예외 처리. * - *

보안 불변 규칙: 스택트레이스를 응답에 절대 노출하지 않는다. - * 에러 코드 + 요약 메시지만 반환한다. + *

보안 불변 규칙: 스택트레이스 · SQL문 · 테이블/컬럼명 · DB 드라이버 메시지를 + * 응답에 절대 노출하지 않는다. 클라이언트에는 에러 코드 + 일반 요약 메시지만 반환하고, + * 실제 원인은 서버 로그(logger.error)에만 기록한다. + * + *

업무/검증 예외는 {@code ERR-XXX-NNN:} 형태의 비민감 메시지만 통과시킨다. + * 그 외(미분류 RuntimeException, DataAccessException, SQLException, 일반 Exception)는 + * 메시지를 마스킹하고 일반 코드로만 응답한다. */ @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { + /** + * 안전한 업무/검증 에러 코드 패턴 (예: ERR-MALL-413, ERR-ORD-404, ERR-LOY-400 ...). + * 메시지가 이 코드로 시작하면 사전 정의된 비민감 메시지로 간주하여 그대로 노출한다. + * 그렇지 않은 메시지(SQL/드라이버/내부 원인 가능성)는 응답에서 마스킹한다. + */ + private static final java.util.regex.Pattern SAFE_CODE = + java.util.regex.Pattern.compile("^ERR-[A-Z0-9]+-[A-Z0-9]+\\b.*"); + + /** 사전 정의된 안전 코드 메시지인지 검사. */ + private static boolean isSafeBusinessMessage(String message) { + return message != null && SAFE_CODE.matcher(message).matches(); + } + @ExceptionHandler(MaxUploadSizeExceededException.class) @ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE) public ApiResponse handleMaxSize(MaxUploadSizeExceededException e) { @@ -28,16 +51,53 @@ public class GlobalExceptionHandler { @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResponse handleIllegalArg(IllegalArgumentException e) { - log.warn("잘못된 요청: {}", e.getMessage()); - return ApiResponse.fail(e.getMessage()); + // 사전 정의된 비민감 검증 메시지(ERR-XXX-NNN)만 노출, 그 외는 마스킹 + if (isSafeBusinessMessage(e.getMessage())) { + log.warn("잘못된 요청: {}", e.getMessage()); + return ApiResponse.fail(e.getMessage()); + } + log.warn("잘못된 요청(메시지 마스킹)", e); + return ApiResponse.fail("ERR-MALL-400: 잘못된 요청입니다"); + } + + /** + * DB 접근 오류 — SQL문/테이블·컬럼명/드라이버 메시지가 e.getMessage()에 포함되므로 + * 절대 응답에 노출하지 않는다. 원인은 서버 로그에만 기록. + */ + @ExceptionHandler({DataAccessException.class, SQLException.class}) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleDataAccess(Exception e) { + log.error("데이터 접근 오류", e); + return ApiResponse.fail("ERR-MALL-DB: 요청 처리 중 오류가 발생했습니다"); + } + + /** 권한 부족(메서드 보안 @PreAuthorize 등) — 403 유지, 내부 메시지 미노출. */ + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ApiResponse handleAccessDenied(AccessDeniedException e) { + log.warn("권한 부족: {}", e.getMessage()); + return ApiResponse.fail("ERR-MALL-403: 접근 권한이 없습니다"); + } + + /** 인증 실패 — 401 유지, 내부 메시지 미노출. */ + @ExceptionHandler(AuthenticationException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ApiResponse handleAuthentication(AuthenticationException e) { + log.warn("인증 실패: {}", e.getMessage()); + return ApiResponse.fail("ERR-MALL-401: 인증이 필요합니다"); } @ExceptionHandler(RuntimeException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResponse handleRuntime(RuntimeException e) { - // 스택트레이스 미노출 — 에러 코드/요약만 반환 - log.warn("업무 오류: {}", e.getMessage()); - return ApiResponse.fail(e.getMessage()); + // 사전 정의된 비민감 업무 메시지(ERR-XXX-NNN)만 노출. + // 미분류 런타임 예외는 SQL/드라이버/내부 원인이 섞일 수 있어 마스킹하고 원인은 로그에만 기록. + if (isSafeBusinessMessage(e.getMessage())) { + log.warn("업무 오류: {}", e.getMessage()); + return ApiResponse.fail(e.getMessage()); + } + log.error("미분류 런타임 오류(메시지 마스킹)", e); + return ApiResponse.fail("ERR-MALL-500: 요청 처리 중 오류가 발생했습니다"); } /** 정적 리소스 미존재 — 500 으로 감싸지 않고 깔끔히 404 반환 (SPA 폴백 이후의 실제 미스). */