λ³Έλ¬Έ λ°”λ‘œκ°€κΈ°
개발 일지 πŸ‘©‍πŸ’»

Canvas Tainted 문제 ν•΄κ²° — μ™ΈλΆ€ 이미지와 μ„œλͺ…을 ν•©μ„±ν•  λ•Œ μ„œλͺ…λ§Œ μ €μž₯λ˜λŠ” 이슈

by chuyj15 2026. 3. 24.
728x90
λ°˜μ‘ν˜•
SMALL

Canvas Tainted 문제 ν•΄κ²° β€” μ™ΈλΆ€ 이미지와 μ„œλͺ…을 ν•©μ„±ν•  λ•Œ μ„œλͺ…λ§Œ μ €μž₯λ˜λŠ” 이슈

문제 상황

μ„œμ•½μ„œμ— μ„œλͺ…ν•˜λŠ” κΈ°λŠ₯을 개발 μ€‘μ΄μ—ˆλ‹€.

μ„œμ•½μ„œ 이미지 μœ„μ— μ‚¬μš©μžκ°€ 직접 μ„œλͺ…ν•˜λ©΄, μ„œμ•½μ„œ + μ„œλͺ…을 ν•˜λ‚˜μ˜ μ΄λ―Έμ§€λ‘œ ν•©μ„±ν•˜μ—¬ μ €μž₯ν•˜λŠ” ꡬ쑰닀.

