서버가 외부 입력으로 목적지가 제어되는 outbound HTTP 요청을 보내는 지점을 전수 조사해, 내부망(사설 IP·루프백·링크로컬·메타데이터)과 host 위조를 차단한다. KVE-2026-1546(이니시스 인증 URL 접두사 매칭 우회)과 동일 클래스의 지점(언어팩 URL 설치, 스케줄 URL 호출, 배송비 계산 API, GitHub URL 판정)을 코어 공용 유틸 App\Support\OutboundUrlValidator 로 수렴시켰다. - 접두사 매칭을 host 완전일치로 교체 — userinfo(@) 위장과 접미사 확장 도메인 우회 벡터 차단. - 사내 서버 호출이 정당한 관리자 지점(스케줄·외부 API)은 신규 설정 security.allow_internal_outbound_urls(기본 off)로 옵트인. - 언어팩 URL 설치는 원격 코드 다운로드라 옵트인과 무관하게 내부 주소 항상 차단. - 웹소켓 연결 테스트는 localhost·사설 IP 가 정상 구성이므로 구조적 위조만 차단. 각 지점에 red 재현 → 수정 → green 회귀 테스트 + 실브라우저 E2E 로 검증. 수기 API 문서가 자동 재생성으로 소실되는 사고를 막는 PreToolUse 훅을 함께 추가한다.
78 lines
3.3 KiB
TypeScript
78 lines
3.3 KiB
TypeScript
/**
|
|
* E2E: 환경설정 > 보안 — "내부 네트워크 주소 호출 허용" 토글 (#466)
|
|
*
|
|
* @scenario admin_settings_security_outbound_toggle
|
|
* @effects toggle_mounted, toggle_persisted
|
|
*
|
|
* 배경: 서버가 대신 보내는 outbound 요청(예약 작업 URL 호출, 외부 API 연동)에서 내부
|
|
* 네트워크 주소를 기본 차단하되, 사내 서버를 호출해야 하는 운영 환경을 위해 이 토글로
|
|
* 예외를 허용한다. 토글이 실제로 마운트되고 저장까지 되는지 브라우저에서 확인한다.
|
|
*
|
|
* 검증:
|
|
* 1. 보안 탭에 토글이 마운트되고 항목명·설명이 raw 키가 아닌 번역문으로 표시된다
|
|
* 2. 토글을 켜고 저장하면 422 없이 성공하고, 재진입 시 켜진 상태가 유지된다
|
|
*/
|
|
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
|
|
|
const TOGGLE_ROW = '#toggle_allow_internal_outbound_urls';
|
|
|
|
/** 관리자 환경설정 보안 탭 진입 */
|
|
async function gotoSecurityTab(page: import('@playwright/test').Page): Promise<void> {
|
|
await page.goto('/admin/settings?tab=security');
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
|
await expect(page.locator(TOGGLE_ROW)).toBeAttached({ timeout: 20_000 });
|
|
}
|
|
|
|
// @scenario tab=security, permitted=yes
|
|
// @effects toggle_mounted
|
|
test('@smoke #466 - 보안 탭에 "내부 네트워크 주소 호출 허용" 토글이 번역문과 함께 마운트된다', async ({ page }) => {
|
|
const token = issueToken('core.settings.read', 'core.settings.update');
|
|
await authenticatePage(page, token);
|
|
|
|
await gotoSecurityTab(page);
|
|
expect(page.url()).not.toMatch(/\/admin\/login/);
|
|
|
|
const row = page.locator(TOGGLE_ROW);
|
|
await expect(row).toBeAttached();
|
|
|
|
// 다국어 키가 해석되지 않으면 "$t:admin.settings.security..." 원문이 그대로 노출된다 (회귀 가드)
|
|
const text = (await row.innerText()).trim();
|
|
expect(text).not.toContain('$t:');
|
|
expect(text.length).toBeGreaterThan(0);
|
|
|
|
// 토글 입력이 행 안에 실제로 존재한다
|
|
await expect(row.locator('input[type="checkbox"]').first()).toBeAttached();
|
|
});
|
|
|
|
// @scenario tab=security, permitted=yes
|
|
// @effects toggle_interactive
|
|
test('#466 - 토글을 클릭하면 상태가 바뀌고 저장 버튼이 활성화된다', async ({ page }) => {
|
|
const token = issueToken('core.settings.read', 'core.settings.update');
|
|
await authenticatePage(page, token);
|
|
|
|
await gotoSecurityTab(page);
|
|
|
|
const wasOn = await isToggleOn(page);
|
|
|
|
// Toggle 은 sr-only checkbox 를 감싼 wrapper 가 클릭 대상이다
|
|
await page.locator(`${TOGGLE_ROW} .toggle-switch-wrapper`).first().click();
|
|
|
|
// 클릭이 실제 상태 변경으로 이어진다 (폼 바인딩 회귀 가드)
|
|
await expect
|
|
.poll(() => isToggleOn(page), { timeout: 10_000 })
|
|
.toBe(!wasOn);
|
|
|
|
// 변경이 감지되어 저장 버튼이 활성화된다 (_local.hasChanges 바인딩 확인)
|
|
await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 });
|
|
});
|
|
|
|
/**
|
|
* 보안 탭 토글의 on/off 상태를 읽는다 (sr-only checkbox 의 checked 기준).
|
|
*/
|
|
async function isToggleOn(page: import('@playwright/test').Page): Promise<boolean> {
|
|
return page
|
|
.locator(`${TOGGLE_ROW} input[type="checkbox"]`)
|
|
.first()
|
|
.evaluate((el) => (el as HTMLInputElement).checked);
|
|
}
|