크로스 사이트 스크립팅(XSS) 방지 방법: 사례를 통해 알아보는 안전한 웹 애플리케이션 개발

신수정·2024년 5월 11일
0

실제 코드를 통해 크로스 사이트 스크립팅(XSS) 공격을 방지하는 방법을 살펴보겠습니다. 구현한 BoardAPIController 코드를 통해 각 메서드에서 어떻게 XSS 공격을 막았는지 구체적으로 알아보겠습니다.

XSS 방지를 위한 BoardAPIController 소개

BoardAPIController는 게시글을 관리하기 위한 REST API 컨트롤러로, 게시글 생성, 수정, 삭제 기능을 제공합니다. 여기서 중요한 것은 모든 입력 데이터를 검증하고, 클라이언트 측 스크립트 삽입을 막는 것이죠. 우선 코드를 살펴보겠습니다.

package com.moonBam.controller.board;

import com.moonBam.dto.MemberDTO;
import com.moonBam.dto.board.PostDTO;
import com.moonBam.dto.board.PostPageDTO;
import com.moonBam.dto.board.PostUpdateRequestDTO;
import com.moonBam.service.PostService;
import com.moonBam.service.ScrapService;
import com.moonBam.service.member.MemberLoginService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.security.Principal;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/post")
public class BoardAPIController {

    private final PostService postService;
    private final MemberLoginService memberLoginService;
    private final ScrapService scrapService;

    @PostMapping
    public ResponseEntity<?> create(@RequestBody @Validated PostDTO postDTO,
                                    BindingResult bindingResult, Principal principal) {

        if (bindingResult.hasErrors()) {
            Map<String, String> errors = getErrors(bindingResult);
            String s = errors.values().stream().toList().get(0);
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body(Map.of("message", s, "errors", errors));
        }

        // XSS 방지를 위해 입력값에서 스크립트 태그를 제거하는 로직 추가
        String sanitizedPostText = sanitizeHtml(postDTO.getPostText());
        String sanitizedPostTitle = sanitizeHtml(postDTO.getPostTitle());
        postDTO.setPostText(sanitizedPostText);
        postDTO.setPostTitle(sanitizedPostTitle);

        MemberDTO loginUser = memberLoginService.findByPrincipal(principal);

        if (loginUser == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "로그인이 필요한 서비스입니다."));
        }

        postDTO.setUserId(loginUser.getUserId());
        postDTO.setNickname(loginUser.getNickname());
        Long postID = postService.save(postDTO);
        return ResponseEntity.ok(Map.of("postID", postID)); // 동적으로 ajax success function에서 redirect 시킬 용도
    }

    @PatchMapping("/{postId}")
    public ResponseEntity<?> update(@RequestBody @Validated PostUpdateRequestDTO postUpdateRequestDTO, @PathVariable("postId") Long postId,
                                    BindingResult bindingResult, Principal principal) {

        PostDTO postDTO = postService.findById(postId);

        if (bindingResult.hasErrors()) {
            Map<String, String> errors = getErrors(bindingResult);
            String s = errors.values().stream().toList().get(0);
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body(Map.of("message", s, "errors", errors));
        }

        // XSS 방지를 위해 입력값에서 스크립트 태그를 제거하는 로직 추가
        String sanitizedPostText = sanitizeHtml(postUpdateRequestDTO.getPostText());
        postUpdateRequestDTO.setPostText(sanitizedPostText);
        postUpdateRequestDTO.setPostTitle(sanitizeHtml(postUpdateRequestDTO.getPostTitle()));

        MemberDTO loginUser = memberLoginService.findByPrincipal(principal);

        if (loginUser == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "로그인이 필요한 서비스입니다."));
        }

        if (!loginUser.getUserId().equals(postDTO.getUserId())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "글을 수정할 권한이 없습니다."));
        }

        postService.update(postUpdateRequestDTO);

        return ResponseEntity.ok(Map.of("postID", postId));
    }

    @DeleteMapping("{postId}")
    public ResponseEntity<?> delete(@PathVariable("postId") Long postId, Principal principal) {
        PostDTO postDTO = postService.findById(postId);
        MemberDTO loginUser = memberLoginService.findByPrincipal(principal);

        if (loginUser == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "로그인이 필요한 서비스입니다."));
        }

        if (postDTO == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "글이 존재하지않습니다."));
        }

        if (!loginUser.getUserId().equals(postDTO.getUserId())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "글을 삭제할 권한이 없습니다."));
        }

        scrapService.findAllByPostId(postId).forEach((scrapDTO -> scrapService.delete(scrapDTO.getScrapId())));

        postService.delete(postId);

        return ResponseEntity.ok(Map.of("message", "삭제가 완료되었습니다."));
    }

    @GetMapping("/{postId}")
    public ResponseEntity<?> findOne(@PathVariable("postId") Long postId, Principal principal){
        PostPageDTO pDTO = postService.selectPagePost(postId);

        if (postId == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "존재하지 않는 게시글입니다."));
        }

        MemberDTO loginUser = memberLoginService.findByPrincipal(principal);
        boolean isAuthorized = false;
        if (loginUser != null) {
            isAuthorized = loginUser.getUserId().equals(pDTO.getUserId());
        }
        return ResponseEntity.ok(Map.of("pDTO", pDTO, "isAuthorized", isAuthorized));
    }

    private Map<String, String> getErrors(BindingResult bindingResult) {
        return bindingResult.getFieldErrors().stream()
                .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
    }

    private String sanitizeHtml(String input) {
        return input.replaceAll("(?i)<script.*?>.*?</script>", "") // 스크립트 태그 제거
                .replaceAll("(?i)<.*?javascript:.*?>.*?</.*?>", "") // "javascript:" URI 사용 제거
                .replaceAll("(?i)<.*?\\bon.*?>.*?</.*?>", ""); // 이벤트 핸들러 제거 (예: onclick)
    }
}

