이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Input validation과 output encoding은 왜 서로 대체할 수 없는가?
  • Java/Spring API에서 입력 검증은 어느 계층에서 어떤 기준으로 수행해야 하는가?
  • 저장된 값이 HTML, JSON, URL, JavaScript 문맥으로 나갈 때 어떤 위험이 달라지는가?

개요

Input validation은 서버가 받아 처리할 수 있는 값의 범위를 제한하는 일이다. 타입, 길이, 형식, enum, 업무 규칙을 통해 “이 값은 이 API에서 처리 가능한가”를 판단한다.

Output encoding은 값이 응답이나 화면에 나갈 때 코드로 해석되지 않게 표현을 바꾸는 일이다. 같은 문자열이라도 HTML 본문, HTML attribute, JavaScript 문자열, URL parameter, JSON 값에 놓일 때 필요한 encoding이 다르다.

둘은 같은 방어가 아니다. validation을 통과한 닉네임도 HTML 화면에서 escape 없이 출력하면 XSS가 될 수 있고, output encoding이 있더라도 잘못된 금액이나 권한 필드를 입력 단계에서 받아서는 안 된다.

이 문서의 핵심은 “입력에서 줄이고 출력에서 문맥에 맞게 무력화한다”는 분리다.

공격 시나리오

  • 공격자는 profile, comment, search, redirect, file name, display name처럼 다시 출력되는 입력을 찾는다.
  • 서버 validation이 느슨하면 매우 긴 값, 제어 문자, 잘못된 enum, 업무적으로 불가능한 값을 저장시킨다.
  • 화면 출력이 escape되지 않으면 <img src=x onerror=alert(1)> 같은 값이 브라우저에서 코드로 실행된다.
  • JSON API만 있다고 생각해도 관리자 콘솔, 알림 메일, CSV export, 로그 뷰어에서 같은 값이 다른 문맥으로 출력될 수 있다.
  • blacklist 기반 필터는 encoding 우회, 대소문자 변형, 다른 출력 문맥에서 쉽게 깨진다.
  • 피해는 XSS, open redirect, 로그 오염, 검색 장애, 비용 증가, 업무 데이터 무결성 훼손으로 이어진다.

취약한 요청/응답 예시

PATCH /api/profile HTTP/1.1
Host: api.example.com
Cookie: SESSION=valid
Content-Type: application/json
 
{"displayName":"<img src=x onerror=fetch('/api/me').then(r=>r.text()).then(console.log)>","age":-1}
@PatchMapping("/api/profile")
public void update(@RequestBody Map<String, Object> body, Authentication authentication) {
    profileService.updateDisplayName(authentication.getName(), (String) body.get("displayName"));
}
<span class="display-name" th:utext="${profile.displayName}"></span>

입력 단계에서 길이와 허용 문자 검증이 없고, 출력 단계에서 HTML을 그대로 렌더링한다. age처럼 업무적으로 불가능한 값도 같은 경계에서 놓치기 쉽다.

주제별 핵심 판단

  • validation은 syntactic validation과 semantic validation을 나눠 본다.
  • syntactic validation은 타입, 길이, 형식, charset, enum처럼 값의 모양을 제한한다.
  • semantic validation은 시작일이 종료일보다 빠른지, 수량이 재고보다 작거나 같은지처럼 업무 의미를 제한한다.
  • allowlist가 기본이고 blacklist는 보조 수단이다.
  • output encoding은 저장 직전이 아니라 출력 직전에 문맥별로 수행한다.
  • HTML 허용 입력이 정말 필요하면 encoding이 아니라 sanitizer와 허용 tag 정책이 별도로 필요하다.
  • 검증 실패 응답은 사용자가 고칠 수 있을 만큼 알려 주되 내부 정규식, 정책 상세, stack trace를 노출하지 않는다.

개선된 요청/응답 예시

public record ProfileUpdateRequest(
    @NotBlank
    @Size(max = 30)
    @Pattern(regexp = "^[가-힣a-zA-Z0-9 _.-]+$")
    String displayName,
 
    @Min(14)
    @Max(120)
    int age
) {}
@PatchMapping("/api/profile")
public ResponseEntity<Void> update(@Valid @RequestBody ProfileUpdateRequest request,
                                   Authentication authentication) {
    profileService.updateProfile(authentication.getName(), request.displayName(), request.age());
    return ResponseEntity.noContent().build();
}
<span class="display-name" th:text="${profile.displayName}"></span>

DTO는 API가 받을 수 있는 필드를 제한하고 Bean Validation은 값의 범위를 줄인다. HTML 출력에서는 th:text처럼 기본 escape가 적용되는 출력을 사용한다.

