관리자 페이지에서 사내 녹음 서버의 오디오 파일을 재생하려는데 콘솔에 이런 에러가 떴다.
Access to fetch at 'http://rec.internal.example.com/audio/file.mp3'
from origin 'https://admin.example.com' has been blocked by CORS policy:
...Private Network Access prelight request failed.
CORS 에러처럼 생겼지만 실제로는 Chrome의 Private Network Access(PNA) 정책 문제였다.
Chrome Private Network Access란?
Chrome 98+부터 적용된 보안 정책이다.
공개 origin(HTTPS 공인 도메인)에서 사설 네트워크 주소(내부망 IP, localhost, .internal 도메인)로 직접 요청하면 차단된다.
https://admin.example.com → http://rec.internal.example.com ← 차단!
(공개 origin) (내부망 도메인 → 사설 IP resolve)
내부망 서버에 CORS 헤더를 추가하면 해결될 것 같지만, PNA는 별도의 Preflight 요청을 요구한다. 그리고 이 내부 서버는 제어할 수 없는 상황이었다.
해결: 서버 사이드 프록시
백엔드 서버는 서버끼리 통신이므로 PNA 정책의 영향을 받지 않는다. 관리자 페이지 → 백엔드 프록시 엔드포인트 → 내부 녹음 서버 순으로 중계하도록 했다.
@GetMapping("/audio/proxy")
public void proxyAudio(@RequestParam String url, HttpServletResponse response) {
// SSRF 방지: 허용된 도메인만 프록시
if (!url.contains("rec.example.com")) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(url).openConnection();
conn.setConnectTimeout(5_000);
conn.setReadTimeout(30_000);
response.setContentType("audio/mpeg");
try (InputStream in = conn.getInputStream()) {
StreamUtils.copy(in, response.getOutputStream());
}
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_BAD_GATEWAY);
} finally {
if (conn != null) conn.disconnect();
}
}
프론트엔드에서는 직접 URL 대신 프록시 URL을 사용한다.
// 변경 전
const audio = new Audio('http://rec.internal.example.com/audio/file.mp3');
// 변경 후
const proxyUrl = `/audio/proxy?url=${encodeURIComponent(url)}`;
const audio = new Audio(proxyUrl);
SSRF 방지가 핵심
프록시 엔드포인트는 임의의 URL을 받아 요청을 중계하므로, SSRF(Server-Side Request Forgery) 공격에 취약할 수 있다.
공격자: /audio/proxy?url=http://169.254.169.254/latest/meta-data/
→ 클라우드 메타데이터 탈취 시도
반드시 허용 도메인을 화이트리스트로 검증해야 한다.
// 단순 contains 체크 (우회 가능)
if (!url.contains("rec.example.com")) { ... }
// 더 안전한 방법: URL 파싱 후 host 검증
URL parsed = new URL(url);
String host = parsed.getHost();
if (!host.equals("rec.example.com") && !host.endsWith(".rec.example.com")) {
response.setStatus(403);
return;
}
결과
- Chrome PNA 정책 우회 완료
- 개발/운영 환경 모두 동일한 코드로 동작
- SSRF 공격 방어
배운 것
- Chrome PNA는 CORS와 다르다. 단순히 CORS 헤더를 추가한다고 해결되지 않는다.
- 프록시를 만들 때는 반드시 SSRF 방어를 함께 고려해야 한다.
encodeURIComponent를 빠뜨리면 URL에 특수문자가 있을 때 파라미터가 깨진다.