crossOrigin과 Canvas Taint
Canvas에 이미지를 그리는 건 쉽다. drawImage()를 호출하면 외부 URL의 이미지든, 로컬 이미지든 캔버스 위에 렌더링된다. 하지만 그 이미지를 다시 꺼내려는 순간 문제가 터진다.
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = 'https://other-domain.com/photo.jpg';
img.onload = () => {
ctx.drawImage(img, 0, 0);
// 여기서 에러 발생
const dataURL = canvas.toDataURL(); // SecurityError!
};
toDataURL(), getImageData(), toBlob(), captureStream() 등 캔버스의 픽셀 데이터를 읽는 API를 호출하면 SecurityError가 발생한다. 왜 그럴까?
Canvas Taint란 무엇인가
브라우저에는 동일 출처 정책(Same-Origin Policy)이 있다. 한 출처(origin)의 스크립트가 다른 출처의 리소스를 마음대로 읽지 못하게 막는 보안 모델이다.
Canvas는 특이한 존재다. drawImage()로 그리는 것은 허용하지만, 그린 내용을 다시 읽는 것은 별개의 문제로 취급한다. 외부 출처의 이미지가 캔버스에 그려지는 순간, 브라우저는 해당 캔버스를 "오염됨(tainted)" 상태로 표시한다.
오염된 캔버스에서 픽셀 데이터를 추출하려고 하면 브라우저가 차단한다. 이유는 간단하다. 만약 이게 허용되면 악의적인 스크립트가 사용자의 인증 쿠키를 이용해 다른 사이트의 비공개 이미지를 캔버스에 그린 뒤, 픽셀 데이터를 훔쳐갈 수 있기 때문이다.
[외부 이미지] → drawImage() → [Canvas: tainted] → toDataURL() → ❌ SecurityError
[같은 출처 이미지] → drawImage() → [Canvas: clean] → toDataURL() → ✅ 정상
오염을 일으키는 조건
캔버스가 오염되는 경우:
- 다른 출처의
<img>나<svg>를drawImage()로 그렸을 때 — CORS 승인 없이 로드된 경우 - 다른 출처의
<video>프레임을 캔버스에 그렸을 때 - 다른 출처의
ImageBitmap을 사용했을 때
오염되면 차단되는 API
| 메서드 | 대상 |
|---|---|
getImageData() | CanvasRenderingContext2D |
toDataURL() | HTMLCanvasElement |
toBlob() | HTMLCanvasElement |
captureStream() | HTMLCanvasElement |
한 번 오염되면 되돌릴 수 없다. 캔버스를 clearRect()로 지워도 taint 상태는 유지된다. 새 캔버스를 만들어야 한다.
crossOrigin 속성으로 해결하기
CORS(Cross-Origin Resource Sharing)를 활용하면 외부 이미지를 "허가받은 상태"로 로드할 수 있다. 이때 사용하는 게 crossOrigin 속성이다.
const img = new Image();
img.crossOrigin = 'anonymous'; // CORS 요청으로 로드
img.src = 'https://other-domain.com/photo.jpg';
img.onload = () => {
ctx.drawImage(img, 0, 0);
const dataURL = canvas.toDataURL(); // ✅ 정상 작동
};
crossOrigin 속성 값
| 값 | 동작 |
|---|---|
"anonymous" | CORS 요청을 보내되, 자격 증명(쿠키, 인증 헤더)은 포함하지 않음 |
"use-credentials" | CORS 요청에 자격 증명을 포함 |
"" (빈 문자열) | "anonymous"와 동일 |
| 속성 없음 | CORS 요청을 보내지 않음 → 캔버스 오염 |
대부분의 경우 "anonymous"를 사용한다. "use-credentials"는 서버가 Access-Control-Allow-Credentials: true를 반환해야 하고, Access-Control-Allow-Origin에 와일드카드(*)를 사용할 수 없어서 설정이 까다롭다.
HTML에서 사용할 때
<!-- 올바른 사용 -->
<img crossorigin="anonymous" src="https://cdn.example.com/photo.jpg" />
<!-- video도 동일하게 적용 -->
<video crossorigin="anonymous" src="https://cdn.example.com/video.mp4"></video>
⚠️ 주의: crossorigin 속성과 src 속성의 순서가 중요하다. 일부 브라우저에서는 src를 먼저 설정하면 이미 non-CORS 요청이 시작되어 crossorigin 속성이 무시될 수 있다. JavaScript에서 동적으로 설정할 때도 반드시 crossOrigin을 src보다 먼저 설정해야 한다.
// ✅ 올바른 순서
const img = new Image();
img.crossOrigin = 'anonymous'; // 먼저
img.src = 'https://cdn.example.com/photo.jpg'; // 나중
// ❌ 잘못된 순서 — 일부 브라우저에서 taint 발생
const img2 = new Image();
img2.src = 'https://cdn.example.com/photo.jpg'; // 이미 non-CORS 요청 시작
img2.crossOrigin = 'anonymous'; // 너무 늦음
서버 측 설정이 필수다
crossOrigin 속성만 설정한다고 끝이 아니다. 이미지를 제공하는 서버가 CORS 응답 헤더를 보내줘야 한다. 서버가 CORS를 허용하지 않으면 브라우저가 이미지 로드 자체를 차단한다.
필수 응답 헤더
Access-Control-Allow-Origin: *
또는 특정 출처만 허용:
Access-Control-Allow-Origin: https://my-app.com
서버별 설정 예시
Nginx:
location ~* \.(jpg|jpeg|png|gif|webp|svg)$ {
add_header Access-Control-Allow-Origin "*";
}
Express.js:
app.use('/images', (req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
next();
});
S3 + CloudFront:
S3 버킷의 CORS 설정에서 허용할 출처와 메서드를 지정한다.
[
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
CloudFront를 사용한다면 Origin 요청 헤더를 S3로 전달하도록 캐시 정책을 설정해야 한다. 그렇지 않으면 CloudFront가 Origin 헤더를 제거해서 S3가 CORS 헤더 없이 응답한다.
실전에서 만나는 문제들
1. 캐시로 인한 CORS 실패
같은 이미지를 먼저 일반 <img> 태그로 로드하고, 이후에 crossOrigin="anonymous"로 다시 로드하면 문제가 생긴다. 브라우저 캐시에 non-CORS 응답(CORS 헤더 없음)이 저장되어 있어서, 두 번째 CORS 요청에도 캐시된 응답을 사용한다.
// 페이지 어딘가에서 이미 로드됨 (CORS 없이)
// <img src="https://cdn.example.com/photo.jpg" />
// 이후 Canvas용으로 다시 로드하려고 하면
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = 'https://cdn.example.com/photo.jpg'; // 캐시된 non-CORS 응답 사용 → 실패
해결 방법:
// 캐시 버스터 추가
img.src = 'https://cdn.example.com/photo.jpg?canvas=1';
// 또는 처음부터 모든 곳에서 crossOrigin 설정
// <img crossorigin="anonymous" src="https://cdn.example.com/photo.jpg" />
가장 좋은 방법은 처음부터 모든 이미지 로드에 crossOrigin을 설정하는 것이다. 나중에 Canvas에서 사용할 가능성이 있다면 미리 설정해두자.
2. data URL과 Blob URL은 안전
data: URL이나 blob: URL로 로드한 이미지는 동일 출처로 취급되므로 Canvas를 오염시키지 않는다.
// data URL — 안전
const img = new Image();
img.src = 'data:image/png;base64,iVBORw0KGgo...';
// Blob URL — 안전
const blob = await fetch('https://cdn.example.com/photo.jpg').then(r => r.blob());
const blobURL = URL.createObjectURL(blob);
const img2 = new Image();
img2.src = blobURL;
fetch API는 자체적으로 CORS를 처리하므로, 서버가 CORS를 지원한다면 fetch로 이미지를 받아 Blob URL로 변환하는 것도 깔끔한 우회 방법이다.
3. Proxy 서버 우회
이미지 서버의 CORS 설정을 변경할 수 없는 경우, 자체 프록시 서버를 경유시키는 방법이 있다.
// 원본: 외부 서버 (CORS 미지원)
// img.src = 'https://external-api.com/photo.jpg'; // ❌
// 프록시 경유: 같은 출처로 처리됨
img.src = '/api/proxy-image?url=https://external-api.com/photo.jpg'; // ✅
// Express 프록시 예시
app.get('/api/proxy-image', async (req, res) => {
const response = await fetch(req.query.url);
const contentType = response.headers.get('content-type');
res.setHeader('Content-Type', contentType);
response.body.pipe(res);
});
같은 출처에서 이미지를 제공하므로 CORS 문제가 아예 발생하지 않는다. 다만 프록시 서버의 대역폭과 지연 시간을 고려해야 한다.
4. SVG foreignObject의 함정
SVG의 <foreignObject> 안에 HTML을 넣고 Canvas에 렌더링할 때도 taint가 발생할 수 있다. foreignObject 내부의 이미지가 외부 출처이면 전체 Canvas가 오염된다.
OffscreenCanvas와 taint
OffscreenCanvas는 Web Worker에서 사용할 수 있는 Canvas다. 일반 Canvas와 동일한 taint 규칙이 적용된다. Worker 안에서도 외부 출처 이미지를 CORS 없이 그리면 오염된다.
// Worker 내부
const canvas = new OffscreenCanvas(800, 600);
const ctx = canvas.getContext('2d');
const response = await fetch('https://cdn.example.com/photo.jpg');
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, 0, 0);
const resultBlob = await canvas.convertToBlob(); // ✅ fetch가 CORS 처리했으므로 안전
Worker에서는 <img> 태그를 사용할 수 없으므로 fetch() + createImageBitmap() 조합을 사용한다. fetch가 CORS를 처리하므로 taint 문제가 자연스럽게 해결된다.
디버깅 체크리스트
Canvas에서 SecurityError가 발생했을 때 순서대로 확인할 것:
-
이미지를
crossOrigin = 'anonymous'로 로드했는가?src설정 전에crossOrigin을 먼저 설정했는가?
-
서버가
Access-Control-Allow-Origin헤더를 반환하는가?- 브라우저 개발자 도구 Network 탭에서 응답 헤더 확인
-
브라우저 캐시가 문제를 일으키는가?
- 개발자 도구에서 "Disable cache" 체크 후 재시도
- 또는 URL에 캐시 버스터 쿼리 파라미터 추가
-
CDN이
Origin헤더를 올바르게 전달하는가?- CloudFront 등 CDN 사용 시 Origin 헤더 포워딩 설정 확인
-
리다이렉트 중에 CORS가 깨지는가?
- HTTP → HTTPS 리다이렉트 시 CORS 헤더가 누락될 수 있음
정리
Canvas taint는 "이미지는 보여줄 수 있지만 훔칠 수는 없게" 하려는 브라우저의 보안 장치다. 핵심은 두 가지:
- 클라이언트: 이미지 로드 시
crossOrigin = 'anonymous'설정 (반드시src보다 먼저) - 서버: 응답에
Access-Control-Allow-Origin헤더 포함
이 두 조건이 모두 충족되어야 Canvas에서 픽셀 데이터를 안전하게 추출할 수 있다. 서버를 제어할 수 없다면 프록시를 경유하거나, fetch + Blob URL 변환을 활용하자.