방어 설계

  • API 계약에 필드별 타입, 길이, 형식, enum, nullable 여부를 명시한다.
  • Controller DTO에서 1차 형식 검증을 수행한다.
  • Service에서 업무 의미 검증을 수행한다.
  • Repository와 외부 API 호출 전에 canonical form으로 정리된 값을 사용한다.
  • 출력 계층은 저장값의 신뢰 여부와 무관하게 문맥별 encoding을 적용한다.
  • HTML 본문, attribute, JavaScript, CSS, URL은 서로 다른 출력 문맥으로 취급한다.
  • log, metric label, trace attribute도 출력이다. 제어 문자와 긴 payload를 그대로 넣지 않는다.
  • validation 실패와 output encoding 누락은 서로 다른 테스트로 검증한다.

Spring/HTTP 예시

@RestControllerAdvice
public class ValidationExceptionHandler {
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ApiError> handle(MethodArgumentNotValidException ex) {
        List<String> fields = ex.getBindingResult().getFieldErrors().stream()
            .map(FieldError::getField)
            .distinct()
            .toList();
 
        return ResponseEntity.badRequest().body(
            new ApiError("INVALID_REQUEST", "요청 값을 확인해 주세요.", fields)
        );
    }
}
HTTP/1.1 400 Bad Request
Content-Type: application/json
 
{"code":"INVALID_REQUEST","message":"요청 값을 확인해 주세요.","fields":["displayName","age"]}

응답은 고칠 필드를 알려 주지만 내부 정규식이나 validator class 이름을 드러내지 않는다.

운영 로그와 감사 지점

level=WARN event=input_validation_failed result=blocked principal=member:1004 endpoint=PATCH /api/profile fields=displayName,age reason=constraint_violation traceId=31d9a7c2
level=WARN event=unsafe_output_context_detected result=review_required template=profile.html sink=th:utext field=displayName traceId=31d9a7c2
  • validation 실패 로그에는 원문 payload보다 field, reason, constraint group을 남긴다.
  • 공격 문자열이 로그 뷰어에서 실행되지 않도록 로그 출력도 escape한다.
  • 같은 계정의 반복 validation 실패는 abuse 탐지 신호가 될 수 있다.
  • template에서 raw HTML sink를 쓰는 지점을 정적 분석이나 리뷰 대상으로 둔다.
  • CSV, Excel, 메일, push 알림 출력도 별도 sink로 추적한다.

실전 판단 기준

  • Map<String, Object>로 요청을 받으면 API 계약이 코드에 남지 않는다.
  • @Size만 있고 업무 검증이 없으면 음수 금액, 과거 예약, 권한 없는 상태 전이가 통과할 수 있다.
  • blacklist 정규식으로 <script>만 막는 설계는 다른 tag, event handler, encoding 변형에 약하다.
  • DB에 저장된 값이라고 해서 안전한 값이 되는 것은 아니다.
  • 출력 encoding을 service에서 미리 해 두면 나중에 다른 문맥에서 이중 encoding 또는 누락이 생긴다.
  • 검증 실패 메시지가 지나치게 자세하면 공격자에게 정책을 알려 주는 문서가 된다.

테스트 포인트

  • 필수값 누락, 너무 긴 문자열, 잘못된 enum, 음수, 최대값 초과를 모두 테스트한다.
  • 업무 의미가 깨지는 값, 예를 들어 시작일 이후 종료일, 재고 초과 수량을 테스트한다.
  • <script>, <img onerror>, 따옴표, URL encoded payload가 화면에서 텍스트로만 보이는지 확인한다.
  • 같은 저장값이 HTML, JSON, CSV, 메일에 나갈 때 각 문맥에서 안전한지 확인한다.
  • validation 실패 응답에 내부 정규식, class name, stack trace가 없는지 확인한다.
  • 로그 검색 화면에서 payload가 실행되지 않는지 확인한다.

위험 신호!

  • 요청 DTO 대신 Map이나 entity를 직접 받는다.
  • allowlist 없이 blacklist 필터로 공격 문자열만 제거한다.
  • validation을 통과한 값은 출력 encoding이 필요 없다고 가정한다.
  • th:utext, innerHTML, raw template helper가 사용자 입력을 출력한다.
  • 검증 실패 응답에 validator class, 정규식 전체, 내부 정책명이 노출된다.
  • 로그나 metric label에 사용자 입력 원문을 그대로 넣는다.

확인 질문

확인 질문

  • input validation과 output encoding은 왜 분리해야 하는가?
    • validation은 입력 범위를 줄이고 encoding은 출력 문맥에서 코드 실행을 막기 때문이다.
  • semantic validation의 예시는 무엇인가?
    • 날짜 순서, 재고 수량, 권한 있는 상태 전이, 가격 범위처럼 업무 의미가 맞는지 확인하는 검증이다.
  • 저장된 값은 언제 안전해지는가?
    • 저장되었다고 안전해지는 것이 아니다. 출력되는 문맥에 맞게 encoding되거나 sanitizer 정책을 통과해야 안전하다.

참고 문서