개주 훈련일지/🔥 히노카미 코구라(오류 수정)

오류내역) CKEditor 이미지가 안 뜨는 이유: XSS 방어(HtmlSanitizer) 문제

lshfood2 2026. 2. 23. 18:44

[ CKEditor 이미지 로딩 실패 ]

좌 : 뉴스 본문 이미지 삽입 불가 / 우 : 게시글 작성 본문 이미지 삽입 불가

 

오류 상황

뉴스/게시글의 작성/수정 페이지에서 CKEditor로

본문을 작성할 때 텍스트는 정상 저장되는데,

이미지 첨부만 화면에 전혀 출력되지 않는 문제가 발생했다.

 

처음에는 'CKEditor 업로드 자체가 실패한 건가?'를 의심했다.
그런데 확인해보니 업로드 요청 자체는 되고 있었고,

DB에도 본문 HTML이 저장되고 있었다.

 

문제는 저장된 본문을 직접 확인했을 때 드러났다.

DB에 명확히 보이는 본문 HTML문

<figure class='image'><img></figure><p>엥?</p><p>ㅋㅋㅋㅋㅋㅋㅋㅋㅋ</p>

즉, 'img 태그는 살아있는데 src 속성만 사라진 상태'였다.

이 상태면 브라우저는 어떤 이미지를 불러와야 하는지

알 수 없어서 화면에 이미지가 출력되지 않는다.


[ 가설 수립: 'CKEditor 업로드 실패'가 아니라 '저장 전에 src가 지워진다' ]

증상을 보면 핵심 포인트는 하나였다.

  • CKEditor 에디터에는 이미지가 들어가는 것처럼 보임
  • DB에는 <img> 태그가 저장됨
  • 그런데 src 속성만 없음

이 흐름이면 '에디터 문제'보다는

'서버에서 저장 직전 본문을 정리하는 과정'에서

속성이 제거됐을 가능성이 높다.

 

프로젝트에서 게시글/뉴스 본문은

XSS 방어를 위해 HtmlSanitizer를 거치고 있었기 때문에,

원인을 이쪽으로 좁혀서 확인했다.


[ 가설 검증: HtmlSanitizer 확인 ]

실제 HtmlSanitizer 설정을 보면

리치 텍스트용 화이트리스트(Safelist)를 사용하고 있었고,

img 태그의 src 프로토콜 허용 정책이 걸려 있었다.

 

핵심은 여기였다.

프로젝트는 CKEditor 이미지 경로를
내부 상대경로 형태로 사용함
  • 예: /upload/..., /images/...

그런데 Sanitizer 쪽 프로토콜 허용 정책이

상대경로를 통과시키지 못하면서 'img src'를 제거함

→ 결과적으로 태그만 남고 'src'가 비는 형태로 DB에 저장

 

그래서 화면에서는 이미지가 안 뜬다.

 

정리하면 이번 문제는 'CKEditor 업로드 실패'가 아니라

'XSS 방어 로직이 내부 이미지 URL을

안전하지 않은 값으로 판단해서 제거한 것'이었다.


[ 원인 분석 ]

이번 케이스를 한 줄로 정리하면 이렇다.

'CKEditor가 생성한 이미지 HTML은 맞았지만,

저장 전에 HtmlSanitizer가 img src를 제거했다.'

 

왜 이런 일이 생겼는지 구조를 보면 이해가 쉽다.

  1. 사용자가 CKEditor에서 이미지 첨부
  2. CKEditor가 서버 업로드 후 본문에 <img src='/...'> 삽입
  3. 서버 저장 전 HtmlSanitizer.sanitizeBoardHtml()
    / sanitizeNewsHtml() 실행
  4. Sanitizer 정책에 의해 src 제거
  5. DB에는 <img>만 저장
  6. 화면 출력 시 이미지 미표시

즉, 문제 지점은 '출력 단계'가 아니라 '저장 단계'였다.


[ 해결 방법 ]

해결 방향은 단순하다.

핵심은 '리치 텍스트 본문에서 허용할 HTML 태그'와
'img/a의 URL 정책'을 분리해서 관리하는 것이다.

  • 태그/속성 허용은 Safelist로 처리
  • URL 안전성 검사는 프로젝트 정책으로 후처리
    (sanitizeSafeUrl, sanitizeImageUrl)

이렇게 하면 XSS 방어는 유지하면서도

내부 상대경로 이미지(/upload/..., /images/...)를

정상 보존할 수 있다.

 

아래는 정리된 예시 코드다.

package fourcheetah.animale.web.common;

