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 μ§μ μ¬μ© |
λ°±μλ νλ‘μ κ²½μ |
ν΅μ¬ ν¬μΈνΈ
λΈλΌμ°μ λ μ΄λ―Έμ§ "νμ"μ "λ°μ΄ν° μ½κΈ°"λ₯Ό ꡬλΆνλ€. νμλ μμ λ‘μ§λ§, Canvasλ₯Ό ν΅ν ν½μ λ°μ΄ν° μ½κΈ°(toDataURL)λ CORS μ μ± μ μ μ©μ λ°λλ€.
μΈλΆ μλ²λ₯Ό ν΅μ ν μ μμΌλ©΄ νλ‘μκ° μ μΌν ν΄κ²°μ± μ΄λ€. λ°±μλμμ μΈλΆ μ΄λ―Έμ§λ₯Ό λμ κ°μ Έμ νλ‘ νΈμ μ λ¬νλ©΄ κ°μ λλ©μΈμΌλ‘ μΈμλλ€.
νλ‘μ APIλ μΈμ¦ μΈν°μ ν°μ λ±λ‘λμ΄ μμΌλ©΄ 401μ΄ λ°μνλ€. λ°λμ
excludePathPatternsμ μΆκ°ν΄μΌ νλ€.
π·οΈ
#Canvas #CORS #TaintedCanvas #JavaScript #SpringBoot #ImageProxy #보μμ μ±
#νΈλ¬λΈμν