Merge: 7.0.7 병렬 검수 수정분 통합 — 구조분해·JSON-LD 중복 구현 단일화, 양측 고유 수정 보존
동일 결함 2건(표현식 배열 구조분해 회귀, JSON-LD 이스케이프)을 양 세션이 독립 구현해 충돌. 해소 기준: - 평가기 구조분해: JS iterator 시맨틱 구현 채택(구 평가기 new Function 패리티가 회귀 수정의 본래 의도). 관대(undefined) 바인딩 구현과 그 단언 1건은 폐기. 단위 테스트는 양측 병합(구조분해 10케이스), E2E·시나리오 축은 destructuring_param 단일 케이스로 통합. 무관 심볼의 @since 소급 변경은 원복. - JSON-LD: JSON_HEX_TAG|JSON_HEX_AMP(상위집합) 채택, XSS 단위 테스트 중복 제거. - 양측 고유 수정 각각 보존: composeTitleSuffix 접미사 조립(타 세션) / 스케줄 확장 명령 웹 검증 폴백·SeoMetaResolverTest 설정 격리(본 세션). - min.js 는 병합 소스로 core:build --production 재빌드. 검증: SafeExpressionEvaluator 120/120, SeoMetaResolverTest 68/68, 배포 번들 E2E 6/6. 실측 — 게시판 환경설정 권한 섹션 60컨트롤 렌더, 장바구니 담기 201, 봇 title 접미사 공백 복원·매달린 구분자 제거, 검색 XSS 봇/_escaped_fragment_ 양 경로 이스케이프 확인.
This commit is contained in:
+2
-1
@@ -23,7 +23,7 @@
|
||||
- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다.
|
||||
- 셸 예약 작업의 허용 실행 파일 목록에 `bash`·`python` 같은 인터프리터를 등록했을 때, 인터프리터에 인라인 명령(`bash -c ...` 형태)을 실어 임의 명령이 실행되던 문제를 막았습니다. 이제 인터프리터 뒤에는 절대경로 스크립트 파일만 지정할 수 있으며, 인라인 코드 실행·상대경로·경로 조작이 담긴 명령은 저장·실행 양쪽에서 거부됩니다. 스크립트 파일을 주기 실행하는 정상 사용(예: `python /경로/작업.py`)은 그대로 동작합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1653)
|
||||
- 검색 봇에게 제공되는 서버 렌더링 화면의 구조화 데이터(JSON-LD)에 검색어·게시글 제목 같은 사용자 입력이 담길 때, 스크립트 태그를 닫는 특수한 문자열을 끼워 넣으면 주입된 스크립트가 실행될 수 있던 문제를 수정했습니다. 이제 해당 문자가 안전한 형태로 변환되어 실리며, 검색엔진이 읽는 데이터 내용은 그대로 보존됩니다. 일반 사용자 화면에는 이 블록이 실리지 않으므로 봇 요청 경로에만 해당합니다.
|
||||
- 검색어·게시글 제목처럼 방문자가 입력한 값이 검색엔진용 구조화 데이터(JSON-LD)에 실릴 때, 스크립트 태그를 닫는 문자열을 끼워 넣으면 그 뒤의 내용이 방문자 브라우저에서 스크립트로 실행될 수 있던 문제를 수정했습니다. 검색엔진 봇에게 제공되는 화면이지만 특정 주소 파라미터로 일반 방문자에게도 같은 화면을 강제할 수 있어 실제 공격 경로가 됩니다. 이제 태그 문자를 이스케이프해 실행을 차단하며, 검색엔진이 읽는 구조화 데이터의 의미는 그대로 유지됩니다.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
- 확장·코어 업데이트가 중단되며 남은 임시 폴더와 오래된 백업본, 언어팩 설치 임시 파일이 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 사흘이 지난 임시 산출물과 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,14 +723,14 @@ class SeoMetaResolver
|
||||
|
||||
$resolved = array_merge(['@context' => 'https://schema.org'], $structuredData);
|
||||
|
||||
// JSON_HEX_TAG: `<`/`>` 를 `<`/`>` 로 이스케이프한다. 이 블록은
|
||||
// `<script type="application/ld+json">` 안에 그대로 삽입되므로, 사용자 입력
|
||||
// (검색어·게시글 제목·상품명 등)이 값에 들어올 때 `</script>` 시퀀스가 스크립트
|
||||
// 태그를 조기 종료시켜 저장형 XSS 로 이어지는 것을 막는다. 값 자체는 JSON 이스케이프
|
||||
// 형태라 파서가 원문으로 복원한다.
|
||||
// JSON-LD 는 `<script type="application/ld+json">` 안에 임베드된다. 사용자 제어 값
|
||||
// (검색어·상품명 등)에 포함된 `</script>` 가 스크립트 컨텍스트를 조기 종료하면 그
|
||||
// 뒤의 페이로드가 실행 가능한 script 요소로 재생성된다(반사형 XSS — 봇 렌더는
|
||||
// _escaped_fragment_ 로 일반 UA 도 강제됨). JSON_HEX_TAG 로 `<`·`>` 를 <·>
|
||||
// 로 이스케이프해 브레이크아웃을 차단한다. JSON_HEX_AMP 는 방어 심화.
|
||||
return json_encode(
|
||||
$resolved,
|
||||
JSON_HEX_TAG | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -798,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,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -9,10 +9,11 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 화살표/함수 파라미터의 배열 구조 분해 지원 (`([key]) => …`)
|
||||
#### 화살표/함수 파라미터 배열 구조분해 미지원 회귀 (장바구니/바로구매 불능·권한 computed 공백)
|
||||
|
||||
- SafeExpressionEvaluator 파서가 화살표·함수 파라미터를 평문 식별자만 허용해, 레이아웃 전반에서 실사용 중인 `Object.entries(...).filter(([key]) => …)` / `.map(([k, v]) => …)` / `([, v]) => …` 형태가 `Expected ")" but found "=>"` 로 평가 실패했다. 게시판 환경설정의 권한 기본값 computed 4종이 계산되지 않아 권한 섹션이 헤더만 남고 비었고, 플러그인 설정·이커머스 폼의 검증 오류 표시(`([field, messages]) => …`)도 발화 시점에 같은 실패에 걸리는 상태였다 (7.0.7 평가기 교체 회귀 — 배포 레이아웃 60여 파일 사용).
|
||||
- 파라미터 목록 파서에 평탄한 배열 구조 분해 패턴(식별자 + 엘리전 홀)을 추가하고, 바인딩 시 홀은 건너뛰며 대상이 null/undefined 여도 예외 없이 undefined 를 바인딩한다. 중첩 패턴·rest·객체 구조 분해는 배포 레이아웃 사용이 0건이라 지원하지 않는다(형태가 어긋나면 종전과 같이 arrow 판정을 되돌린다).
|
||||
- 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
|
||||
|
||||
|
||||
@@ -248,10 +248,11 @@ type Node =
|
||||
/**
|
||||
* 함수/화살표 파라미터 (기본값 지원).
|
||||
*
|
||||
* 배열 구조 분해 패턴(`([key]) => …`, `([, v]) => …`)은 `name: null` +
|
||||
* `elements` 로 표현한다. `elements` 의 `null` 항목은 엘리전 홀(`[, v]`)이다.
|
||||
* 레이아웃 실사용 형태(`Object.entries(...).map(([k, v]) => …)`)가 이 문법에
|
||||
* 의존하므로 평문 식별자와 동급으로 지원한다. (engine-v1.60.5)
|
||||
* `name` 이 null 이면 배열 구조분해 패턴 — `elements` 가 요소 이름 목록이며
|
||||
* null 요소는 홀(elision)이다 (`([, vid]) =>` → elements: [null, 'vid']).
|
||||
* 구 평가기(new Function)가 허용하던 형태로, 레이아웃 전반에서 사용된다.
|
||||
*
|
||||
* @since engine-v1.60.5
|
||||
*/
|
||||
interface Param {
|
||||
name: string | null;
|
||||
@@ -766,8 +767,8 @@ class Parser {
|
||||
}
|
||||
|
||||
/**
|
||||
* 파라미터 목록을 파싱한다: `ident (= 기본값)?` 를 `,` 로 구분. 열림 `(` 는 호출부가
|
||||
* 이미 소비한 상태이며, 닫힘 `)` 는 소비하지 않는다(호출부가 검사).
|
||||
* 파라미터 목록을 파싱한다: `(ident | [배열패턴]) (= 기본값)?` 를 `,` 로 구분.
|
||||
* 열림 `(` 는 호출부가 이미 소비한 상태이며, 닫힘 `)` 는 소비하지 않는다(호출부가 검사).
|
||||
*
|
||||
* @return 파싱된 파라미터 배열, 또는 파라미터 형태가 아니면 null(arrow 아님)
|
||||
*/
|
||||
@@ -779,16 +780,14 @@ class Parser {
|
||||
let name: string | null = null;
|
||||
let elements: (string | null)[] | undefined;
|
||||
if (p && p.type === 'punct' && p.value === '[') {
|
||||
// 배열 구조 분해 패턴: [a, b] / [, v] / [a,] — 평탄한 식별자만 허용
|
||||
this.pos += 1; // [
|
||||
elements = this.tryParseArrayPatternElements();
|
||||
if (!elements) return null;
|
||||
} else {
|
||||
if (!p || p.type !== 'ident' || FORBIDDEN_KEYWORDS.has(String(p.value))) {
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
let def: Node | null = null;
|
||||
if (this.isPunct('=')) {
|
||||
@@ -808,27 +807,30 @@ class Parser {
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열 구조 분해 패턴의 요소를 파싱한다. 열림 `[` 는 호출부가 소비했고,
|
||||
* 닫힘 `]` 까지 소비한다. 엘리전 홀(`[, v]`)은 null 로 표현한다.
|
||||
* 중첩 패턴·rest·기본값 등 평탄한 식별자 외의 형태는 지원하지 않는다
|
||||
* (레이아웃 실사용 형태가 전부 평탄 — 형태가 어긋나면 null 로 arrow 판정 자체를 되돌린다).
|
||||
* 파라미터 위치의 배열 구조분해 패턴 `[a, , b]` 를 파싱한다.
|
||||
* 요소는 식별자 또는 홀(elision)만 허용 — 중첩 패턴·rest·요소별 기본값은
|
||||
* 레이아웃 표현식에서 쓰이지 않으므로 지원하지 않는다(발견 시 arrow 아님으로 되돌림).
|
||||
*
|
||||
* @return 요소 이름 배열(홀은 null), 또는 패턴 형태가 아니면 null
|
||||
* @return 요소 이름 배열 (홀은 null), 패턴 형태가 아니면 null
|
||||
* @since engine-v1.60.5
|
||||
*/
|
||||
private tryParseArrayPatternElements(): (string | null)[] | null {
|
||||
private tryParseArrayPattern(): (string | null)[] | null {
|
||||
const save = this.pos;
|
||||
this.pos += 1; // [
|
||||
const elements: (string | null)[] = [];
|
||||
for (;;) {
|
||||
if (this.isPunct(']')) {
|
||||
this.pos += 1; // ]
|
||||
this.pos += 1;
|
||||
return elements;
|
||||
}
|
||||
if (this.isPunct(',')) {
|
||||
this.pos += 1; // 엘리전 홀
|
||||
elements.push(null);
|
||||
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));
|
||||
@@ -838,9 +840,10 @@ class Parser {
|
||||
continue;
|
||||
}
|
||||
if (this.isPunct(']')) {
|
||||
this.pos += 1; // ]
|
||||
this.pos += 1;
|
||||
return elements;
|
||||
}
|
||||
this.pos = save;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1558,18 +1561,18 @@ function bindParams(params: Param[], args: unknown[], local: Scope): void {
|
||||
v = evalNode(p.default, local);
|
||||
}
|
||||
if (p.elements) {
|
||||
// 배열 구조 분해 패턴: 홀(null)은 건너뛰고 각 이름을 인덱스 값으로 바인딩.
|
||||
// 대상이 배열/문자열이 아니어도(undefined 포함) 예외 없이 undefined 바인딩 —
|
||||
// 표현식 평가 실패로 화면이 통째로 비는 것보다 관대한 해석이 낫다.
|
||||
const src = v as Record<number, unknown> | null | undefined;
|
||||
for (let ei = 0; ei < p.elements.length; ei++) {
|
||||
const name = p.elements[ei];
|
||||
if (name === null) continue;
|
||||
local.vars[name] = src == null ? undefined : src[ei];
|
||||
// 배열 구조분해 패턴 — JS iterator 시맨틱과 동일하게 null/undefined 는 예외
|
||||
if (v == null) {
|
||||
throw new TypeError(`Cannot destructure ${String(v)}: value is not iterable`);
|
||||
}
|
||||
continue;
|
||||
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;
|
||||
}
|
||||
local.vars[p.name as string] = v;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,8 +147,69 @@ describe('SafeExpressionEvaluator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrow-function 배열 구조 분해 파라미터 (7.0.7 회귀 — 게시판 환경설정 권한 computed)', () => {
|
||||
it('([key]) => — Object.entries 필터/맵 (admin_board_settings 실사용 형태)', () => {
|
||||
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: {
|
||||
@@ -166,13 +227,7 @@ describe('SafeExpressionEvaluator', () => {
|
||||
).toEqual(['read', 'write']);
|
||||
});
|
||||
|
||||
it('([k, v]) => — 키·값 동시 바인딩', () => {
|
||||
expect(evalx("Object.entries(o).map(([k, v]) => k + '=' + v).join(',')", { o: { a: 1, b: 2 } })).toBe(
|
||||
'a=1,b=2',
|
||||
);
|
||||
});
|
||||
|
||||
it('([field, messages]) => — 검증 오류 표시 형태 (플러그인 설정 onError 실사용)', () => {
|
||||
it('실전 회귀: 검증 오류 표시 ([field, messages]) — 플러그인 설정 onError 형태', () => {
|
||||
const ctx = { errors: { name: ['이름은 필수입니다'], email: ['형식 오류'] } };
|
||||
expect(evalx("Object.entries(errors ?? {}).map(([field, messages]) => field + ': ' + messages[0])", ctx)).toEqual([
|
||||
'name: 이름은 필수입니다',
|
||||
@@ -180,17 +235,9 @@ describe('SafeExpressionEvaluator', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('([,v]) => — 엘리전(홀) 패턴 (_bank_management_modal 실사용)', () => {
|
||||
expect(evalx('Object.entries(o).map(([,v]) => v)', { o: { a: 10, b: 20 } })).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
it('function 선언 파라미터의 배열 구조 분해', () => {
|
||||
it('function 선언 파라미터의 배열 구조분해', () => {
|
||||
expect(evalx('(function f([a, b]) { return a + b; })([3, 4])')).toBe(7);
|
||||
});
|
||||
|
||||
it('구조 분해 대상이 아닌 값(undefined)은 각 이름에 undefined 바인딩', () => {
|
||||
expect(evalx('[undefined].map(([a]) => a === undefined)[0]')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spread (array / object / call)', () => {
|
||||
|
||||
@@ -71,34 +71,39 @@ test.describe('레이아웃 표현식 평가 샌드박스', () => {
|
||||
expect(result.date).toBe(true);
|
||||
});
|
||||
|
||||
// @scenario case=arrow_destructured_params
|
||||
// @scenario case=destructuring_param
|
||||
// @effects same_expression_same_value_across_paths
|
||||
test('화살표 파라미터의 배열 구조 분해가 배포 번들에서 동작한다 (engine-v1.60.5 회귀)', async ({ page }) => {
|
||||
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 {
|
||||
// 게시판 환경설정 권한 computed 실사용 형태: entries + filter([key]) + map([key])
|
||||
// _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])
|
||||
// 검증 오류 표시 형태: ([field, messages])
|
||||
fieldMessages: g7.evaluateCondition(
|
||||
"{{Object.entries(errors ?? {}).map(([field, messages]) => field + ':' + messages[0])[0] === 'name:이름은 필수입니다'}}",
|
||||
ctx,
|
||||
),
|
||||
// 엘리전 홀: ([, v])
|
||||
elision: g7.evaluateCondition('{{Object.entries(perms ?? {}).map(([,v]) => v).length === 3}}', 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);
|
||||
expect(result.elision).toBe(true);
|
||||
});
|
||||
|
||||
// @scenario case=constructor_escape
|
||||
|
||||
@@ -378,21 +378,17 @@ class SeoMetaResolverTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조화 데이터의 문자열 값이 `</script>` 를 담아도 JSON-LD `<script>` 블록을
|
||||
* 조기 종료시키지 못한다 — 봇 화면(서버 렌더)에서만 노출되는 저장형 XSS 차단.
|
||||
*
|
||||
* 검색어·게시글 제목·상품명 등 사용자 입력이 structured_data 의 name 으로 흘러
|
||||
* 들어가는데, `json_encode` 가 `<`/`>` 를 이스케이프하지 않으면 문자열 안의
|
||||
* `</script>` 가 ld+json 스크립트 태그를 닫고 그 뒤 `<script>` 가 실행된다.
|
||||
* HTML meta 경로(og/twitter/title)는 stripHtml 로 태그를 지우지만 JSON-LD 는
|
||||
* 별도 경로라 함께 지켜야 한다.
|
||||
* JSON-LD 는 `<script type="application/ld+json">` 안에 임베드되므로, 사용자
|
||||
* 제어 값(검색어 등)의 `</script>` 가 스크립트 컨텍스트를 조기 종료해 실행 가능한
|
||||
* script 요소를 재생성하지 못하도록 `<`·`>` 를 유니코드 이스케이프해야 한다.
|
||||
* (봇 렌더는 _escaped_fragment_ 로 일반 UA 도 강제되므로 반사형 XSS 벡터가 된다.)
|
||||
*/
|
||||
public function test_structured_data_json_ld_escapes_script_breakout(): void
|
||||
public function test_structured_data_escapes_script_breakout_in_json_ld(): void
|
||||
{
|
||||
$seoConfig = [
|
||||
'structured_data' => [
|
||||
'@type' => 'SearchResultsPage',
|
||||
'name' => '{{query.q}}',
|
||||
'name' => 'G7 - {{query.q}}',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -400,13 +396,14 @@ class SeoMetaResolverTest extends TestCase
|
||||
|
||||
$result = $this->resolver->resolve($seoConfig, $context, null, null, []);
|
||||
|
||||
$this->assertNotNull($result['jsonLd']);
|
||||
// 원문 `<script>` / `</script>` 시퀀스가 출력에 그대로 있으면 안 된다.
|
||||
$this->assertStringNotContainsString('<script', $result['jsonLd']);
|
||||
$this->assertStringNotContainsString('</script', $result['jsonLd']);
|
||||
// 값 자체는 보존되어야 한다(디코드하면 원문 그대로).
|
||||
// 원문 태그가 그대로 실리면 스크립트 컨텍스트가 조기 종료돼 실행 가능한 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('</script><script>alert(1)</script>', $decoded['name']);
|
||||
$this->assertSame('G7 - </script><script>alert(1)</script>', $decoded['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1597,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',
|
||||
],
|
||||
];
|
||||
@@ -1673,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('', '| '));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ description: |
|
||||
미실행(전역 canary `window.__g7jsi` 미설정) 양면을 잠근다.
|
||||
|
||||
axes:
|
||||
case: [safe_expression, arrow_template_literal, arrow_destructured_params, 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
|
||||
|
||||
Reference in New Issue
Block a user