[μ„œμ•½μ„œ 이미지] + [μ„œλͺ… μΊ”λ²„μŠ€] = [ν•©μ„±λœ 이미지(PNG)]
  • μ„œμ•½μ„œκ°€ μ„œλ²„ λ‚΄λΆ€ 파일(/api/ftp/pledge.png)일 λ•Œ β†’ βœ… ν•©μ„± 정상
  • μ„œμ•½μ„œκ°€ μ™ΈλΆ€ URL(https://example.com/pledge.png)일 λ•Œ β†’ ❌ μ„œλͺ…λ§Œ μ €μž₯됨

ν™”λ©΄μ—λŠ” μ„œμ•½μ„œ 이미지가 잘 λ³΄μ΄λŠ”λ°, ν•©μ„± κ²°κ³Όμ—λŠ” μ„œλͺ…λ§Œ λ“€μ–΄κ°€ μžˆμ—ˆλ‹€.


πŸ” 원인 뢄석

1단계: μ΄λ―Έμ§€λŠ” 잘 λ³΄μ΄λŠ”λ° 합성이 μ•ˆ λœλ‹€?

<img> νƒœκ·Έλ‘œ μ™ΈλΆ€ 이미지λ₯Ό ν‘œμ‹œν•˜λŠ” 것은 CORS μ œν•œμ„ λ°›μ§€ μ•ŠλŠ”λ‹€. λΈŒλΌμš°μ €κ°€ 이미지λ₯Ό "λ³΄μ—¬μ£ΌκΈ°λ§Œ" ν•˜λŠ” 것은 λ³΄μ•ˆμƒ λ¬Έμ œκ°€ μ—†κΈ° λ•Œλ¬Έμ΄λ‹€.

{/* βœ… μ™ΈλΆ€ 이미지도 정상 ν‘œμ‹œ */}
<img src="https://example.com/pledge.png" />

ν•˜μ§€λ§Œ 이 이미지λ₯Ό Canvas에 그리면 상황이 달라진닀.

2단계: Canvas Tainted (μ˜€μ—Ό) μƒνƒœ

Canvas APIμ—μ„œ μ™ΈλΆ€ 도메인 이미지λ₯Ό drawImage()둜 그리면, λΈŒλΌμš°μ €λŠ” ν•΄λ‹Ή canvasλ₯Ό "tainted(μ˜€μ—Ό)" μƒνƒœλ‘œ ν‘œμ‹œν•œλ‹€.

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(externalImage, 0, 0); // ← μ—¬κΈ°κΉŒμ§€λŠ” OK

canvas.toDataURL('image/png'); // ❌ SecurityError λ°œμƒ!

μ™œ? Canvas에 μ™ΈλΆ€ 이미지 ν”½μ…€ 데이터가 λ“€μ–΄κ°€λ©΄, toDataURL()μ΄λ‚˜ getImageData()둜 κ·Έ 데이터λ₯Ό 읽을 수 있게 λœλ‹€. μ΄λŠ” λ‹€λ₯Έ λ„λ©”μΈμ˜ 이미지 데이터λ₯Ό μŠ€ν¬λ¦½νŠΈκ°€ μ½λŠ” κ²ƒμ΄λ―€λ‘œ λΈŒλΌμš°μ €κ°€ μ°¨λ‹¨ν•œλ‹€.

3단계: crossOrigin="anonymous" 도 μ•ˆ λœλ‹€?

일반적인 CORS 해결법인 crossOrigin="anonymous" 속성을 μΆ”κ°€ν•˜λ©΄?

const img = new Image();
img.crossOrigin = 'anonymous';
img.src = 'https://example.com/pledge.png';

이 경우, μ™ΈλΆ€ μ„œλ²„κ°€ Access-Control-Allow-Origin 헀더λ₯Ό λ°˜ν™˜ν•΄μ•Ό 이미지가 λ‘œλ“œλœλ‹€. μ™ΈλΆ€ μ„œλ²„κ°€ 이 헀더λ₯Ό 보내지 μ•ŠμœΌλ©΄ 이미지 λ‘œλ”© μžμ²΄κ°€ μ‹€νŒ¨ν•œλ‹€.

Access to image at 'https://example.com/pledge.png' from origin 'http://localhost:3000' 
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

β†’ μš°λ¦¬κ°€ ν†΅μ œν•  수 μ—†λŠ” μ™ΈλΆ€ μ„œλ²„μ΄λ―€λ‘œ 이 방법은 μ‚¬μš© λΆˆκ°€.


ν•΄κ²° 방법: λ°±μ—”λ“œ 이미지 ν”„λ‘μ‹œ

핡심 아이디어: μ™ΈλΆ€ 이미지λ₯Ό 우리 μ„œλ²„λ₯Ό κ²½μœ ν•΄μ„œ κ°€μ Έμ˜€λ©΄, ν”„λ‘ νŠΈμ—”λ“œ μž…μž₯μ—μ„œ 같은 λ„λ©”μΈμœΌλ‘œ μΈμ‹λœλ‹€.

[ν”„λ‘ νŠΈμ—”λ“œ] β†’ /api/image-proxy?url=https://example.com/pledge.png
                         ↓
              [λ°±μ—”λ“œ μ„œλ²„κ°€ μ™ΈλΆ€ 이미지λ₯Ό 직접 λ‹€μš΄λ‘œλ“œ]
                         ↓
              [ν”„λ‘ νŠΈμ—”λ“œμ— 이미지 λ°”μ΄λ„ˆλ¦¬ λ°˜ν™˜]
                         ↓
              [같은 도메인 β†’ Canvas tainted μ—†μŒ!]

λ°±μ—”λ“œ: ImageProxyController.java

@GetMapping("/api/image-proxy")
public ResponseEntity<byte[]> proxyImage(@RequestParam("url") String url) {
    // μ™ΈλΆ€ URL만 ν—ˆμš© (http/https)
    if (!url.startsWith("http://") && !url.startsWith("https://")) {
        return ResponseEntity.badRequest().build();
    }

    // Java HttpClient둜 μ™ΈλΆ€ 이미지 λ‹€μš΄λ‘œλ“œ
    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .GET()
            .timeout(Duration.ofSeconds(15))
            .build();

    HttpResponse<byte[]> response = httpClient.send(request, 
            HttpResponse.BodyHandlers.ofByteArray());

    // 원본 Content-Typeκ³Ό ν•¨κ»˜ λ°˜ν™˜
    return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(contentType))
            .body(response.body());
}

ν”„λ‘ νŠΈμ—”λ“œ: AgreementModal.jsx

