실제 코드를 통해 크로스 사이트 스크립팅(XSS) 공격을 방지하는 방법을 살펴보겠습니다. 구현한 BoardAPIController
코드를 통해 각 메서드에서 어떻게 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)
}
}
BoardAPIController
는 XSS 공격을 방지하기 위해 입력값을 처리하는 여러 가지 로직을 포함하고 있습니다.
가장 기본적인 XSS 방지 기법은 <script>
태그를 제거하는 것입니다. 이를 위해 정규 표현식을 활용하여 <script>
태그를 감지하고 제거합니다.
input.replaceAll("(?i)<script.*?>.*?</script>", "")
(?i)
: 대소문자 무시<script.*?>
: <script>
태그를 열고, 속성이 있을 수 있으므로 .*?
로 처리.*?</script>
: <script>
태그 사이의 모든 내용을 매칭하고, 닫는 </script>
태그까지 감지HTML 태그의 href
속성 등에서 javascript:
를 통해 악성 코드를 실행할 수 있으므로 이를 제거합니다.
input.replaceAll("(?i)<.*?javascript:.*?>.*?</.*?>", "")
<.*?javascript:.*?>
: HTML 태그 속성에서 javascript:
URI를 감지.*?</.*?>
: 태그 내의 내용을 감지하고 태그 닫기onclick
)태그에 onclick
같은 이벤트 핸들러를 이용해 악성 코드를 실행할 수 있습니다. 이를 제거하기 위해 \\bon
패턴을 사용합니다.
input.replaceAll("(?i)<.*?\\bon.*?>.*?</.*?>", "")
\\bon
: 이벤트 핸들러 속성(on...
)을 감지.*?>
: HTML 태그의 끝까지 감지.*?</.*?>
: 태그 내의 내용을 감지하고 태그 닫기이번 포스팅에서는 BoardAPIController
의 코드를 통해 크로스 사이트 스크립팅(XSS) 공격을 방지하는 방법을 살펴보았습니다. 실제 구현을 통해 알 수 있듯이, 각 입력 데이터마다 철저한 검증과 이스케이핑을 적용하여야만 안전한 웹 애플리케이션을 구축할 수 있습니다.
참고 자료