Merge pull request from gnuboard:HeuJung/issue562
fix(core,admin_basic,basic): 공개 자산 CDN 교차 출처 요청의 토큰 노출·CORS 실패 수정 + S3 접속 필드 노출 조건 확장
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- 공개 자산을 외부 주소(CDN)로 서빙할 때, 그 주소로 나가는 요청에는 로그인 토큰을 함께 보내지 않습니다 — 공개 자산은 인증이 필요 없고, 토큰이 외부 서비스로 전달되지 않아야 하기 때문입니다. 사이트 자신의 주소로 가는 요청은 종전과 동일합니다.
|
||||
- 확장 개발자용: 확장 캐시 계약 인터페이스에 카테고리별 스토리지 접근이 추가되었습니다. 코어가 제공하는 확장 베이스 클래스·서비스 프로바이더를 상속하는 확장은 수정 없이 호환되지만, 이 계약을 직접 구현하는 확장은 새 메서드를 구현해야 합니다 (번들 확장은 모두 반영 완료 — 최소 요구 코어 버전 7.0.7).
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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 을
|
||||
|
||||
+4
-4
File diff suppressed because one or more lines are too long
+32
-75
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
|
||||
+102
@@ -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();
|
||||
});
|
||||
});
|
||||
+250
@@ -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
+23
@@ -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": [
|
||||
|
||||
+2
-1
@@ -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"
|
||||
},
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
|
||||
+9
@@ -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;
|
||||
}
|
||||
};
|
||||
+65
@@ -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 님께서 제보해주셨습니다.)
|
||||
|
||||
+102
@@ -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
+14
-13
File diff suppressed because one or more lines are too long
+1
-1
@@ -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;
|
||||
|
||||
+23
@@ -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;
|
||||
+8
@@ -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;
|
||||
|
||||
|
||||
+9
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user