const mergeImageAndSignature = async (signatureDataUrl) => {
  const pledgeUrl = currentPledge.fileUrl;

  // URL μœ ν˜•λ³„ λΆ„κΈ°
  if (isExternalUrl(pledgeUrl)) {
    // ── μ™ΈλΆ€ URL: ν”„λ‘μ‹œ 경유 β†’ blob β†’ ObjectURL β†’ Canvas ──
    const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(pledgeUrl)}`;
    const response = await fetch(proxyUrl);
    const blob = await response.blob();
    const objectUrl = URL.createObjectURL(blob);
    // β†’ objectUrl은 blob:http://localhost:3000/... μ΄λ―€λ‘œ 같은 도메인!
    // β†’ Canvas tainted μ—†μŒ βœ…
  } else {
    // ── 같은 μ„œλ²„ 파일: DOM의 <img>λ₯Ό 직접 μ‚¬μš© ──
    ctx.drawImage(pledgeImgRef.current, 0, 0);
    // β†’ 같은 λ„λ©”μΈμ΄λ―€λ‘œ 문제 μ—†μŒ βœ…
  }
};

인터셉터 μ„€μ •: WebConfig.java

ν”„λ‘μ‹œ APIλŠ” 인증이 ν•„μš” μ—†μœΌλ―€λ‘œ JWT μΈν„°μ…‰ν„°μ—μ„œ μ œμ™Έν•΄μ•Ό ν•œλ‹€. 이 뢀뢄을 λ†“μΉ˜λ©΄ ν”„λ‘μ‹œ μžμ²΄κ°€ 401을 λ°˜ν™˜ν•œλ‹€.

.excludePathPatterns(
    "/api/image-proxy"  // μ„œμ•½μ„œ μ™ΈλΆ€ 이미지 ν”„λ‘μ‹œ (인증 λΆˆν•„μš”)
)

⚠ μ‚½μ§ˆ 둜그

디버깅 κ³Όμ •μ—μ„œ μ—¬λŸ¬ μ‹œν–‰μ°©μ˜€λ₯Ό κ±°μ³€λ‹€.

μ‹œλ„ κ²°κ³Ό 원인
crossOrigin="anonymous" μΆ”κ°€ ❌ 이미지 λ‘œλ”© 자체 μ‹€νŒ¨ μ™ΈλΆ€ μ„œλ²„κ°€ CORS 헀더 미제곡
fetch() β†’ blob β†’ ObjectURL ❌ application/json 89 bytes fetch()에 인증 토큰 미포함 β†’ 401 응닡
axios (api μΈμŠ€ν„΄μŠ€) μ‚¬μš© ❌ μš”μ²­ 경둜 쀑볡 baseURL: '/api'둜 인해 /api/api/ftp/...
axios 직접 μ‚¬μš© + withCredentials ❌ μ—¬μ „νžˆ μ‹€νŒ¨ μΏ ν‚€λ§ŒμœΌλ‘œ 인증 μ•ˆ λ˜λŠ” ꡬ쑰
ν™”λ©΄ DOM <img> ref 직접 μ‚¬μš© ⚠️ νŒŒμΌμ€ OK, μ™ΈλΆ€ URL은 NG Canvas tainted
λ°±μ—”λ“œ ν”„λ‘μ‹œ + 인터셉터 μ œμ™Έ βœ… 성곡 같은 λ„λ©”μΈμœΌλ‘œ 인식

정리

ꡬ뢄 같은 μ„œλ²„ 파일 μ™ΈλΆ€ URL
<img> ν‘œμ‹œ βœ… βœ…
Canvas drawImage() βœ… βœ… (그렀짐)
Canvas toDataURL() βœ… ❌ (tainted)
ν•΄κ²° 방법 DOM <img> ref 직접 μ‚¬μš© λ°±μ—”λ“œ ν”„λ‘μ‹œ 경유

핡심 포인트

  1. λΈŒλΌμš°μ €λŠ” 이미지 "ν‘œμ‹œ"와 "데이터 읽기"λ₯Ό κ΅¬λΆ„ν•œλ‹€. ν‘œμ‹œλŠ” μžμœ λ‘­μ§€λ§Œ, Canvasλ₯Ό ν†΅ν•œ ν”½μ…€ 데이터 읽기(toDataURL)λŠ” CORS μ •μ±…μ˜ μ μš©μ„ λ°›λŠ”λ‹€.

  2. μ™ΈλΆ€ μ„œλ²„λ₯Ό ν†΅μ œν•  수 μ—†μœΌλ©΄ ν”„λ‘μ‹œκ°€ μœ μΌν•œ 해결책이닀. λ°±μ—”λ“œμ—μ„œ μ™ΈλΆ€ 이미지λ₯Ό λŒ€μ‹  가져와 ν”„λ‘ νŠΈμ— μ „λ‹¬ν•˜λ©΄ 같은 λ„λ©”μΈμœΌλ‘œ μΈμ‹λœλ‹€.

  3. ν”„λ‘μ‹œ API도 인증 인터셉터에 λ“±λ‘λ˜μ–΄ 있으면 401이 λ°œμƒν•œλ‹€. λ°˜λ“œμ‹œ excludePathPatterns에 μΆ”κ°€ν•΄μ•Ό ν•œλ‹€.

🏷️

#Canvas #CORS #TaintedCanvas #JavaScript #SpringBoot #ImageProxy #λ³΄μ•ˆμ •μ±… #νŠΈλŸ¬λΈ”μŠˆνŒ…

728x90
λ°˜μ‘ν˜•
LIST