/** * YS Project - 추천 스타일 이미지 URL 안전성 판정 (공용) * * ⚠️ 이 파일은 서버측 `includes/style_helpers.php` 의 * `styleIsAllowedAssetPath()` / `styleSafeImageUrl()` 과 **같은 규칙**이어야 한다. * 한쪽만 바꾸면 그 순간 규칙이 갈라지고, 갈라진 쪽이 실사용 경로가 되어 구멍이 된다. * * 왜 이 판정이 필요한가: * 작품/배우 이미지 URL 은 **인증 없는 업체 Ingest API(`api/partner/*`)로 아무나 넣는다.** * `cover_image_url` 에 `/api/logout.php` (또는 `https://<이 사이트>/api/logout.php`) 를 넣으면 * 목록을 여는 **모든 사용자·관리자가 로드만으로 강제 로그아웃**된다(실제 재현됨). * 그래서 "이 사이트로 향하는" 이미지 URL 은 정적 자산 경로로만 제한한다. * 외부 호스트 URL 은 계속 허용한다 — 외부 CDN 저장이 정상 동작이고, * 다른 호스트로 가는 요청에는 이 사이트 세션 쿠키가 실리지 않는다. * * 이 파일은 전역 함수 하나만 노출한다. 사용하는 쪽은 * `admin/styles_works.php`, `admin/styles_actors.php`, `styles/index.php`. */ (function (global) { 'use strict'; /** * 호스트 정규화 — 서버측 `styleSafeImageUrl()` 의 호스트 처리와 맞춘다. * * ⚠️ 딱 한 곳, **의도적으로 서버가 더 엄격하다.** * 서버는 `intl` 확장이 없어 브라우저의 UTS46/IDNA 매핑을 재현할 수 없다. 그래서 * **비ASCII 호스트를 통째로 거부**한다(전각 `ys.com` 같은 위장을 막는 유일한 방법). * 여기서는 `new URL().hostname` 이 이미 IDNA 정규화를 끝내주므로 그 검사가 필요 없고, * 넣으면 오히려 정상적인 IDN 외부 CDN 을 과차단한다. * → 결과 차이: 진짜 IDN 외부 호스트 이미지는 AJAX 목록엔 뜨고 SSR 엔 안 뜬다. * 보안상 안전한 방향(서버가 더 조임)이고, intl 이 깔리면 서버쪽을 idn_to_ascii() 로 바꿔 해소할 것. */ function canonHost(host) { var h = String(host || '').toLowerCase(); try { h = decodeURIComponent(h); } catch (e) { /* 깨진 인코딩은 원문 그대로 비교 */ } return h.replace(/\.+$/, ''); } /** 이미지로 허용할 내부 경로인지 (서버 styleIsAllowedAssetPath() 와 동일 규칙) */ function isAllowedAssetPath(path) { if (!path || path.charAt(0) !== '/') return false; var decoded; try { decoded = decodeURIComponent(path); } catch (e) { return false; } if (decoded.indexOf('\\') !== -1) return false; // '..' 는 세그먼트 단위로만 거부한다 ('/uploads/my..old.jpg' 같은 정상 파일명은 통과) var segs = decoded.split('/'); for (var i = 0; i < segs.length; i++) { if (segs[i] === '..') return false; } // 접두사는 원문 기준 — '/%75ploads/' 같은 인코딩 위장을 함께 거른다 return path.indexOf('/uploads/') === 0 || path.indexOf('/assets/') === 0; } /** * @param {string} url * @returns {string} 안전하면 원본 URL, 아니면 '' (호출부는 '' 이면 를 만들지 않는다) */ function ysSafeImageUrl(url) { if (!url) return ''; var v = String(url).trim(); if (v === '') return ''; if (/[\x00-\x1F\x7F]/.test(v)) return ''; // 제어문자로 스킴 검사 우회 차단 if (v.indexOf('//') === 0 || v.indexOf('/\\') === 0) return ''; // 프로토콜 상대 URL / 백슬래시 변형 if (v.charAt(0) === '/') { return isAllowedAssetPath(v) ? v : ''; } if (!/^https?:\/\//i.test(v)) return ''; var u; try { u = new URL(v); } catch (e) { return ''; } // 같은 호스트면 결국 같은 엔드포인트를 때린다 → 내부 경로와 동일 규칙 적용. // u.pathname 은 브라우저가 이미 '..' 와 '\' 를 정규화한 결과라 서버측 검사와 결론이 같아진다. // 호스트는 서버(styleSafeImageUrl)와 **똑같이** 트레일링 닷·퍼센트 인코딩을 풀고 비교한다 — // 안 맞추면 `ys.com.` / `ys.com%2e` 가 서버에선 차단, 여기선 통과로 갈려 규칙이 발산한다. if (canonHost(u.hostname) === canonHost(global.location.hostname)) { return isAllowedAssetPath(u.pathname) ? v : ''; } return v; // 외부 호스트 허용 } global.ysSafeImageUrl = ysSafeImageUrl; })(window);