fix(core,admin_basic,basic): 공개 자산 CDN 교차 출처 요청의 토큰 노출·CORS 실패 수정 + S3 접속 필드 노출 조건 확장

공개 (S3 드라이버) × (공개 자산 CDN) 병합 결과를 실 AWS S3 로 교차 실측하다
발견한 결함과, 그 과정에서 드러난 설정 화면의 사각을 함께 처리한다.

## 교차 출처 공개 자산 요청

공개 자산 디스크를 켜면 첨부·이미지의 download_url 이 외부 origin 절대 URL 이 되는데,
API 클라이언트가 URL 출처를 보지 않고 Authorization 을 붙이고 있었다. 그 결과
① 세션 토큰이 제3자 origin 으로 전송되고(응답을 허용하는 CDN 에서는 접근 로그에 남는다)
② 안전목록 밖 헤더라 preflight 가 발생해 CORS 미설정 버킷에서는 이미지가 통째로 실패했다.
상점 화면은 <img> 라 무증상이고 관리자 화면에서만 발현해 발견이 늦었다.

- 코어: 동일 출처 요청에만 Authorization 첨부 (상대 경로는 항상 동일 출처로 간주)
- 템플릿 2종: 교차 출처 URL 은 인증 XHR 없이 그대로 사용 (썸네일·갤러리·다운로드 3지점씩)

로컬 public 디스크나 허용적 CORS 를 주는 오브젝트 스토리지에서는 재현되지 않는다 —
외부 origin 과 CORS 미제공이 겹쳐야 드러나므로 회귀 테스트는 두 조건을 모두 고정한다.

## S3 접속 필드 노출 조건

파일 스토리지는 로컬로 두고 공개 자산만 S3 로 보내는 구성에서 접속 정보를 넣을 화면이
없었다(도움말이 보이지 않는 필드를 가리켰다). 두 축 중 하나라도 s3 면 블록을 노출하고,
연결 테스트 게이트의 적용 대상도 같은 조건으로 넓힌다. 재검증 트리거는 S3 접속 설정
변경으로 한정한다 — 트리거까지 넓히면 "파일 스토리지=s3 에서 공개 자산만 변경" 하는
기존 흐름이 저장 불가가 된다.
This commit is contained in:
HeuJung
2026-08-14 10:32:24 +09:00
parent a5a7caf8ba
commit c341fe28a4
31 changed files with 959 additions and 192 deletions
@@ -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;
}
};