import java.net.URI;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.safety.Safelist;
import org.springframework.stereotype.Component;

@Component
public class HtmlSanitizer {

    private final Safelist richTextSafelist;
    private final Document.OutputSettings outputSettings;

    // 로그인 ID / 닉네임 검증 패턴 (기존 정책 유지)
    private static final Pattern LOGIN_ID_PATTERN =
            Pattern.compile("^[a-zA-Z0-9._-]{4,30}$");

    private static final Pattern NICKNAME_PATTERN =
            Pattern.compile("^[가-힣a-zA-Z0-9._ -]{2,20}$");

    private static final Pattern PROFILE_TEMP_TOKEN_PATTERN =
            Pattern.compile("^m\\d+_[a-fA-F0-9]{32}\\.(jpg|jpeg|png|webp)$");

    public HtmlSanitizer() {
        this.outputSettings = new Document.OutputSettings().prettyPrint(false);

        // 리치 텍스트 본문용 허용 태그/속성 정의
        // 포인트:
        // - CKEditor가 쓰는 figure / figcaption / table 계열을 허용
        // - img / a 의 URL 안전성은 safelist 프로토콜만 믿지 않고 후처리에서 직접 검증
        this.richTextSafelist = Safelist.relaxed()
                .addTags("figure", "figcaption", "span", "div", "hr",
                        "table", "thead", "tbody", "tr", "th", "td")
                .addAttributes(":all", "class")
                .addAttributes("a", "href", "target", "rel")
                .addAttributes("img", "src", "alt", "width", "height");

        // 상대경로 링크를 보존 (예: '/upload/...', '/images/...')
        this.richTextSafelist.preserveRelativeLinks(true);

        // 링크 보안 속성 강제
        this.richTextSafelist.addEnforcedAttribute("a", "rel", "noopener noreferrer nofollow");
    }

    // ===========================
    // Rich HTML (게시글/뉴스 본문)
    // ===========================

    public String sanitizeBoardHtml(String html) {
        return sanitizeRichHtml(html);
    }

    public String sanitizeNewsHtml(String html) {
        return sanitizeRichHtml(html);
    }

    public String sanitizeRichHtml(String html) {
        if (html == null) return "";

        // 1) 먼저 위험한 태그/속성을 정리
        String cleaned = Jsoup.clean(html, "", richTextSafelist, outputSettings);

        // 2) 그 다음 URL 속성은 프로젝트 정책으로 다시 검증
        //    - 내부 상대경로(/...) 허용
        //    - http/https 허용
        //    - javascript:, data:, vbscript: 차단
        Document doc = Jsoup.parseBodyFragment(cleaned);

        // a[href] 검증
        for (Element a : doc.select("a[href]")) {
            String safeHref = sanitizeSafeUrl(a.attr("href"));
            if (safeHref == null) {
                a.removeAttr("href");
            } else {
                a.attr("href", safeHref);
                // target='_blank'가 온 경우를 대비해 rel 보안 속성 보강
                a.attr("rel", "noopener noreferrer nofollow");
            }
        }

        // img[src] 검증
        for (Element img : doc.select("img[src]")) {
            String safeSrc = sanitizeImageUrl(img.attr("src"));
            if (safeSrc == null) {
                // src가 위험하거나 형식이 이상하면 이미지 자체를 제거
                // (src 없는 img만 남는 상태를 막기 위해 태그 제거)
                img.remove();
            } else {
                img.attr("src", safeSrc);
            }
        }

        return doc.body().html();
    }

    // ===========================
    // Plain Text (제목/댓글/닉네임 등)
    // ===========================

    public String normalizePlainText(String text) {
        if (text == null) return "";
        return text.replaceAll("[\\p{Cntrl}&&[^\\r\\n\\t]]", "").trim();
    }

    public String sanitizePlainText(String text) {
        if (text == null) return "";

        String normalized = normalizePlainText(text);
        if (normalized.isEmpty()) return "";

        String stripped = Jsoup.clean(normalized, Safelist.none());
        return normalizePlainText(stripped);
    }

    // ===========================
    // URL / 이미지 URL 검증
    // ===========================
    // 내부 상대경로('/...') + http/https만 허용
    // javascript:, data:, vbscript: 등은 차단
    public String sanitizeSafeUrl(String url) {
        if (url == null) return null;

        String v = url.trim();
        if (v.isEmpty()) return null;

        // 내부 상대경로 허용
        if (v.startsWith("/")) {
            return v;
        }

        try {
            URI uri = URI.create(v);
            String scheme = uri.getScheme();
            if (scheme == null) return null;

            String s = scheme.toLowerCase(Locale.ROOT);
            if (Set.of("http", "https").contains(s)) {
                return v;
            }
            return null;
        } catch (Exception e) {
            return null;
        }
    }