XSS 방지 구현 방법 상세 설명

BoardAPIController는 XSS 공격을 방지하기 위해 입력값을 처리하는 여러 가지 로직을 포함하고 있습니다.

1. 스크립트 태그 제거

가장 기본적인 XSS 방지 기법은 <script> 태그를 제거하는 것입니다. 이를 위해 정규 표현식을 활용하여 <script> 태그를 감지하고 제거합니다.

input.replaceAll("(?i)<script.*?>.*?</script>", "")
  • (?i): 대소문자 무시
  • <script.*?>: <script> 태그를 열고, 속성이 있을 수 있으므로 .*?로 처리
  • .*?</script>: <script> 태그 사이의 모든 내용을 매칭하고, 닫는 </script> 태그까지 감지
2. "javascript:" URI 사용 제거

HTML 태그의 href 속성 등에서 javascript:를 통해 악성 코드를 실행할 수 있으므로 이를 제거합니다.

input.replaceAll("(?i)<.*?javascript:.*?>.*?</.*?>", "")
  • <.*?javascript:.*?>: HTML 태그 속성에서 javascript: URI를 감지
  • .*?</.*?>: 태그 내의 내용을 감지하고 태그 닫기
3. 이벤트 핸들러 제거 (예: onclick)

태그에 onclick 같은 이벤트 핸들러를 이용해 악성 코드를 실행할 수 있습니다. 이를 제거하기 위해 \\bon 패턴을 사용합니다.

input.replaceAll("(?i)<.*?\\bon.*?>.*?</.*?>", "")
  • \\bon: 이벤트 핸들러 속성(on...)을 감지
  • .*?>: HTML 태그의 끝까지 감지
  • .*?</.*?>: 태그 내의 내용을 감지하고 태그 닫기

마치며

이번 포스팅에서는 BoardAPIController의 코드를 통해 크로스 사이트 스크립팅(XSS) 공격을 방지하는 방법을 살펴보았습니다. 실제 구현을 통해 알 수 있듯이, 각 입력 데이터마다 철저한 검증과 이스케이핑을 적용하여야만 안전한 웹 애플리케이션을 구축할 수 있습니다.

참고 자료

profile
안녕하세요:)

0개의 댓글