Merge branch 'develop' of https://github.com/gnuboard/dev-g7 into develop
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다.
|
||||
- 셸 예약 작업의 허용 실행 파일 목록에 `bash`·`python` 같은 인터프리터를 등록했을 때, 인터프리터에 인라인 명령(`bash -c ...` 형태)을 실어 임의 명령이 실행되던 문제를 막았습니다. 이제 인터프리터 뒤에는 절대경로 스크립트 파일만 지정할 수 있으며, 인라인 코드 실행·상대경로·경로 조작이 담긴 명령은 저장·실행 양쪽에서 거부됩니다. 스크립트 파일을 주기 실행하는 정상 사용(예: `python /경로/작업.py`)은 그대로 동작합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1653)
|
||||
- 검색어·게시글 제목처럼 방문자가 입력한 값이 검색엔진용 구조화 데이터(JSON-LD)에 실릴 때, 스크립트 태그를 닫는 문자열을 끼워 넣으면 그 뒤의 내용이 방문자 브라우저에서 스크립트로 실행될 수 있던 문제를 수정했습니다. 검색엔진 봇에게 제공되는 화면이지만 특정 주소 파라미터로 일반 방문자에게도 같은 화면을 강제할 수 있어 실제 공격 경로가 됩니다. 이제 태그 문자를 이스케이프해 실행을 차단하며, 검색엔진이 읽는 구조화 데이터의 의미는 그대로 유지됩니다.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -81,6 +82,8 @@
|
||||
- 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다.
|
||||
- 확장·코어 업데이트가 중단되며 남은 임시 폴더와 오래된 백업본, 언어팩 설치 임시 파일이 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 사흘이 지난 임시 산출물과 30일이 지난 백업본(최신 1개는 보존)이 매일 자동 정리됩니다.
|
||||
- 개발 도구의 브라우저 콘솔 로그 파일이 하나의 파일에 무한히 쌓이던 문제를 수정했습니다. 이제 날짜별 파일로 나뉘어 7일간 보관됩니다.
|
||||
- 예약 작업 저장 화면에서 설치된 확장이 제공하는 Artisan 명령이 "예약 실행이 허용된 명령이 아닙니다" 로 거부되던 문제를 수정했습니다. 터미널에서의 검증은 통과하는 명령이 관리자 화면 저장에서만 거부되었습니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다.
|
||||
- 검색엔진 노출용 페이지 제목에서 타이틀 접미사가 제목과 붙어 표시되던 문제를 수정했습니다. 관리자 화면 안내대로 접미사를 공백으로 시작하게 입력해도 저장 시 선행 공백이 제거되어 "제목| 사이트명" 처럼 접착되었고, 페이지 제목이 없는 화면에서는 "| 사이트명" 처럼 구분자가 매달려 표시되었습니다. 이제 제목이 있으면 공백을 복원해 잇고, 제목이 없으면 구분자를 떼고 사이트명만 표시합니다.
|
||||
|
||||
## [7.0.6] - 2026-08-10
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@ class SeoMetaResolver
|
||||
return $this->resolveLocalizedValue($value);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
}
|
||||
@@ -428,6 +429,37 @@ class SeoMetaResolver
|
||||
return [$contextIdentifier, $rest];
|
||||
}
|
||||
|
||||
/**
|
||||
* `<title>` 조립용 접미사를 정규화합니다.
|
||||
*
|
||||
* 관리자 저장 경로의 TrimStrings 미들웨어가 접미사의 선행 공백을 제거하므로
|
||||
* ("` | 그누보드7`" 을 입력해도 "`| 그누보드7`" 로 저장된다), blade 의
|
||||
* `{{ $title }}{{ $titleSuffix }}` 단순 연결에서 제목과 구분자가 붙어 버린다.
|
||||
* 이 메서드가 조립 규칙의 SSoT 다:
|
||||
*
|
||||
* - 제목이 있고 접미사가 공백으로 시작하지 않으면 공백 하나를 복원해 잇는다.
|
||||
* - 제목이 비어 있으면 매달린 선행 구분자(`|`,`-`,`·`,`:`,`/`,`–`,`—`)를 떼어
|
||||
* 사이트명 부분만 남긴다 (홈처럼 페이지 제목이 없는 화면의 "`| 사이트명`" 방지).
|
||||
*
|
||||
* @param string $title 최종 페이지 제목 (filter 훅 적용 후)
|
||||
* @param string $suffix 코어 설정의 타이틀 접미사
|
||||
* @return string 조립에 사용할 접미사 (제목이 없으면 접미사가 곧 전체 제목)
|
||||
*/
|
||||
public static function composeTitleSuffix(string $title, string $suffix): string
|
||||
{
|
||||
if (trim($suffix) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($title === '') {
|
||||
$stripped = (string) preg_replace('/^[\s|·:\/\x{2013}\x{2014}-]+/u', '', $suffix);
|
||||
|
||||
return trim($stripped);
|
||||
}
|
||||
|
||||
return preg_match('/^\s/u', $suffix) === 1 ? $suffix : ' '.$suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 meta의 title을 해석합니다 (fallback용).
|
||||
*
|
||||
@@ -691,7 +723,15 @@ class SeoMetaResolver
|
||||
|
||||
$resolved = array_merge(['@context' => 'https://schema.org'], $structuredData);
|
||||
|
||||
return json_encode($resolved, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
// JSON-LD 는 `<script type="application/ld+json">` 안에 임베드된다. 사용자 제어 값
|
||||
// (검색어·상품명 등)에 포함된 `</script>` 가 스크립트 컨텍스트를 조기 종료하면 그
|
||||
// 뒤의 페이로드가 실행 가능한 script 요소로 재생성된다(반사형 XSS — 봇 렌더는
|
||||
// _escaped_fragment_ 로 일반 UA 도 강제됨). JSON_HEX_TAG 로 `<`·`>` 를 <·>
|
||||
// 로 이스케이프해 브레이크아웃을 차단한다. JSON_HEX_AMP 는 방어 심화.
|
||||
return json_encode(
|
||||
$resolved,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -790,9 +830,9 @@ class SeoMetaResolver
|
||||
* 구조화 데이터 하위 객체가 실질적으로 비어있는지 확인합니다.
|
||||
*
|
||||
* @type 키가 있는 객체에서, @type 외 필드 중 하나라도 빈 문자열이면
|
||||
* 해당 객체를 JSON-LD에서 제거합니다.
|
||||
* 예: aggregateRating의 ratingValue=""이면, bestRating="5"가 있더라도 제거됩니다.
|
||||
* Google 구조화 데이터 검증에서 필수 필드가 빈 값이면 에러로 처리되기 때문입니다.
|
||||
* 해당 객체를 JSON-LD에서 제거합니다.
|
||||
* 예: aggregateRating의 ratingValue=""이면, bestRating="5"가 있더라도 제거됩니다.
|
||||
* Google 구조화 데이터 검증에서 필수 필드가 빈 값이면 에러로 처리되기 때문입니다.
|
||||
*
|
||||
* @param array $resolved 평가된 구조화 데이터 객체
|
||||
* @return bool 비어있으면 true
|
||||
|
||||
@@ -491,7 +491,9 @@ class SeoRenderer implements SeoRendererInterface
|
||||
$viewData = [
|
||||
'locale' => $locale,
|
||||
'title' => $meta['title'],
|
||||
'titleSuffix' => $meta['titleSuffix'],
|
||||
// filter 훅이 title 을 바꿨을 수 있으므로 최종 title 기준으로 접미사를 정규화한다
|
||||
// (TrimStrings 로 선행 공백이 제거된 접미사 복원 + 빈 제목의 매달린 구분자 제거)
|
||||
'titleSuffix' => SeoMetaResolver::composeTitleSuffix((string) ($meta['title'] ?? ''), (string) ($meta['titleSuffix'] ?? '')),
|
||||
'description' => $meta['description'],
|
||||
'keywords' => $meta['keywords'],
|
||||
'canonicalUrl' => $canonicalUrl,
|
||||
|
||||
@@ -591,15 +591,88 @@ class ScheduleCommandValidator
|
||||
|
||||
$command = self::findRegisteredCommand($name);
|
||||
|
||||
if ($command === null) {
|
||||
if ($command !== null) {
|
||||
$class = get_class($command);
|
||||
|
||||
foreach ($namespaces as $namespace) {
|
||||
if (str_starts_with($class, $namespace)) {
|
||||
return $command->getDefinition();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$class = get_class($command);
|
||||
// 레지스트리에 없으면 프로바이더 선언 폴백 — HTTP 요청에서는 확장 프로바이더가
|
||||
// `runningInConsole()` 게이트로 커맨드 등록을 건너뛰어 `Artisan::all()` 에
|
||||
// 확장 커맨드가 존재하지 않는다. 관리자 화면의 스케줄 저장 검증이 바로 이
|
||||
// 문맥이므로, 등록 인스턴스가 없을 때는 활성 프로바이더의 `$commands` 선언을
|
||||
// 같은 기준(클래스 네임스페이스 + 실제 명령명 대조)으로 해석한다.
|
||||
return self::resolveProviderDeclaredCommandDefinition($name, $namespaces);
|
||||
}
|
||||
|
||||
foreach ($namespaces as $namespace) {
|
||||
if (str_starts_with($class, $namespace)) {
|
||||
return $command->getDefinition();
|
||||
/**
|
||||
* 활성 서비스 프로바이더가 `$commands` 프로퍼티로 선언한 확장 커맨드에서
|
||||
* 명령명을 해석해 정의를 돌려줍니다.
|
||||
*
|
||||
* 판정 기준은 레지스트리 경로와 동일하다 — 커맨드 **클래스**의 네임스페이스가
|
||||
* 확장 네임스페이스여야 하고, 요청된 이름은 그 클래스를 실제 인스턴스화해 얻은
|
||||
* 선언 명령명과 일치해야 한다(이름만으로 위조할 수 없다). 프로바이더가 커맨드
|
||||
* 목록을 생성자에서 동적으로 만들면 기본값이 비므로 이 폴백에는 걸리지 않는다
|
||||
* (fail-closed 유지).
|
||||
*
|
||||
* @param string $name 명령명
|
||||
* @param array<int, string> $namespaces 허용 확장 네임스페이스 접두사
|
||||
* @return InputDefinition|null 해석 불가하면 null
|
||||
*/
|
||||
private static function resolveProviderDeclaredCommandDefinition(string $name, array $namespaces): ?InputDefinition
|
||||
{
|
||||
try {
|
||||
$providers = app()->getLoadedProviders();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (array_keys($providers) as $providerClass) {
|
||||
if (! class_exists($providerClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$defaults = (new \ReflectionClass($providerClass))->getDefaultProperties();
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ((array) ($defaults['commands'] ?? []) as $commandClass) {
|
||||
if (! is_string($commandClass) || ! class_exists($commandClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$owned = false;
|
||||
foreach ($namespaces as $namespace) {
|
||||
if (str_starts_with($commandClass, $namespace)) {
|
||||
$owned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $owned) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var SymfonyCommand $instance */
|
||||
$instance = app()->make($commandClass);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $instance instanceof SymfonyCommand || $instance->getName() !== $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $instance->getDefinition();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-13
File diff suppressed because one or more lines are too long
@@ -5,6 +5,16 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.60.5] - 2026-08-18
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 화살표/함수 파라미터 배열 구조분해 미지원 회귀 (장바구니/바로구매 불능·권한 computed 공백)
|
||||
|
||||
- engine-v1.60.0 의 SafeExpressionEvaluator 교체가 구 평가기(`new Function`)가 허용하던 **화살표·함수 파라미터의 배열 구조분해**(`([k, v]) =>`, `([, vid]) =>`)를 수용하지 않아, 해당 문법을 쓰는 표현식이 전부 `Expected ")" but found "=>"` 로 파싱 실패했다. 액션 params 는 미평가 원문 문자열 그대로 서버에 전송되어 422 가 되고(스토어프론트 장바구니 담기·바로 구매가 전 상품에서 불능), 게시판 환경설정의 권한 기본값 computed 4종이 계산되지 않아 권한 섹션이 비었으며, 플러그인 설정·이커머스 폼의 검증 오류 표시(`([field, messages]) => …`)도 발화 시점에 같은 실패에 걸리는 상태였다. 저장소 레이아웃 54개 파일 104곳이 이 문법을 사용 중이었다.
|
||||
- `tryParseParamList` 에 배열 패턴(식별자 + 홀/elision, 파라미터 기본값 조합 포함)을 추가하고 `bindParams` 가 JS iterator 시맨틱(비-iterable 은 TypeError)으로 분해 바인딩하도록 했다. 중첩 패턴·rest·객체 패턴은 배포 레이아웃 사용 0건이라 지원하지 않는다(발견 시 arrow 아님으로 안전 되돌림).
|
||||
- 회귀 잠금: 사용 형태 전수(단일/쌍/홀/기본값/function 선언 파라미터/실전 `_purchase_card.json` 본문·게시판 권한 computed·검증 오류 표시) 단위 테스트 + 배포 번들 E2E(`destructuring_param`) 추가.
|
||||
|
||||
## [engine-v1.60.4] - 2026-08-14
|
||||
|
||||
### Security
|
||||
|
||||
@@ -245,9 +245,18 @@ type Node =
|
||||
| { type: 'Empty' }
|
||||
| { type: 'Try'; block: Node; handlerParam: string | null; handler: Node | null; finalizer: Node | null };
|
||||
|
||||
/** 함수/화살표 파라미터 (기본값 지원) */
|
||||
/**
|
||||
* 함수/화살표 파라미터 (기본값 지원).
|
||||
*
|
||||
* `name` 이 null 이면 배열 구조분해 패턴 — `elements` 가 요소 이름 목록이며
|
||||
* null 요소는 홀(elision)이다 (`([, vid]) =>` → elements: [null, 'vid']).
|
||||
* 구 평가기(new Function)가 허용하던 형태로, 레이아웃 전반에서 사용된다.
|
||||
*
|
||||
* @since engine-v1.60.5
|
||||
*/
|
||||
interface Param {
|
||||
name: string;
|
||||
name: string | null;
|
||||
elements?: (string | null)[];
|
||||
default: Node | null;
|
||||
}
|
||||
|
||||
@@ -758,8 +767,8 @@ class Parser {
|
||||
}
|
||||
|
||||
/**
|
||||
* 파라미터 목록을 파싱한다: `ident (= 기본값)?` 를 `,` 로 구분. 열림 `(` 는 호출부가
|
||||
* 이미 소비한 상태이며, 닫힘 `)` 는 소비하지 않는다(호출부가 검사).
|
||||
* 파라미터 목록을 파싱한다: `(ident | [배열패턴]) (= 기본값)?` 를 `,` 로 구분.
|
||||
* 열림 `(` 는 호출부가 이미 소비한 상태이며, 닫힘 `)` 는 소비하지 않는다(호출부가 검사).
|
||||
*
|
||||
* @return 파싱된 파라미터 배열, 또는 파라미터 형태가 아니면 null(arrow 아님)
|
||||
*/
|
||||
@@ -768,11 +777,18 @@ class Parser {
|
||||
if (this.isPunct(')')) return params; // 빈 목록
|
||||
for (;;) {
|
||||
const p = this.peek();
|
||||
if (!p || p.type !== 'ident' || FORBIDDEN_KEYWORDS.has(String(p.value))) {
|
||||
let name: string | null = null;
|
||||
let elements: (string | null)[] | undefined;
|
||||
if (p && p.type === 'punct' && p.value === '[') {
|
||||
const pattern = this.tryParseArrayPattern();
|
||||
if (!pattern) return null;
|
||||
elements = pattern;
|
||||
} else if (p && p.type === 'ident' && !FORBIDDEN_KEYWORDS.has(String(p.value))) {
|
||||
name = String(p.value);
|
||||
this.pos += 1;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const name = String(p.value);
|
||||
this.pos += 1;
|
||||
let def: Node | null = null;
|
||||
if (this.isPunct('=')) {
|
||||
this.pos += 1; // =
|
||||
@@ -780,7 +796,7 @@ class Parser {
|
||||
// 콤마를 소비하지 않으므로 다음 파라미터 구분자 ',' 는 그대로 남는다.
|
||||
def = this.parseExpression();
|
||||
}
|
||||
params.push({ name, default: def });
|
||||
params.push(elements ? { name, elements, default: def } : { name, default: def });
|
||||
if (this.isPunct(',')) {
|
||||
this.pos += 1;
|
||||
continue;
|
||||
@@ -790,6 +806,48 @@ class Parser {
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파라미터 위치의 배열 구조분해 패턴 `[a, , b]` 를 파싱한다.
|
||||
* 요소는 식별자 또는 홀(elision)만 허용 — 중첩 패턴·rest·요소별 기본값은
|
||||
* 레이아웃 표현식에서 쓰이지 않으므로 지원하지 않는다(발견 시 arrow 아님으로 되돌림).
|
||||
*
|
||||
* @return 요소 이름 배열 (홀은 null), 패턴 형태가 아니면 null
|
||||
* @since engine-v1.60.5
|
||||
*/
|
||||
private tryParseArrayPattern(): (string | null)[] | null {
|
||||
const save = this.pos;
|
||||
this.pos += 1; // [
|
||||
const elements: (string | null)[] = [];
|
||||
for (;;) {
|
||||
if (this.isPunct(']')) {
|
||||
this.pos += 1;
|
||||
return elements;
|
||||
}
|
||||
if (this.isPunct(',')) {
|
||||
elements.push(null); // 홀 (elision)
|
||||
this.pos += 1;
|
||||
continue;
|
||||
}
|
||||
const t = this.peek();
|
||||
if (!t || t.type !== 'ident' || FORBIDDEN_KEYWORDS.has(String(t.value))) {
|
||||
this.pos = save;
|
||||
return null;
|
||||
}
|
||||
elements.push(String(t.value));
|
||||
this.pos += 1;
|
||||
if (this.isPunct(',')) {
|
||||
this.pos += 1;
|
||||
continue;
|
||||
}
|
||||
if (this.isPunct(']')) {
|
||||
this.pos += 1;
|
||||
return elements;
|
||||
}
|
||||
this.pos = save;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseArrowBody(): { body: Node; isBlock: boolean } {
|
||||
// 블록 본문: (…) => { statements } (return 으로 값 반환)
|
||||
if (this.isPunct('{')) {
|
||||
@@ -1502,7 +1560,19 @@ function bindParams(params: Param[], args: unknown[], local: Scope): void {
|
||||
if (v === undefined && p.default) {
|
||||
v = evalNode(p.default, local);
|
||||
}
|
||||
local.vars[p.name] = v;
|
||||
if (p.elements) {
|
||||
// 배열 구조분해 패턴 — JS iterator 시맨틱과 동일하게 null/undefined 는 예외
|
||||
if (v == null) {
|
||||
throw new TypeError(`Cannot destructure ${String(v)}: value is not iterable`);
|
||||
}
|
||||
const arr = Array.isArray(v) ? v : Array.from(v as Iterable<unknown>);
|
||||
for (let e = 0; e < p.elements.length; e++) {
|
||||
const name = p.elements[e];
|
||||
if (name !== null) local.vars[name] = arr[e];
|
||||
}
|
||||
} else if (p.name !== null) {
|
||||
local.vars[p.name] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,99 @@ describe('SafeExpressionEvaluator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrow param 배열 구조분해 (engine-v1.60.5 회귀 — 장바구니/바로구매 불능)', () => {
|
||||
// 구 평가기(new Function)가 허용하던 형태 전수: 저장소 레이아웃 54파일 104곳 사용
|
||||
// (`([code])` 35 · `([field, messages])` 34 · `([k, v])` 등 — _purchase_card.json 이 대표)
|
||||
it('단일 요소 ([k])', () => {
|
||||
expect(evalx('Object.entries({ a: 1, b: 2 }).map(([k]) => k)')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('두 요소 ([k, v])', () => {
|
||||
expect(evalx('Object.entries({ a: 1, b: 2 }).map(([k, v]) => k + v)')).toEqual(['a1', 'b2']);
|
||||
});
|
||||
|
||||
it('선행 홀 ([, v])', () => {
|
||||
expect(evalx('Object.entries({ a: 1, b: null }).filter(([, v]) => v != null).length')).toBe(1);
|
||||
});
|
||||
|
||||
it('배열 인자 직접 분해', () => {
|
||||
expect(evalx('[[1, 2], [3, 4]].map(([a, b]) => a * b)')).toEqual([2, 12]);
|
||||
});
|
||||
|
||||
it('파라미터 기본값 조합 (([a, b] = []) =>)', () => {
|
||||
expect(evalx('[undefined].map(([a, b] = [7, 8]) => (a ?? 0) + (b ?? 0))')).toEqual([15]);
|
||||
});
|
||||
|
||||
it('null/undefined 인자 분해는 JS 와 동일하게 예외', () => {
|
||||
expect(() => evalx('[null].map(([a]) => a)')).toThrow();
|
||||
});
|
||||
|
||||
it('실전 회귀: _purchase_card.json 장바구니 담기 본문 표현식', () => {
|
||||
const ctx = {
|
||||
product: { data: { id: 98, has_options: true, options: [{ id: 1 }, { id: 2 }] } },
|
||||
_local: {
|
||||
selectedOptionItems: [
|
||||
{
|
||||
optionId: 11,
|
||||
quantity: 2,
|
||||
additionalOptionSelections: { 5: 50, 6: null },
|
||||
additionalOptionCustomTexts: { 5: '각인 문구' },
|
||||
},
|
||||
],
|
||||
noOptionQuantity: 1,
|
||||
},
|
||||
};
|
||||
const expr =
|
||||
'product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? ' +
|
||||
'{ product_id: product.data?.id, items: (_local.selectedOptionItems ?? []).map(item => ({ ' +
|
||||
'product_option_id: item.optionId, quantity: item.quantity, ' +
|
||||
'additional_option_selections: Object.entries(item.additionalOptionSelections ?? {})' +
|
||||
'.filter(([, vid]) => vid != null)' +
|
||||
'.map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) ' +
|
||||
'})) } : { product_id: product.data?.id, items: [{ quantity: _local.noOptionQuantity ?? 1 }] }';
|
||||
expect(evalx(expr, ctx)).toEqual({
|
||||
product_id: 98,
|
||||
items: [
|
||||
{
|
||||
product_option_id: 11,
|
||||
quantity: 2,
|
||||
additional_option_selections: [{ additional_option_id: 5, value_id: 50, custom_text: '각인 문구' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('실전 회귀: 게시판 환경설정 권한 computed (filter([key]) + startsWith)', () => {
|
||||
const ctx = {
|
||||
settings: {
|
||||
data: {
|
||||
basic_defaults: {
|
||||
default_board_permissions: { 'admin.manage': true, read: true, write: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(
|
||||
evalx(
|
||||
"Object.entries(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(([key]) => !key.startsWith('admin.')).map(([key]) => key)",
|
||||
ctx,
|
||||
),
|
||||
).toEqual(['read', 'write']);
|
||||
});
|
||||
|
||||
it('실전 회귀: 검증 오류 표시 ([field, messages]) — 플러그인 설정 onError 형태', () => {
|
||||
const ctx = { errors: { name: ['이름은 필수입니다'], email: ['형식 오류'] } };
|
||||
expect(evalx("Object.entries(errors ?? {}).map(([field, messages]) => field + ': ' + messages[0])", ctx)).toEqual([
|
||||
'name: 이름은 필수입니다',
|
||||
'email: 형식 오류',
|
||||
]);
|
||||
});
|
||||
|
||||
it('function 선언 파라미터의 배열 구조분해', () => {
|
||||
expect(evalx('(function f([a, b]) { return a + b; })([3, 4])')).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spread (array / object / call)', () => {
|
||||
it('배열 스프레드', () => {
|
||||
expect(evalx('[...a, ...b, 3]', { a: [1], b: [2] })).toEqual([1, 2, 3]);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\G7Testing\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\G7Testing\Console\FakeExtensionScheduleCommand;
|
||||
|
||||
/**
|
||||
* 스케줄 Artisan 허용목록의 "확장 소유 명령" 계층을 HTTP 문맥에서 검증하기 위한
|
||||
* 테스트 전용 서비스 프로바이더.
|
||||
*
|
||||
* 실제 확장 프로바이더는 `$this->app->runningInConsole()` 일 때만 커맨드를 Artisan 에
|
||||
* 등록하므로, HTTP 요청(관리자 화면의 스케줄 저장 검증)에서는 `Artisan::all()` 에
|
||||
* 확장 커맨드가 존재하지 않는다. 검증기는 이 경우 프로바이더가 선언한 `$commands`
|
||||
* 기본값을 폴백으로 해석해야 한다 — 이 픽스처는 그 상황(커맨드 미등록 + 프로바이더의
|
||||
* `$commands` 선언만 존재)을 재현한다.
|
||||
*
|
||||
* `Tests\` PSR-4 네임스페이스가 아니므로 오토로드되지 않는다 — 사용처에서 require 한다.
|
||||
*/
|
||||
class FakeExtensionScheduleServiceProvider extends ServiceProvider
|
||||
{
|
||||
/** @var array<int, class-string> 실제 확장 프로바이더와 동일한 선언 형태 */
|
||||
protected array $commands = [
|
||||
FakeExtensionScheduleCommand::class,
|
||||
];
|
||||
}
|
||||
@@ -71,6 +71,41 @@ test.describe('레이아웃 표현식 평가 샌드박스', () => {
|
||||
expect(result.date).toBe(true);
|
||||
});
|
||||
|
||||
// @scenario case=destructuring_param
|
||||
// @effects same_expression_same_value_across_paths
|
||||
test('화살표 파라미터 배열 구조분해(([k, v])·홀)가 배포 번들에서 동작한다 (engine-v1.60.5 회귀)', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const g7 = (window as any).G7Core;
|
||||
const ctx = {
|
||||
sel: { 5: 50, 6: null },
|
||||
perms: { 'admin.manage': true, read: true, write: false },
|
||||
errors: { name: ['이름은 필수입니다'] },
|
||||
};
|
||||
return {
|
||||
// _purchase_card.json 장바구니/바로구매 본문 형태 — 홀 + 쌍 분해
|
||||
hole: g7.evaluateCondition('{{Object.entries(sel ?? {}).filter(([, vid]) => vid != null).length === 1}}', ctx),
|
||||
pair: g7.evaluateCondition('{{Object.entries(sel ?? {}).filter(([, v]) => v != null).map(([gid, vid]) => Number(gid) + Number(vid)).includes(55)}}', ctx),
|
||||
single: g7.evaluateCondition('{{Object.entries(sel ?? {}).map(([k]) => k).includes("5")}}', ctx),
|
||||
// 게시판 환경설정 권한 computed 형태: filter([key]) + startsWith + map([key])
|
||||
entriesFilterMap: g7.evaluateCondition(
|
||||
"{{Object.entries(perms ?? {}).filter(([key]) => !key.startsWith('admin.')).map(([key]) => key).length === 2}}",
|
||||
ctx,
|
||||
),
|
||||
// 검증 오류 표시 형태: ([field, messages])
|
||||
fieldMessages: g7.evaluateCondition(
|
||||
"{{Object.entries(errors ?? {}).map(([field, messages]) => field + ':' + messages[0])[0] === 'name:이름은 필수입니다'}}",
|
||||
ctx,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.hole).toBe(true);
|
||||
expect(result.pair).toBe(true);
|
||||
expect(result.single).toBe(true);
|
||||
expect(result.entriesFilterMap).toBe(true);
|
||||
expect(result.fieldMessages).toBe(true);
|
||||
});
|
||||
|
||||
// @scenario case=constructor_escape
|
||||
// @effects sandbox_escape_blocked, dangerous_payload_does_not_set_global
|
||||
test('constructor 체인 샌드박스 탈출이 코드를 실행하지 못한다', async ({ page }) => {
|
||||
|
||||
@@ -33,6 +33,9 @@ class SeoMetaResolverTest extends TestCase
|
||||
Config::set('g7_settings.core.seo.google_analytics_id', 'GA-12345');
|
||||
Config::set('g7_settings.core.seo.google_site_verification', '');
|
||||
Config::set('g7_settings.core.seo.naver_site_verification', '');
|
||||
// 운영자가 로컬에 저장한 값(storage/app/settings/seo.json)이 부팅 시 config 로
|
||||
// 실려 들어오므로, 미고정 시 og site_name fallback 단언이 환경에 좌우된다.
|
||||
Config::set('g7_settings.core.seo.og_default_site_name', '');
|
||||
Config::set('g7_settings.core.general.site_name', '그누보드7 쇼핑몰');
|
||||
}
|
||||
|
||||
@@ -374,6 +377,35 @@ class SeoMetaResolverTest extends TestCase
|
||||
$this->assertSame('나이키 에어맥스', $jsonLd['description']);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-LD 는 `<script type="application/ld+json">` 안에 임베드되므로, 사용자
|
||||
* 제어 값(검색어 등)의 `</script>` 가 스크립트 컨텍스트를 조기 종료해 실행 가능한
|
||||
* script 요소를 재생성하지 못하도록 `<`·`>` 를 유니코드 이스케이프해야 한다.
|
||||
* (봇 렌더는 _escaped_fragment_ 로 일반 UA 도 강제되므로 반사형 XSS 벡터가 된다.)
|
||||
*/
|
||||
public function test_structured_data_escapes_script_breakout_in_json_ld(): void
|
||||
{
|
||||
$seoConfig = [
|
||||
'structured_data' => [
|
||||
'@type' => 'SearchResultsPage',
|
||||
'name' => 'G7 - {{query.q}}',
|
||||
],
|
||||
];
|
||||
|
||||
$context = ['query' => ['q' => '</script><script>alert(1)</script>']];
|
||||
|
||||
$result = $this->resolver->resolve($seoConfig, $context, null, null, []);
|
||||
|
||||
// 원문 태그가 그대로 실리면 스크립트 컨텍스트가 조기 종료돼 실행 가능한 script 요소가
|
||||
// 재생성된다(반사형 XSS). JSON_HEX_TAG 가 태그 문자를 유니코드 이스케이프하므로 산출물에는
|
||||
// 리터럴 태그 열림/닫힘 문자가 없어야 한다.
|
||||
$this->assertDoesNotMatchRegularExpression('#</?script#', $result['jsonLd']);
|
||||
$this->assertStringContainsString(chr(0x5C).'u003C', $result['jsonLd']);
|
||||
// 이스케이프에도 불구하고 JSON 파싱 시 원래 값이 복원된다(구조화 데이터 의미 보존).
|
||||
$decoded = json_decode($result['jsonLd'], true);
|
||||
$this->assertSame('G7 - </script><script>alert(1)</script>', $decoded['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조화 데이터가 없으면 jsonLd는 null입니다.
|
||||
*/
|
||||
@@ -1562,7 +1594,7 @@ class SeoMetaResolverTest extends TestCase
|
||||
'page_type' => 'product',
|
||||
'vars' => [
|
||||
// 표현식이 다국어 객체를 직접 반환 — substituteVars 까지 array 전달
|
||||
'product_name' => "{{product.data.name}}",
|
||||
'product_name' => '{{product.data.name}}',
|
||||
'commerce_name' => '$module_settings:basic_info.shop_name',
|
||||
],
|
||||
];
|
||||
@@ -1638,4 +1670,32 @@ class SeoMetaResolverTest extends TestCase
|
||||
$this->assertSame('운동화', $result['og']['description']);
|
||||
$this->assertSame('https://e.co/ko.jpg', $result['og']['image']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 접미사 조립: 저장 시 TrimStrings 가 선행 공백을 제거하므로("| 그누보드7"),
|
||||
* 제목이 있으면 공백을 복원해 잇고, 제목이 비면 매달린 구분자를 떼어낸다.
|
||||
* (blade 는 title.titleSuffix 단순 연결 — 조립 규칙은 이 메서드가 SSoT)
|
||||
*/
|
||||
public function test_compose_title_suffix_restores_space_between_title_and_trimmed_suffix(): void
|
||||
{
|
||||
$this->assertSame(' | 그누보드7', SeoMetaResolver::composeTitleSuffix('가죽 크로스백', '| 그누보드7'));
|
||||
}
|
||||
|
||||
public function test_compose_title_suffix_keeps_explicit_leading_space_as_is(): void
|
||||
{
|
||||
$this->assertSame(' | 그누보드7', SeoMetaResolver::composeTitleSuffix('가죽 크로스백', ' | 그누보드7'));
|
||||
}
|
||||
|
||||
public function test_compose_title_suffix_strips_dangling_separator_when_title_is_empty(): void
|
||||
{
|
||||
$this->assertSame('그누보드7', SeoMetaResolver::composeTitleSuffix('', '| 그누보드7'));
|
||||
$this->assertSame('그누보드7', SeoMetaResolver::composeTitleSuffix('', ' - 그누보드7'));
|
||||
}
|
||||
|
||||
public function test_compose_title_suffix_returns_empty_when_suffix_is_blank(): void
|
||||
{
|
||||
$this->assertSame('', SeoMetaResolver::composeTitleSuffix('가죽 크로스백', ''));
|
||||
$this->assertSame('', SeoMetaResolver::composeTitleSuffix('', ' '));
|
||||
$this->assertSame('', SeoMetaResolver::composeTitleSuffix('', '| '));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,6 +675,40 @@ class ScheduleCommandValidatorTest extends TestCase
|
||||
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_OPTION, $verdict['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan 에 커맨드가 등록되지 않은 문맥(HTTP 요청)에서도, 활성 프로바이더가
|
||||
* `$commands` 로 선언한 확장 소유 명령은 자동 허용된다.
|
||||
*
|
||||
* 실제 확장 프로바이더는 `runningInConsole()` 일 때만 커맨드를 등록하므로,
|
||||
* 관리자 화면의 스케줄 저장 검증(HTTP)에서는 `Artisan::all()` 에 확장 커맨드가
|
||||
* 없다. 등록 인스턴스가 없으면 프로바이더 선언(클래스 네임스페이스 + 실제 명령명
|
||||
* 대조)을 폴백으로 해석해야 한다 — 없으면 "설치된 확장 명령 자동 허용" 약속이
|
||||
* 웹 저장 경로에서 통째로 동작하지 않는다 (7.0.7 검수 발견).
|
||||
*
|
||||
* @scenario command_class=extension_owned, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects extension_owned_commands_resolved_without_console_registration
|
||||
*/
|
||||
#[Test]
|
||||
public function it_allows_provider_declared_extension_commands_without_console_registration(): void
|
||||
{
|
||||
require_once __DIR__.'/../../Fixtures/Extension/FakeExtensionScheduleCommand.php';
|
||||
require_once __DIR__.'/../../Fixtures/Extension/FakeExtensionScheduleServiceProvider.php';
|
||||
|
||||
// Artisan::registerCommand 를 호출하지 않는다 — HTTP 문맥 재현.
|
||||
$this->app->register(\Modules\G7Testing\Providers\FakeExtensionScheduleServiceProvider::class);
|
||||
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand('g7-testing-fake-extension-command --scope=all');
|
||||
|
||||
$this->assertTrue($verdict['allowed'], '프로바이더 선언 확장 명령이 HTTP 문맥에서 거부됨');
|
||||
$this->assertSame('g7-testing-fake-extension-command', $verdict['name']);
|
||||
|
||||
// 자기 정의에 없는 옵션은 같은 폴백 경로에서도 거부되어야 한다.
|
||||
$optionVerdict = ScheduleCommandValidator::inspectArtisanCommand('g7-testing-fake-extension-command --g7-not-a-real-option');
|
||||
$this->assertFalse($optionVerdict['allowed']);
|
||||
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_OPTION, $optionVerdict['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 네임스페이스를 비워도 코어 허용목록은 그대로 동작한다 (계층 독립성).
|
||||
*
|
||||
|
||||
@@ -10,7 +10,7 @@ description: |
|
||||
미실행(전역 canary `window.__g7jsi` 미설정) 양면을 잠근다.
|
||||
|
||||
axes:
|
||||
case: [safe_expression, arrow_template_literal, constructor_escape, nonstring_key_escape, object_facade_safe]
|
||||
case: [safe_expression, arrow_template_literal, destructuring_param, constructor_escape, nonstring_key_escape, object_facade_safe]
|
||||
|
||||
effects:
|
||||
- same_expression_same_value_across_paths
|
||||
|
||||
@@ -59,6 +59,7 @@ effects:
|
||||
- denylist_is_evaluated_before_the_allowlist
|
||||
- make_prefix_blocks_all_code_generating_commands
|
||||
- extension_owned_commands_are_allowed_by_namespace
|
||||
- extension_owned_commands_resolved_without_console_registration
|
||||
- extension_gate_can_be_turned_off
|
||||
- extension_commands_only_accept_their_own_options
|
||||
# 사용법 제한
|
||||
|
||||
Reference in New Issue
Block a user