Files
Gnuboard7/tests/Playwright/specs/admin/settings-security-outbound-toggle.spec.ts
T
HeuJung c4beb116ad fix(security): outbound URL 내부주소·host 검증 전수 강화
서버가 외부 입력으로 목적지가 제어되는 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 훅을 함께 추가한다.
2026-07-14 09:17:00 +09:00

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);
}