    public String sanitizeImageUrl(String url) {
        return sanitizeSafeUrl(url);
    }

    // ===========================
    // 로그인 ID / 닉네임 형식 검증
    // ===========================

    public boolean isSafeLoginId(String loginId) {
        if (loginId == null) return false;
        String v = sanitizePlainText(loginId);
        return LOGIN_ID_PATTERN.matcher(v).matches();
    }

    public boolean isSafeNickname(String nickname) {
        if (nickname == null) return false;
        String v = sanitizePlainText(nickname);
        return NICKNAME_PATTERN.matcher(v).matches();
    }

    // ===========================
    // 프로필 임시 토큰 / 경로 검증
    // ===========================

    public boolean isValidProfileTempToken(String token) {
        if (token == null) return false;
        return PROFILE_TEMP_TOKEN_PATTERN.matcher(token.trim()).matches();
    }

    public boolean isUnderBaseDir(Path baseDir, Path target) {
        if (baseDir == null || target == null) return false;

        Path base = baseDir.toAbsolutePath().normalize();
        Path t = target.toAbsolutePath().normalize();

        return t.startsWith(base);
    }
}

[ 해결 포인트 정리 ]

이번 수정의 핵심은 아래 3가지다.

 

1) CKEditor가 쓰는 리치 텍스트 태그를 Safelist에서 허용

  • figure, figcaption 등

2) 이미지/링크 URL은 프로젝트 정책으로 직접 검증

  • 내부 상대경로(/...) 허용
  • http, https 허용
  • 위험 스킴 차단

3) 잘못된 이미지는 src만 지우지 말고 img 태그 자체를 제거

  • <img>만 남는 깨진 HTML 방지

[ 결과 확인 ]

수정 후에는 같은 방식으로 작성한 본문이

DB에 아래처럼 정상 저장된다.

<figure class='image'>
  <img src='/upload/news/2026/02/abc123.png' alt=''>
</figure>
<p>본문 텍스트...</p>

 

그리고 뉴스 상세 / 게시글 상세에서 이미지가 정상 출력된다.

수정 후 정상 출력되는 뉴스/게시글 본문 이미지

즉, 이번 문제는 'CKEditor 자체 오류'가 아니라
'XSS 방어 로직의 URL 허용 정책과

CKEditor 이미지 경로 형식이 충돌한 문제'였다.


[ 추가로 체크한 점 ]

이번 케이스에서 주 원인은 HtmlSanitizer였고,

컨트롤러 매핑을 대거 수정할 필요는 없었다.

 

다만 아래는 같이 점검하면 좋다.

  • 컨트롤러에서 실제로 sanitizeBoardHtml()
    / sanitizeNewsHtml()를 저장 직전에 호출하는지
  • CKEditor 업로드 응답 JSON이 정상인지 (url 반환)
  • 이미지 URL이 실제로 브라우저에서 접근 가능한 경로인지
    (/upload/**, /images/** 매핑)

즉, 순서는 항상 이렇게 보면 빨리 찾을 수 있다.

  1. 에디터에 보이냐
  2. DB에 어떻게 저장됐냐
  3. 화면 렌더 HTML이 뭐냐
  4. 실제 이미지 URL이 열리냐

[ 마무리 ]

이번 이슈는 겉으로 보면 '이미지가 안 뜬다'는 단순 증상이지만,
실제로는 '보안(XSS 방어)과 기능(CKEditor 이미지 업로드)이

만나는 지점'에서 생긴 문제였다.

 

보안 로직을 넣는 것 자체는 맞는 방향인데,
리치 텍스트 본문처럼 HTML을 일부 허용해야 하는 영역은
'무조건 제거'가 아니라 '허용 태그 + URL 정책 분리' 방식으로

설계해야 안정적으로 동작한다.

 

이번 수정으로 얻은 포인트는 두 가지다.

  • XSS 방어를 유지하면서도 CKEditor 기능을 살릴 수 있게 됨
  • 이후 뉴스/게시글 본문 처리 정책을 공통화하기 쉬워짐
    (sanitizeRichHtml 재사용)

같은 증상이 보이면 가장 먼저 DB에 저장된 본문 HTML을 확인해보자.
<img>는 있는데 src가 없다면, 거의 확실하게 저장 전 Sanitizer 단계 문제다.