Merge pull request from gnuboard:HeuJung/issue562

fix(core,admin_basic,basic): 공개 자산 CDN 교차 출처 요청의 토큰 노출·CORS 실패 수정 + S3 접속 필드 노출 조건 확장
This commit is contained in:
정정홍
2026-08-14 10:33:50 +09:00
committed by GitHub
31 changed files with 959 additions and 192 deletions
+1
View File
@@ -16,6 +16,7 @@
### Changed
- 공개 자산을 외부 주소(CDN)로 서빙할 때, 그 주소로 나가는 요청에는 로그인 토큰을 함께 보내지 않습니다 — 공개 자산은 인증이 필요 없고, 토큰이 외부 서비스로 전달되지 않아야 하기 때문입니다. 사이트 자신의 주소로 가는 요청은 종전과 동일합니다.
- 확장 개발자용: 확장 캐시 계약 인터페이스에 카테고리별 스토리지 접근이 추가되었습니다. 코어가 제공하는 확장 베이스 클래스·서비스 프로바이더를 상속하는 확장은 수정 없이 호환되지만, 이 계약을 직접 구현하는 확장은 새 메서드를 구현해야 합니다 (번들 확장은 모두 반영 완료 — 최소 요구 코어 버전 7.0.7).
### Fixed
+18
View File
@@ -1592,6 +1592,24 @@ class AttachmentServiceTest extends TestCase
목록·상세 화면과 삭제(상품/카테고리/리뷰 삭제 포함)는 정상 동작합니다. 이 검증을
생략하면 미등록 디스크 접근이 예외가 되어 서빙과 삭제가 모두 500 이 됩니다.
### 버킷/CDN 쪽 요구사항
직접 URL 서빙은 브라우저가 그 URL 을 **인증 없이** 읽는다는 전제 위에 있습니다.
따라서 대상 버킷/배포는 다음을 만족해야 합니다.
| 요구사항 | 미충족 시 증상 |
| --- | --- |
| 객체가 익명 읽기 가능 (버킷 정책 `s3:GetObject` 공개 또는 CDN 공개 배포) | 화면의 그 이미지들이 전부 깨짐 (S3 는 `403 AccessDenied` 를 XML 로 응답) |
| 공개 URL base(`S3 URL`)가 그 객체를 가리킴 | `url()` 이 null → 스트리밍 폴백(기능은 정상, CDN 이점만 없음) |
관리자 화면의 썸네일은 교차 출처 공개 URL 을 `<img>` 로 직접 사용하므로 CORS 설정은
필요하지 않습니다. 다만 그 URL 을 자바스크립트로 읽는 커스텀 확장을 만든다면 그때는
버킷/CDN 에 CORS 규칙이 필요합니다.
교차 출처 URL 에는 코어 API 클라이언트가 인증 토큰을 붙이지 않습니다 — 공개 자산은
인증이 필요 없고, 세션 토큰이 제3자 origin 으로 나가서는 안 되기 때문입니다.
같은 이유로 첨부 썸네일·다운로드도 교차 출처 URL 이면 인증 요청 대신 URL 을 직접 씁니다.
### 서명 URL 등 커스텀 URL 공급
`core.storage.filter_url` 필터 훅이 디스크 무관 항상 발화하므로, 확장이 서명 URL 을
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+31 -1
View File
@@ -155,8 +155,12 @@ class ApiClient {
config.baseURL = '';
}
// 인증 토큰은 동일 출처 요청에만 첨부한다. 공개 자산 디스크(S3/CDN)를 켜면
// 첨부 URL 이 외부 origin 절대 URL 이 되는데, 그 요청에 Authorization 을 실으면
// ① 세션 토큰이 제3자 origin 의 접근 로그에 남고
// ② 안전목록 밖 헤더라 preflight 가 발생해 CORS 미설정 CDN 에서는 이미지가 통째로 실패한다.
const token = this.getToken();
if (token && config.headers) {
if (token && config.headers && !this.isCrossOriginRequest(config.url)) {
config.headers.Authorization = `Bearer ${token}`;
}
@@ -435,6 +439,32 @@ class ApiClient {
}
}
/**
* 요청 URL 이 현재 문서와 다른 출처인지 판정합니다.
*
* 상대 경로는 항상 동일 출처입니다. 절대 URL(`https://…`)과 프로토콜 상대
* URL(`//host/…`)만 출처를 비교하며, 파싱 불가하거나 브라우저 밖(SSR/테스트)
* 이면 동일 출처로 간주해 기존 동작을 유지합니다.
*
* @param url 요청 URL (Axios config.url)
* @return bool 교차 출처 여부
*/
private isCrossOriginRequest(url?: string): boolean {
if (!url || typeof window === 'undefined' || !window.location) {
return false;
}
if (!/^(https?:)?\/\//i.test(url)) {
return false;
}
try {
return new URL(url, window.location.href).origin !== window.location.origin;
} catch {
return false;
}
}
/**
* 요청 설정에서 전체 URL을 생성합니다.
*
@@ -274,4 +274,79 @@ describe('ApiClient', () => {
);
});
});
// @scenario consumer=product, disk_setting=fake_cdn, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=new_remote_row
// @effects cross_origin_asset_request_omits_session_token
describe('교차 출처 요청의 인증 헤더 차단', () => {
/**
* 요청 인터셉터를 캡처해 임의 config 로 실행합니다.
*
* @returns 캡처된 요청 인터셉터
*/
function buildClientCapturingRequest(): (config: any) => any {
let captured: ((config: any) => any) | null = null;
mockedAxios.create.mockReturnValue({
interceptors: {
request: {
use: vi.fn((onFulfilled: any) => {
captured = onFulfilled;
return 0;
}),
},
response: { use: vi.fn(() => 0) },
},
get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(),
});
new ApiClient();
return captured!;
}
beforeEach(() => {
localStorage.setItem('auth_token', 'secret-token');
});
it('동일 출처 상대 경로에는 Authorization 을 첨부한다', () => {
const onRequest = buildClientCapturingRequest();
const config = onRequest({ url: '/api/attachments/abc', headers: {}, baseURL: '/api' });
expect(config.headers.Authorization).toBe('Bearer secret-token');
});
it('동일 출처 절대 URL 에는 Authorization 을 첨부한다', () => {
const onRequest = buildClientCapturingRequest();
const config = onRequest({
url: `${window.location.origin}/api/attachments/abc`,
headers: {},
baseURL: '/api',
});
expect(config.headers.Authorization).toBe('Bearer secret-token');
});
it('교차 출처 절대 URL(공개 자산 CDN)에는 Authorization 을 첨부하지 않는다', () => {
const onRequest = buildClientCapturingRequest();
const config = onRequest({
url: 'https://cdn.example.com/bucket/products/a.png',
headers: {},
baseURL: '/api',
});
expect(config.headers.Authorization).toBeUndefined();
});
it('프로토콜 상대 URL(//cdn) 도 교차 출처면 Authorization 을 첨부하지 않는다', () => {
const onRequest = buildClientCapturingRequest();
const config = onRequest({
url: '//cdn.example.com/bucket/products/a.png',
headers: {},
baseURL: '/api',
});
expect(config.headers.Authorization).toBeUndefined();
});
});
});
@@ -5,6 +5,19 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.58.3] - 2026-08-14
### Fixed
#### 교차 출처(공개 자산 CDN) 요청에 세션 토큰이 실리고 이미지가 통째로 실패하던 문제
- `api/ApiClient.ts::setupInterceptors` — 요청 인터셉터가 `Authorization` 을 **동일 출처 요청에만** 첨부한다. 판정은 신설 `isCrossOriginRequest()` 가 담당하며, 상대 경로는 항상 동일 출처, 절대 URL(`https://…`)과 프로토콜 상대 URL(`//host/…`)만 `window.location.origin` 과 비교한다(파싱 실패·SSR 은 동일 출처로 간주해 기존 동작 유지).
- 공개 자산 디스크(S3/CDN) 옵트인이 켜지면 첨부·이미지의 `download_url` 이 외부 origin 절대 URL 이 된다. 종전 인터셉터는 URL 출처를 보지 않고 토큰을 붙였으므로 두 가지가 동시에 발생했다.
1. **토큰 노출** — 관리자 세션 토큰이 제3자 CDN origin 으로 전송된다. CDN 이 `Access-Control-Allow-Origin` 을 허용하는 흔한 구성에서는 요청이 성공하므로 그 origin 의 접근 로그에 토큰이 남는다.
2. **이미지 전면 실패** — `Authorization` 은 CORS 안전목록 밖이라 preflight 가 발생한다. 버킷/배포에 CORS 규칙이 없으면(AWS S3 기본값) preflight 가 거절되어 그 이미지가 전부 깨진다.
- 실측(AWS S3 실 버킷, 관리자 상품 이미지 탭): 요청 헤더에 `authorization: Bearer …` 가 실린 채 `net::ERR_FAILED` + 콘솔 CORS 오류 15건. 같은 이미지를 `<img>` 로 여는 상점 화면은 CORS 대상이 아니라 정상이어서, **관리자 화면에서만** 증상이 나타난다.
- 로컬 `public` 디스크(동일 출처)나 CORS 를 허용하는 개발용 오브젝트 스토리지에서는 재현되지 않는다 — 두 조건(외부 origin + CORS 미허용)이 겹쳐야 드러난다.
## [engine-v1.58.2] - 2026-08-10
### Fixed
@@ -9,7 +9,8 @@
### Added
- 환경설정 > 드라이버 > 파일 스토리지에 S3 호환 스토리지 설정이 추가되었습니다 — 엔드포인트 URL 입력칸과 Path-style 주소 토글로 Cloudflare R2, MinIO 등도 연결할 수 있습니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
- 환경설정 > 드라이버 탭에 「공개 자산 스토리지」 카드가 추가되었습니다. 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지 등)을 직접 URL(CDN)로 서빙할 디스크를 고를 수 있으며, 기본값(사용 안 함)이면 종전 스트리밍 방식 그대로입니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
- 환경설정 > 드라이버 탭에 「공개 자산 스토리지」 카드가 추가되었습니다. 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지 등)을 직접 URL(CDN)로 서빙할 디스크를 고를 수 있으며, 기본값(사용 안 함)이면 종전 스트리밍 방식 그대로입니다. 파일 스토리지는 로컬로 두고 공개 자산만 S3 로 보내는 구성도 가능하며, 이 경우에도 S3 접속 정보 입력칸이 함께 표시되고 저장 전 연결 테스트를 요구합니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
- 공개 자산 스토리지를 외부 주소(CDN)로 설정하면 관리자 화면의 첨부·이미지 미리보기도 그 주소를 그대로 사용합니다 — 별도의 스토리지 CORS 설정 없이 표시됩니다.
### Changed
@@ -0,0 +1,102 @@
/**
* 공개 자산(S3/CDN) 교차 출처 URL 의 인증 요청 차단 회귀 테스트
*
* 공개 자산 스토리지를 켜면 첨부·이미지의 `download_url` 이 외부 origin 절대 URL 이 된다.
* 그 URL 을 인증 XHR(Blob)로 가져오면
* ① CORS 헤더를 주지 않는 CDN(기본 설정 S3 버킷 등)에서 이미지가 통째로 실패하고
* ② 응답을 읽을 수 있는 CDN 이라면 세션 토큰이 제3자 origin 으로 전송된다.
* 두 경로 모두 브라우저 실측으로 확인된 결함이므로, 교차 출처 URL 은
* 인증 요청 없이 그대로 사용해야 한다.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { isCrossOriginAssetUrl } from '../../src/components/composite/assetOrigin';
import { SortableThumbnailItem } from '../../src/components/composite/FileUploader/SortableThumbnailItem';
const apiGet = vi.fn();
beforeEach(() => {
apiGet.mockReset();
apiGet.mockResolvedValue(new Blob(['x'], { type: 'image/png' }));
// 전역 G7Core 객체는 setup 이 만든 것을 그대로 쓰고 api 만 주입한다
// (컴포넌트가 모듈 로드 시점에 같은 객체 참조를 캡처하므로 교체하면 안 된다)
(window as any).G7Core.api = { get: apiGet };
(window as any).URL.createObjectURL = vi.fn(() => 'blob:mock-object-url');
(window as any).URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('isCrossOriginAssetUrl', () => {
it('상대 경로(API 스트리밍)는 동일 출처로 판정한다', () => {
expect(isCrossOriginAssetUrl('/api/modules/sirsoft-ecommerce/product-image/abc')).toBe(false);
});
it('동일 출처 절대 URL 은 동일 출처로 판정한다', () => {
expect(isCrossOriginAssetUrl(`${window.location.origin}/storage/a.png`)).toBe(false);
});
it('외부 CDN 절대 URL 은 교차 출처로 판정한다', () => {
expect(isCrossOriginAssetUrl('https://bucket.s3.ap-southeast-2.amazonaws.com/a.png')).toBe(true);
});
it('프로토콜 상대 URL 도 호스트가 다르면 교차 출처로 판정한다', () => {
expect(isCrossOriginAssetUrl('//cdn.example.com/a.png')).toBe(true);
});
it('빈 값은 동일 출처로 간주해 기존 경로를 유지한다', () => {
expect(isCrossOriginAssetUrl(undefined)).toBe(false);
expect(isCrossOriginAssetUrl('')).toBe(false);
});
});
/**
* 썸네일 항목 렌더용 첨부 픽스처를 만듭니다.
*
* @param downloadUrl 첨부의 download_url
* @returns 첨부 객체
*/
const makeAttachment = (downloadUrl: string) => ({
id: 1,
hash: 'h1',
original_filename: 'probe.png',
size: 100,
mime_type: 'image/png',
is_image: true,
download_url: downloadUrl,
});
// @scenario consumer=product, disk_setting=fake_cdn, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=new_remote_row
// @effects cross_origin_asset_request_omits_session_token
describe('SortableThumbnailItem 의 공개 자산 URL 처리', () => {
it('동일 출처 URL 은 종전대로 인증 요청으로 이미지를 로드한다', async () => {
render(
<SortableThumbnailItem
file={makeAttachment('/api/modules/sirsoft-ecommerce/product-image/abc') as any}
onRemove={vi.fn()}
/>
);
await waitFor(() => expect(apiGet).toHaveBeenCalledTimes(1));
expect(apiGet).toHaveBeenCalledWith(
'/api/modules/sirsoft-ecommerce/product-image/abc',
{ responseType: 'blob' }
);
});
it('교차 출처 공개 자산 URL 은 인증 요청 없이 그대로 사용한다', async () => {
const cdnUrl = 'https://bucket.s3.ap-southeast-2.amazonaws.com/images/products/a.png';
render(<SortableThumbnailItem file={makeAttachment(cdnUrl) as any} onRemove={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole('img')).toHaveAttribute('src', cdnUrl);
});
expect(apiGet).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,250 @@
/**
* @file admin-settings-drivers-s3-visibility.test.tsx
* @description S3 접속 정보 블록 노출 조건 테스트 (공개 #99 × #100 교차)
*
* 파일 스토리지는 로컬로 두고 공개 자산만 S3/CDN 으로 보내는 구성(이슈 #100 이
* 제안한 "공개 자산 전용 디스크 분리")에서도 버킷·키·엔드포인트·공개 URL 을
* 입력할 수 있어야 한다. 종전에는 s3 블록이 `storage_driver === 's3'` 일 때만
* 렌더되어, 공개 자산 카드가 S3 를 제공하는데 그 접속 정보를 넣을 화면이
* 없었다(도움말은 보이지 않는 필드를 가리켰다).
*/
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
const driversPartial = JSON.parse(
readFileSync(resolve(__dirname, '../../layouts/partials/admin_settings/_tab_drivers.json'), 'utf-8')
);
const settingsLayout = JSON.parse(
readFileSync(resolve(__dirname, '../../layouts/admin_settings.json'), 'utf-8')
);
// ---------------------------------------------------------------------------
// 테스트용 컴포넌트
// ---------------------------------------------------------------------------
const TestDiv: React.FC<any> = ({ className, children }) => <div className={className}>{children}</div>;
const TestInput: React.FC<any> = ({ name, type }) => <input name={name} type={type} data-testid={name} />;
const TestSelect: React.FC<any> = ({ name }) => <select name={name} data-testid={`select-${name}`} />;
const TestToggle: React.FC<any> = ({ name }) => (
<input type="checkbox" role="switch" name={name} data-testid={`toggle-${name}`} />
);
const TestButton: React.FC<any> = ({ children, text }) => <button type="button">{children || text}</button>;
const TestSpan: React.FC<any> = ({ children, text }) => <span>{children || text}</span>;
const TestP: React.FC<any> = ({ children, text }) => <p>{children || text}</p>;
const TestLabel: React.FC<any> = ({ children, text }) => <label>{children || text}</label>;
const TestIcon: React.FC<any> = ({ name }) => <i data-icon={name} />;
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
/**
* 테스트용 컴포넌트 레지스트리를 구성합니다.
*
* @returns 구성된 레지스트리
*/
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
Select: { component: TestSelect, metadata: { name: 'Select', type: 'composite' } },
Toggle: { component: TestToggle, metadata: { name: 'Toggle', type: 'composite' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
/**
* id 로 노드를 깊이 우선 탐색합니다.
*
* @param node 탐색 시작 노드
* @param id 찾을 노드 id
* @returns 찾은 노드 또는 null
*/
function findNodeById(node: any, id: string): any {
if (!node || typeof node !== 'object') return null;
if (node.id === id) return node;
for (const child of node.children ?? []) {
const found = findNodeById(child, id);
if (found) return found;
}
return null;
}
/**
* partial 루트들에서 id 노드를 찾습니다.
*
* @param id 찾을 노드 id
* @returns 찾은 노드 또는 null
*/
function findInPartial(id: string): any {
for (const root of driversPartial.components ?? [driversPartial]) {
const found = findNodeById(root, id);
if (found) return found;
}
return null;
}
/**
* 주어진 드라이버 폼 상태로 s3 블록을 렌더합니다.
*
* @param drivers 폼의 drivers 하위 상태
* @returns 레이아웃 테스트 유틸
*/
function renderS3Block(drivers: Record<string, unknown>) {
const s3Settings = findInPartial('s3_settings');
expect(s3Settings).not.toBeNull();
return createLayoutTest(
{
version: '1.0.0',
layout_name: 'test_drivers_s3_visibility',
components: [s3Settings],
} as any,
{
initialState: {
_local: {
form: { drivers },
errors: {},
},
},
}
);
}
const BASE_DRIVERS = {
s3_bucket: '',
s3_region: '',
s3_access_key: '',
s3_secret_key: '',
s3_endpoint: '',
s3_use_path_style: false,
s3_url: '',
};
// @scenario consumer=product, disk_setting=s3_without_url, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=new_remote_row
// @effects s3_credentials_visible_when_public_asset_disk_is_s3
describe('S3 접속 정보 블록 노출 조건', () => {
let registry: ComponentRegistry;
beforeEach(() => {
registry = setupTestRegistry();
});
afterEach(() => {
(registry as any).registry = {};
});
it('파일 스토리지가 s3 면 종전대로 렌더된다', async () => {
const testUtils = renderS3Block({
...BASE_DRIVERS,
storage_driver: 's3',
public_asset_disk: 'none',
});
await testUtils.render();
expect(screen.getByTestId('drivers.s3_bucket')).toBeInTheDocument();
expect(screen.getByTestId('drivers.s3_url')).toBeInTheDocument();
testUtils.cleanup();
});
it('파일 스토리지가 로컬이어도 공개 자산 디스크가 s3 면 접속 필드가 렌더된다', async () => {
const testUtils = renderS3Block({
...BASE_DRIVERS,
storage_driver: 'local',
public_asset_disk: 's3',
});
await testUtils.render();
for (const field of ['s3_bucket', 's3_region', 's3_access_key', 's3_secret_key', 's3_endpoint', 's3_url']) {
expect(screen.getByTestId(`drivers.${field}`)).toBeInTheDocument();
}
expect(screen.getByTestId('toggle-drivers.s3_use_path_style')).toBeInTheDocument();
testUtils.cleanup();
});
it('두 축 모두 s3 가 아니면 접속 필드가 렌더되지 않는다', async () => {
const present = renderS3Block({
...BASE_DRIVERS,
storage_driver: 's3',
public_asset_disk: 'none',
});
await present.render();
// 존재를 먼저 확정한 뒤에 부재를 단언한다 (부재 단독 단언 금지 규율)
expect(screen.getByTestId('drivers.s3_bucket')).toBeInTheDocument();
present.cleanup();
const absent = renderS3Block({
...BASE_DRIVERS,
storage_driver: 'local',
public_asset_disk: 'public',
});
await absent.render();
expect(screen.queryByTestId('drivers.s3_bucket')).toBeNull();
absent.cleanup();
});
});
describe('저장 게이트의 S3 연결 테스트 요구 조건', () => {
/**
* 저장 버튼의 disabled 표현식을 찾습니다.
*
* @param node 탐색 시작 노드
* @returns disabled 표현식 문자열 또는 null
*/
function findSaveDisabledExpression(node: any): string | null {
if (!node || typeof node !== 'object') return null;
const disabled = node.props?.disabled;
if (typeof disabled === 'string' && disabled.includes('driverTestResults')) {
return disabled;
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const item of value) {
const found = findSaveDisabledExpression(item);
if (found) return found;
}
} else if (value && typeof value === 'object') {
const found = findSaveDisabledExpression(value);
if (found) return found;
}
}
return null;
}
it('공개 자산 디스크가 s3 인 경우도 연결 테스트 성공을 요구한다', () => {
const expression = findSaveDisabledExpression(settingsLayout);
expect(expression).not.toBeNull();
expect(expression).toContain(
"(_local.form?.drivers?.storage_driver === 's3' || _local.form?.drivers?.public_asset_disk === 's3')"
);
});
it('재검증 트리거는 S3 접속 설정 변경으로 한정된다 (공개 자산 디스크 전환 자체는 트리거 아님)', () => {
const expression = findSaveDisabledExpression(settingsLayout);
// 파일 스토리지가 s3 인 상태에서 공개 자산 디스크를 public 등으로 바꾸는 것은
// S3 접속 설정과 무관하므로 연결 테스트를 다시 요구해서는 안 된다.
expect(expression).not.toContain(
"(_local.form?.drivers?.public_asset_disk || '') !== (_local.originalDrivers?.public_asset_disk || '')"
);
// 접속 설정 변경은 종전대로 트리거로 남아 있어야 한다 (대조군)
expect(expression).toContain(
"(_local.form?.drivers?.s3_bucket || '') !== (_local.originalDrivers?.s3_bucket || '')"
);
});
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
/**
* 자산 URL 출처 판정 유틸리티
*
* 공개 자산 스토리지(S3/CDN)를 켜면 첨부·이미지의 `download_url` 이 외부 origin
* 절대 URL 이 된다. 이런 URL 은 인증이 필요 없는 공개 자산이므로 XHR(Blob) 로
* 가져오면 안 된다 — 교차 출처 XHR 은 CDN 이 CORS 헤더를 주지 않는 한 응답을
* 읽지 못해 이미지가 통째로 실패하고, 응답을 읽을 수 있는 CDN 이라면 이번에는
* 세션 토큰이 제3자 origin 으로 나간다. 교차 출처 URL 은 `<img src>` / 링크로
* 직접 사용한다.
*
* @module composite/assetOrigin
*/
/**
* URL 이 현재 문서와 다른 출처인지 판정합니다.
*
* 상대 경로는 항상 동일 출처입니다. 절대 URL(`https://…`)과 프로토콜 상대
* URL(`//host/…`)만 출처를 비교하며, 파싱 불가하거나 브라우저 밖이면
* 동일 출처로 간주합니다(기존 인증 경로 유지).
*
* @param url 판정할 URL
* @returns 교차 출처 여부
*/
export declare const isCrossOriginAssetUrl: (url?: string | null) => boolean;
@@ -694,7 +694,7 @@
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{!_local.hasChanges || _local.isSaving || ((_global.activeSettingsTab || query.tab) === 'drivers' && ((_local.form?.drivers?.storage_driver === 's3' && !_local.driverTestResults?.s3?.success && ((_local.form?.drivers?.storage_driver || '') !== (_local.originalDrivers?.storage_driver || '') || (_local.form?.drivers?.s3_bucket || '') !== (_local.originalDrivers?.s3_bucket || '') || (_local.form?.drivers?.s3_region || '') !== (_local.originalDrivers?.s3_region || '') || (_local.form?.drivers?.s3_access_key || '') !== (_local.originalDrivers?.s3_access_key || '') || (_local.form?.drivers?.s3_secret_key || '') !== (_local.originalDrivers?.s3_secret_key || '') || (_local.form?.drivers?.s3_endpoint || '') !== (_local.originalDrivers?.s3_endpoint || '') || Boolean(_local.form?.drivers?.s3_use_path_style) !== Boolean(_local.originalDrivers?.s3_use_path_style))) || (_local.form?.drivers?.cache_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.cache_driver || '') !== (_local.originalDrivers?.cache_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.cache_driver === 'memcached' && !_local.driverTestResults?.memcached?.success && ((_local.form?.drivers?.cache_driver || '') !== (_local.originalDrivers?.cache_driver || '') || (_local.form?.drivers?.memcached_host || '') !== (_local.originalDrivers?.memcached_host || '') || String(_local.form?.drivers?.memcached_port ?? '') !== String(_local.originalDrivers?.memcached_port ?? ''))) || (_local.form?.drivers?.session_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.session_driver || '') !== (_local.originalDrivers?.session_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.queue_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.queue_driver || '') !== (_local.originalDrivers?.queue_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.websocket_enabled && !_local.driverTestResults?.websocket?.success && (Boolean(_local.form?.drivers?.websocket_enabled) !== Boolean(_local.originalDrivers?.websocket_enabled) || (_local.form?.drivers?.websocket_host || '') !== (_local.originalDrivers?.websocket_host || '') || String(_local.form?.drivers?.websocket_port ?? '') !== String(_local.originalDrivers?.websocket_port ?? '') || (_local.form?.drivers?.websocket_scheme || '') !== (_local.originalDrivers?.websocket_scheme || '')))))}}",
"disabled": "{{!_local.hasChanges || _local.isSaving || ((_global.activeSettingsTab || query.tab) === 'drivers' && (((_local.form?.drivers?.storage_driver === 's3' || _local.form?.drivers?.public_asset_disk === 's3') && !_local.driverTestResults?.s3?.success && ((_local.form?.drivers?.storage_driver || '') !== (_local.originalDrivers?.storage_driver || '') || (_local.form?.drivers?.s3_bucket || '') !== (_local.originalDrivers?.s3_bucket || '') || (_local.form?.drivers?.s3_region || '') !== (_local.originalDrivers?.s3_region || '') || (_local.form?.drivers?.s3_access_key || '') !== (_local.originalDrivers?.s3_access_key || '') || (_local.form?.drivers?.s3_secret_key || '') !== (_local.originalDrivers?.s3_secret_key || '') || (_local.form?.drivers?.s3_endpoint || '') !== (_local.originalDrivers?.s3_endpoint || '') || Boolean(_local.form?.drivers?.s3_use_path_style) !== Boolean(_local.originalDrivers?.s3_use_path_style))) || (_local.form?.drivers?.cache_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.cache_driver || '') !== (_local.originalDrivers?.cache_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.cache_driver === 'memcached' && !_local.driverTestResults?.memcached?.success && ((_local.form?.drivers?.cache_driver || '') !== (_local.originalDrivers?.cache_driver || '') || (_local.form?.drivers?.memcached_host || '') !== (_local.originalDrivers?.memcached_host || '') || String(_local.form?.drivers?.memcached_port ?? '') !== String(_local.originalDrivers?.memcached_port ?? ''))) || (_local.form?.drivers?.session_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.session_driver || '') !== (_local.originalDrivers?.session_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.queue_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.queue_driver || '') !== (_local.originalDrivers?.queue_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.websocket_enabled && !_local.driverTestResults?.websocket?.success && (Boolean(_local.form?.drivers?.websocket_enabled) !== Boolean(_local.originalDrivers?.websocket_enabled) || (_local.form?.drivers?.websocket_host || '') !== (_local.originalDrivers?.websocket_host || '') || String(_local.form?.drivers?.websocket_port ?? '') !== String(_local.originalDrivers?.websocket_port ?? '') || (_local.form?.drivers?.websocket_scheme || '') !== (_local.originalDrivers?.websocket_scheme || '')))))}}",
"className": "btn btn-primary flex-center gap-1.5"
},
"actions": [
@@ -90,7 +90,8 @@
"id": "s3_settings",
"type": "basic",
"name": "Div",
"if": "{{_local.form?.drivers?.storage_driver === 's3'}}",
"comment": "공개 자산 스토리지만 s3 인 구성(파일 스토리지는 로컬)에서도 접속 정보를 입력해야 하므로 두 축 중 하나라도 s3 면 노출한다",
"if": "{{_local.form?.drivers?.storage_driver === 's3' || _local.form?.drivers?.public_asset_disk === 's3'}}",
"props": {
"className": "ml-4 pl-4 border-l-2 border-blue-200 dark:border-blue-800 space-y-4"
},
@@ -19,6 +19,7 @@ import { Img } from '../../basic/Img';
import type { Attachment, PendingFile } from './types';
import { getFileIcon, t } from './utils';
import { isCrossOriginAssetUrl } from '../assetOrigin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const G7Core = (window as any).G7Core;
@@ -89,6 +90,13 @@ export const SortableThumbnailItem: React.FC<SortableThumbnailItemProps> = ({
return;
}
// 공개 자산 디스크(S3/CDN)가 준 교차 출처 URL 은 인증이 필요 없는 공개 자산이다.
// XHR 로 가져오면 CORS 미설정 CDN 에서 실패하므로 URL 을 그대로 사용한다.
if (isCrossOriginAssetUrl(downloadUrl)) {
setAuthenticatedImageUrl(downloadUrl);
return;
}
let isMounted = true;
let objectUrl: string | undefined;
@@ -22,6 +22,7 @@ import imageCompression from 'browser-image-compression';
import type { Attachment, PendingFile, FileUploaderProps, ApiEndpoints } from './types';
import { formatFileSize, extractErrorMessage, t } from './utils';
import { isCrossOriginAssetUrl } from '../assetOrigin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const G7Core = (window as any).G7Core;
@@ -710,6 +711,14 @@ export function useFileUploader(options: UseFileUploaderOptions): UseFileUploade
// ref 기반 has 체크 (stale closure 방지)
if (authenticatedImageUrlsRef.current.has(file.id)) continue;
// 공개 자산 디스크(S3/CDN)의 교차 출처 URL 은 공개 자산이므로 XHR 없이 직접 사용한다.
// (CORS 미설정 CDN 에서의 실패 방지 + 제3자 origin 으로의 토큰 노출 차단)
if (isCrossOriginAssetUrl(file.download_url)) {
authenticatedImageUrlsRef.current.set(file.id, file.download_url);
setAuthenticatedImageUrls(new Map(authenticatedImageUrlsRef.current));
continue;
}
try {
const blob = await G7Core.api.get(file.download_url, {
responseType: 'blob',
@@ -15,6 +15,7 @@ import 'yet-another-react-lightbox/plugins/thumbnails.css';
import { Button } from '../basic/Button';
import { I } from '../basic/I';
import { isCrossOriginAssetUrl } from './assetOrigin';
import { Div } from '../basic/Div';
import { Img } from '../basic/Img';
import type { EditorAttrs } from '../../types';
@@ -137,7 +138,9 @@ export const executeImageDownload = async (image: GalleryImage): Promise<void> =
const downloadUrl = image.downloadUrl || image.src;
const filename = image.filename || image.title || 'image';
if (image.downloadRequiresAuth) {
// 공개 자산 디스크(S3/CDN)의 교차 출처 URL 은 인증이 필요 없는 공개 자산이다.
// 인증 XHR 로 가져오면 CORS 미설정 CDN 에서 실패하므로 일반 링크 다운로드를 쓴다.
if (image.downloadRequiresAuth && !isCrossOriginAssetUrl(downloadUrl)) {
await downloadAuthenticatedFile(downloadUrl, filename);
} else {
downloadFile(downloadUrl, filename);
@@ -0,0 +1,38 @@
/**
* 자산 URL 출처 판정 유틸리티
*
* 공개 자산 스토리지(S3/CDN)를 켜면 첨부·이미지의 `download_url` 이 외부 origin
* 절대 URL 이 된다. 이런 URL 은 인증이 필요 없는 공개 자산이므로 XHR(Blob) 로
* 가져오면 안 된다 — 교차 출처 XHR 은 CDN 이 CORS 헤더를 주지 않는 한 응답을
* 읽지 못해 이미지가 통째로 실패하고, 응답을 읽을 수 있는 CDN 이라면 이번에는
* 세션 토큰이 제3자 origin 으로 나간다. 교차 출처 URL 은 `<img src>` / 링크로
* 직접 사용한다.
*
* @module composite/assetOrigin
*/
/**
* URL 이 현재 문서와 다른 출처인지 판정합니다.
*
* 상대 경로는 항상 동일 출처입니다. 절대 URL(`https://…`)과 프로토콜 상대
* URL(`//host/…`)만 출처를 비교하며, 파싱 불가하거나 브라우저 밖이면
* 동일 출처로 간주합니다(기존 인증 경로 유지).
*
* @param url 판정할 URL
* @returns 교차 출처 여부
*/
export const isCrossOriginAssetUrl = (url?: string | null): boolean => {
if (!url || typeof window === 'undefined' || !window.location) {
return false;
}
if (!/^(https?:)?\/\//i.test(url)) {
return false;
}
try {
return new URL(url, window.location.href).origin !== window.location.origin;
} catch {
return false;
}
};
@@ -163,4 +163,69 @@ test.describe('공개 자산 스토리지 설정', () => {
await page.keyboard.press('Escape');
});
// @scenario consumer=product, disk_setting=fake_cdn, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=new_remote_row
// @effects cross_origin_asset_request_omits_session_token
test('교차 출처 공개 자산 요청에는 세션 토큰을 보내지 않는다', async ({ page }) => {
await authenticatePage(page, settingsToken);
await gotoDriversTab(page);
// 외부 CDN origin 을 가로채 요청 헤더를 실측한다 (CORS 헤더는 주지 않는다 —
// 기본 설정 S3 버킷과 동일한 조건)
const crossOriginUrl = 'https://cdn.example.invalid/bucket/products/probe.png';
const capturedHeaders: Record<string, string>[] = [];
await page.route(crossOriginUrl, async (route) => {
capturedHeaders.push(route.request().headers());
await route.fulfill({ status: 200, contentType: 'image/png', body: Buffer.from([]) });
});
const sameOriginHeaders: Record<string, string>[] = [];
await page.route('**/api/admin/settings?probe=1', async (route) => {
sameOriginHeaders.push(route.request().headers());
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"success":true}' });
});
await page.evaluate(async (url) => {
const api = (window as any).G7Core?.api;
await api.get(url, { responseType: 'blob' }).catch(() => undefined);
await api.get('/api/admin/settings?probe=1').catch(() => undefined);
}, crossOriginUrl);
await expect.poll(() => capturedHeaders.length, { timeout: 20_000 }).toBeGreaterThan(0);
await expect.poll(() => sameOriginHeaders.length, { timeout: 20_000 }).toBeGreaterThan(0);
// 동일 출처는 종전대로 토큰을 실어야 한다 (대조군 — 판정식이 살아 있음을 보장)
expect(sameOriginHeaders[0]['authorization']).toContain('Bearer');
// 교차 출처는 토큰이 없어야 한다 (제3자 origin 노출 + CORS preflight 유발 차단)
expect(capturedHeaders[0]['authorization']).toBeUndefined();
});
// @scenario consumer=product, disk_setting=s3_without_url, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=new_remote_row
// @effects s3_credentials_visible_when_public_asset_disk_is_s3
test('파일 스토리지가 로컬이어도 공개 자산이 S3 면 접속 필드가 노출된다', async ({ page }) => {
await authenticatePage(page, settingsToken);
await gotoDriversTab(page);
const storageSelect = '[name="drivers.storage_driver"]';
// 파일 스토리지를 로컬로 두고(저장하지 않는다 — 폼 상태만 변경)
await openAndReadOptions(page, storageSelect);
await page.locator('[role="listbox"] [role="option"]', { hasText: '로컬' }).first().click();
// 이 상태에서 s3 접속 필드는 없어야 한다 (존재 확정 전 부재 단언 회피 —
// 아래에서 같은 선택자로 존재를 확정한다)
const bucketInput = page.locator('input[name="drivers.s3_bucket"]');
await expect(bucketInput).toHaveCount(0);
// 공개 자산 디스크만 S3 로 바꾸면 접속 필드가 나타나야 한다
await openAndReadOptions(page, CORE_SELECT_ROOT);
await page.locator('[role="listbox"] [role="option"]', { hasText: 'Amazon S3' }).first().click();
await expect(bucketInput).toBeVisible({ timeout: 20_000 });
await expect(page.locator('input[name="drivers.s3_url"]')).toBeVisible();
await expect(page.locator('input[name="drivers.s3_endpoint"]')).toBeVisible();
// 저장하지 않고 이탈 — 사이트 설정을 바꾸지 않는다
await gotoDriversTab(page);
});
});
@@ -6,6 +6,10 @@
## [1.1.1] - 2026-08-11
### Added
- 공개 자산 스토리지(직접 URL/CDN)를 사용하는 사이트에서도 첨부 이미지 미리보기와 다운로드가 그 주소를 그대로 사용해 정상 동작합니다.
### Fixed
- 장바구니를 처음 열었을 때 "전체 선택" 옆 개수가 `(/27)` 처럼 선택 개수 없이 표시되던 문제를 수정했습니다. 이제 진입·새로고침·다른 화면에서 돌아온 경우 모두 `(27/27)` 로 바로 표시되며, 체크박스 상태 및 주문 요약의 "선택 상품 N / M개" 와 항상 같은 값을 가리킵니다. (#92 @koojunho 님께서 제보해주셨습니다.)
@@ -0,0 +1,102 @@
/**
* 공개 자산(S3/CDN) 교차 출처 URL 의 인증 요청 차단 회귀 테스트
*
* 공개 자산 스토리지를 켜면 첨부·이미지의 `download_url` 이 외부 origin 절대 URL 이 된다.
* 그 URL 을 인증 XHR(Blob)로 가져오면
* ① CORS 헤더를 주지 않는 CDN(기본 설정 S3 버킷 등)에서 이미지가 통째로 실패하고
* ② 응답을 읽을 수 있는 CDN 이라면 세션 토큰이 제3자 origin 으로 전송된다.
* 두 경로 모두 브라우저 실측으로 확인된 결함이므로, 교차 출처 URL 은
* 인증 요청 없이 그대로 사용해야 한다.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { isCrossOriginAssetUrl } from '../../src/components/composite/assetOrigin';
import { SortableThumbnailItem } from '../../src/components/composite/FileUploader/SortableThumbnailItem';
const apiGet = vi.fn();
beforeEach(() => {
apiGet.mockReset();
apiGet.mockResolvedValue(new Blob(['x'], { type: 'image/png' }));
// 전역 G7Core 객체는 setup 이 만든 것을 그대로 쓰고 api 만 주입한다
// (컴포넌트가 모듈 로드 시점에 같은 객체 참조를 캡처하므로 교체하면 안 된다)
(window as any).G7Core.api = { get: apiGet };
(window as any).URL.createObjectURL = vi.fn(() => 'blob:mock-object-url');
(window as any).URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('isCrossOriginAssetUrl', () => {
it('상대 경로(API 스트리밍)는 동일 출처로 판정한다', () => {
expect(isCrossOriginAssetUrl('/api/modules/sirsoft-ecommerce/product-image/abc')).toBe(false);
});
it('동일 출처 절대 URL 은 동일 출처로 판정한다', () => {
expect(isCrossOriginAssetUrl(`${window.location.origin}/storage/a.png`)).toBe(false);
});
it('외부 CDN 절대 URL 은 교차 출처로 판정한다', () => {
expect(isCrossOriginAssetUrl('https://bucket.s3.ap-southeast-2.amazonaws.com/a.png')).toBe(true);
});
it('프로토콜 상대 URL 도 호스트가 다르면 교차 출처로 판정한다', () => {
expect(isCrossOriginAssetUrl('//cdn.example.com/a.png')).toBe(true);
});
it('빈 값은 동일 출처로 간주해 기존 경로를 유지한다', () => {
expect(isCrossOriginAssetUrl(undefined)).toBe(false);
expect(isCrossOriginAssetUrl('')).toBe(false);
});
});
/**
* 썸네일 항목 렌더용 첨부 픽스처를 만듭니다.
*
* @param downloadUrl 첨부의 download_url
* @returns 첨부 객체
*/
const makeAttachment = (downloadUrl: string) => ({
id: 1,
hash: 'h1',
original_filename: 'probe.png',
size: 100,
mime_type: 'image/png',
is_image: true,
download_url: downloadUrl,
});
// @scenario consumer=product, disk_setting=fake_cdn, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=legacy_local_row
// @effects cross_origin_asset_request_omits_session_token
describe('SortableThumbnailItem 의 공개 자산 URL 처리', () => {
it('동일 출처 URL 은 종전대로 인증 요청으로 이미지를 로드한다', async () => {
render(
<SortableThumbnailItem
file={makeAttachment('/api/modules/sirsoft-ecommerce/product-image/abc') as any}
onRemove={vi.fn()}
/>
);
await waitFor(() => expect(apiGet).toHaveBeenCalledTimes(1));
expect(apiGet).toHaveBeenCalledWith(
'/api/modules/sirsoft-ecommerce/product-image/abc',
{ responseType: 'blob' }
);
});
it('교차 출처 공개 자산 URL 은 인증 요청 없이 그대로 사용한다', async () => {
const cdnUrl = 'https://bucket.s3.ap-southeast-2.amazonaws.com/images/products/a.png';
render(<SortableThumbnailItem file={makeAttachment(cdnUrl) as any} onRemove={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole('img')).toHaveAttribute('src', cdnUrl);
});
expect(apiGet).not.toHaveBeenCalled();
});
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -58,5 +58,5 @@ export interface AvatarUploaderProps {
/** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */
editorAttrs?: EditorAttrs;
}
export declare function AvatarUploader({ src, fallbackText, size, uploadEndpoint, deleteEndpoint, onUploadSuccess, onUploadError, onDeleteSuccess, onDeleteError, showDeleteButton, accept, maxSize, className, uploadButtonText, deleteButtonText, readOnly, confirmDelete, deleteConfirmMessage, confirmUpload, uploadConfirmMessage, uploadSuccessActions, uploadErrorActions, deleteSuccessActions, deleteErrorActions, id, editorAttrs, }: AvatarUploaderProps): import("react/jsx-runtime").JSX.Element;
export declare function AvatarUploader({ src, fallbackText, size, uploadEndpoint, deleteEndpoint, onUploadSuccess, onUploadError, onDeleteSuccess, onDeleteError, showDeleteButton, accept, maxSize, className, uploadButtonText, deleteButtonText, readOnly, confirmDelete, deleteConfirmMessage, confirmUpload, uploadConfirmMessage, uploadSuccessActions, uploadErrorActions, deleteSuccessActions, deleteErrorActions, id, editorAttrs, }: AvatarUploaderProps): import("react").JSX.Element;
export default AvatarUploader;
@@ -0,0 +1,23 @@
/**
* 자산 URL 출처 판정 유틸리티
*
* 공개 자산 스토리지(S3/CDN)를 켜면 첨부·이미지의 `download_url` 이 외부 origin
* 절대 URL 이 된다. 이런 URL 은 인증이 필요 없는 공개 자산이므로 XHR(Blob) 로
* 가져오면 안 된다 — 교차 출처 XHR 은 CDN 이 CORS 헤더를 주지 않는 한 응답을
* 읽지 못해 이미지가 통째로 실패하고, 응답을 읽을 수 있는 CDN 이라면 이번에는
* 세션 토큰이 제3자 origin 으로 나간다. 교차 출처 URL 은 `<img src>` / 링크로
* 직접 사용한다.
*
* @module composite/assetOrigin
*/
/**
* URL 이 현재 문서와 다른 출처인지 판정합니다.
*
* 상대 경로는 항상 동일 출처입니다. 절대 URL(`https://…`)과 프로토콜 상대
* URL(`//host/…`)만 출처를 비교하며, 파싱 불가하거나 브라우저 밖이면
* 동일 출처로 간주합니다(기존 인증 경로 유지).
*
* @param url 판정할 URL
* @returns 교차 출처 여부
*/
export declare const isCrossOriginAssetUrl: (url?: string | null) => boolean;
@@ -19,6 +19,7 @@ import { Img } from '../../basic/Img';
import type { Attachment, PendingFile } from './types';
import { getFileIcon, t } from './utils';
import { isCrossOriginAssetUrl } from '../assetOrigin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const G7Core = (window as any).G7Core;
@@ -79,6 +80,13 @@ export const SortableThumbnailItem: React.FC<SortableThumbnailItemProps> = ({
return;
}
// 공개 자산 디스크(S3/CDN)가 준 교차 출처 URL 은 인증이 필요 없는 공개 자산이다.
// XHR 로 가져오면 CORS 미설정 CDN 에서 실패하므로 URL 을 그대로 사용한다.
if (isCrossOriginAssetUrl(downloadUrl)) {
setAuthenticatedImageUrl(downloadUrl);
return;
}
let isMounted = true;
let objectUrl: string | undefined;
@@ -15,6 +15,7 @@ import imageCompression from 'browser-image-compression';
import type { Attachment, PendingFile, FileUploaderProps, ApiEndpoints } from './types';
import { formatFileSize, extractErrorMessage, t } from './utils';
import { isCrossOriginAssetUrl } from '../assetOrigin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const G7Core = (window as any).G7Core;
@@ -704,6 +705,14 @@ export function useFileUploader(options: UseFileUploaderOptions): UseFileUploade
// ref 기반 has 체크 (stale closure 방지)
if (authenticatedImageUrlsRef.current.has(file.id)) continue;
// 공개 자산 디스크(S3/CDN)의 교차 출처 URL 은 공개 자산이므로 XHR 없이 직접 사용한다.
// (CORS 미설정 CDN 에서의 실패 방지 + 제3자 origin 으로의 토큰 노출 차단)
if (isCrossOriginAssetUrl(file.download_url)) {
authenticatedImageUrlsRef.current.set(file.id, file.download_url);
setAuthenticatedImageUrls(new Map(authenticatedImageUrlsRef.current));
continue;
}
try {
const blob = await G7Core.api.get(file.download_url, {
responseType: 'blob',
@@ -20,6 +20,7 @@ import 'yet-another-react-lightbox/plugins/thumbnails.css';
import { Button } from '../basic/Button';
import { I } from '../basic/I';
import { isCrossOriginAssetUrl } from './assetOrigin';
import { Div } from '../basic/Div';
import { Img } from '../basic/Img';
import type { EditorAttrs } from '../../types';
@@ -134,7 +135,9 @@ export const executeImageDownload = async (image: GalleryImage): Promise<void> =
const downloadUrl = image.downloadUrl || image.src;
const filename = image.filename || image.title || 'image';
if (image.downloadRequiresAuth) {
// 공개 자산 디스크(S3/CDN)의 교차 출처 URL 은 인증이 필요 없는 공개 자산이다.
// 인증 XHR 로 가져오면 CORS 미설정 CDN 에서 실패하므로 일반 링크 다운로드를 쓴다.
if (image.downloadRequiresAuth && !isCrossOriginAssetUrl(downloadUrl)) {
await downloadAuthenticatedFile(downloadUrl, filename);
} else {
downloadFile(downloadUrl, filename);
@@ -0,0 +1,38 @@
/**
* 자산 URL 출처 판정 유틸리티
*
* 공개 자산 스토리지(S3/CDN)를 켜면 첨부·이미지의 `download_url` 이 외부 origin
* 절대 URL 이 된다. 이런 URL 은 인증이 필요 없는 공개 자산이므로 XHR(Blob) 로
* 가져오면 안 된다 — 교차 출처 XHR 은 CDN 이 CORS 헤더를 주지 않는 한 응답을
* 읽지 못해 이미지가 통째로 실패하고, 응답을 읽을 수 있는 CDN 이라면 이번에는
* 세션 토큰이 제3자 origin 으로 나간다. 교차 출처 URL 은 `<img src>` / 링크로
* 직접 사용한다.
*
* @module composite/assetOrigin
*/
/**
* URL 이 현재 문서와 다른 출처인지 판정합니다.
*
* 상대 경로는 항상 동일 출처입니다. 절대 URL(`https://…`)과 프로토콜 상대
* URL(`//host/…`)만 출처를 비교하며, 파싱 불가하거나 브라우저 밖이면
* 동일 출처로 간주합니다(기존 인증 경로 유지).
*
* @param url 판정할 URL
* @returns 교차 출처 여부
*/
export const isCrossOriginAssetUrl = (url?: string | null): boolean => {
if (!url || typeof window === 'undefined' || !window.location) {
return false;
}
if (!/^(https?:)?\/\//i.test(url)) {
return false;
}
try {
return new URL(url, window.location.href).origin !== window.location.origin;
} catch {
return false;
}
};
+6
View File
@@ -43,6 +43,8 @@ effects:
- settings_load_recursion_free
- global_setting_injects_core_config_with_none_normalized
- seo_guard_passes_absolute_cdn_url_unmodified
- cross_origin_asset_request_omits_session_token
- s3_credentials_visible_when_public_asset_disk_is_s3
test_files:
- tests/Unit/Extension/Storage/CoreStorageDriverTest.php
@@ -75,6 +77,10 @@ test_files:
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-public-asset-storage.test.tsx
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsPublicAssetDisk.test.tsx
- plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsPublicAssetDisk.test.tsx
- resources/js/core/api/__tests__/ApiClient.test.ts
- templates/_bundled/sirsoft-admin_basic/__tests__/components/assetOrigin.crossOriginAuth.test.tsx
- templates/_bundled/sirsoft-basic/__tests__/components/assetOrigin.crossOriginAuth.test.tsx
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-drivers-s3-visibility.test.tsx
rules_layer_coverage:
- rule: no-storage-disk-direct