diff --git a/src/parser/body_text.rs b/src/parser/body_text.rs index e3dec82a9e..cbe057790e 100644 --- a/src/parser/body_text.rs +++ b/src/parser/body_text.rs @@ -122,10 +122,56 @@ pub fn parse_body_text_section(data: &[u8]) -> Result { Ok(section) } +/// [#4827] 문단↔표↔셀 상호재귀 깊이 상한. +/// +/// 셀 안의 문단이 다시 표를 품는 사이클(`parse_paragraph`→`parse_ctrl_header`→ +/// `parse_control`→`parse_table_control`→`parse_cell`→`parse_paragraph_list`→ +/// `parse_paragraph`)에 상한이 없으면, 손상 문서가 스택을 고갈시켜 SIGSEGV(패닉과 달리 +/// `catch_unwind` 로 못 잡음) 를 낸다. 레코드 레벨은 10비트(≤1023)라 표 중첩이 최대 ~341겹까지 +/// 파일로 도달 가능하고, 그 깊이가 스레드 기본 스택 한계 근처라 크래시/완주가 비결정적으로 갈린다 +/// (#4822 §2). 이 재귀 계열은 머리말/꼬리말·각주/미주·글상자·캡션까지 **전부 `parse_paragraph` 를 +/// 경유**하므로, 그 진입 깊이를 스레드-로컬로 세어 한 곳에서 전 경로를 막는다(파라미터를 여러 +/// 호출부에 관통시키지 않는다). HWPX `MAX_HWPX_SECTION_DEPTH`(#4759)·HWP3(#4285)·HWP5 묶음 +/// 개체(#4761)·HML 의 형제 가드와 같은 취지·같은 값이다. 실문서의 표 중첩은 이에 한참 못 미친다. +pub(crate) const MAX_HWP5_SECTION_DEPTH: u32 = 64; + +thread_local! { + static HWP5_SECTION_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// `parse_paragraph` 진입 시 재귀 깊이를 +1 하고 이탈(Drop, 오류 전파·조기 반환 포함) 시 +/// 되돌리는 RAII 가드. 상한 초과면 스택을 고갈시키기 전에 오류로 거부한다. +struct SectionDepthGuard; + +impl SectionDepthGuard { + fn enter() -> Result { + HWP5_SECTION_DEPTH.with(|d| { + if d.get() >= MAX_HWP5_SECTION_DEPTH { + return Err(BodyTextError::ParseError(format!( + "문단 중첩이 {MAX_HWP5_SECTION_DEPTH} 단계를 초과했습니다(표·셀 상호재귀 상한)" + ))); + } + d.set(d.get() + 1); + Ok(SectionDepthGuard) + }) + } +} + +impl Drop for SectionDepthGuard { + fn drop(&mut self) { + HWP5_SECTION_DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + } +} + /// 문단 레코드 그룹에서 Paragraph 구성 /// /// records[0] = PARA_HEADER, records[1..] = 자식 레코드 pub fn parse_paragraph(records: &[Record]) -> Result { + // [#4827] 문단↔표↔셀 상호재귀 깊이 상한 — 위 `SectionDepthGuard` 참고. 진입 즉시 +1, + // 반환(오류·조기 반환 포함) 시 -1. 상한 초과 시 `parse_paragraph_list` 의 `if let Ok(..)` + // 가 해당 하위 트리만 절단하고 나머지는 정상 파싱한다. + let _depth_guard = SectionDepthGuard::enter()?; + if records.is_empty() || records[0].tag_id != tags::HWPTAG_PARA_HEADER { return Err(BodyTextError::ParseError("PARA_HEADER 레코드 없음".into())); } diff --git a/src/parser/body_text/tests.rs b/src/parser/body_text/tests.rs index 5eeeaafd0e..fc8846d261 100644 --- a/src/parser/body_text/tests.rs +++ b/src/parser/body_text/tests.rs @@ -852,3 +852,95 @@ fn test_table_paragraph_diagnostics() { eprintln!("=== 진단 완료 ===\n"); } + +// ========================================================================== +// [#4827] 문단↔표↔셀 상호재귀 깊이 상한 회귀 — 손상 문서 스택 오버플로 DoS 가드 +// ========================================================================== + +/// 표 `depth` 겹을 선형 중첩한 BodyText 레코드 바이트 스트림을 만든다. +/// +/// 한 겹 = PARA_HEADER(L) → CTRL_HEADER(L+1, `tbl `) → HWPTAG_TABLE(L+2) → +/// LIST_HEADER(L+2, 셀). 셀 안의 다음 PARA_HEADER 는 L+3 — 즉 표 한 겹이 레코드 레벨을 3 판다. +/// 레벨 필드는 10비트(≤1023)라 이 방식으로 최대 ~341겹까지 만들 수 있다(실파일 도달 한계). +fn build_nested_table_stream(depth: u16) -> Vec { + let para = make_para_header_data(0, 0, 0); + let table_data = [0u8; 4]; + let cell_data = [0u8; 32]; + let ctrl = tags::CTRL_TABLE.to_le_bytes(); + + let mut bytes = Vec::new(); + for k in 0..depth { + let l = 3 * k; + bytes.extend(make_record_bytes(tags::HWPTAG_PARA_HEADER, l, ¶)); + bytes.extend(make_record_bytes(tags::HWPTAG_CTRL_HEADER, l + 1, &ctrl)); + bytes.extend(make_record_bytes(tags::HWPTAG_TABLE, l + 2, &table_data)); + bytes.extend(make_record_bytes( + tags::HWPTAG_LIST_HEADER, + l + 2, + &cell_data, + )); + } + bytes.extend(make_record_bytes( + tags::HWPTAG_PARA_HEADER, + 3 * depth, + ¶, + )); + bytes +} + +/// 파싱된 문단 트리에서 최대 표 중첩 깊이를 잰다(표→셀→문단 재귀). +fn max_table_nesting(paras: &[crate::model::paragraph::Paragraph]) -> usize { + let mut best = 0; + for p in paras { + for c in &p.controls { + if let Control::Table(t) = c { + let mut deepest = 0; + for cell in &t.cells { + deepest = deepest.max(max_table_nesting(&cell.paragraphs)); + } + best = best.max(1 + deepest); + } + } + } + best +} + +#[test] +fn nested_table_recursion_is_depth_capped() { + // 상한(64)을 크게 넘는 표 중첩을 파싱해도 크래시 없이 완주하고, 결과 트리의 표 중첩 + // 깊이가 상한 이내로 절단돼야 한다. 가드가 없으면 이 입력은 341겹 근처에서 스택을 + // 고갈시켜 SIGSEGV 를 내거나(비결정적) 입력 깊이 그대로 내려간다. 넉넉한 스택 전용 + // 스레드에서 경계를 결정론적으로 시험한다(HWPX #4759 형제 테스트와 같은 방식). + let input_depth = MAX_HWP5_SECTION_DEPTH + 40; + let nesting = std::thread::Builder::new() + .stack_size(64 * 1024 * 1024) + .spawn(move || { + let stream = build_nested_table_stream(input_depth as u16); + let section = parse_body_text_section(&stream).expect("파싱은 성공(하위 트리만 절단)"); + max_table_nesting(§ion.paragraphs) + }) + .expect("파서 스레드 생성 실패") + .join() + .expect("파서 스레드 패닉"); + + assert!( + nesting <= MAX_HWP5_SECTION_DEPTH as usize, + "표 중첩이 상한을 넘겨 절단되지 않았다 — 상호재귀 깊이 가드 회귀 (nesting={nesting})" + ); + assert!( + nesting >= 8, + "가드가 얕은 깊이에서 과잉 차단했다 (nesting={nesting})" + ); +} + +#[test] +fn shallow_table_nesting_is_preserved() { + // 상한 안쪽의 정상적인 표 중첩은 깊이 그대로 보존돼야 한다(가드가 과잉 차단 안 함). + let stream = build_nested_table_stream(5); + let section = parse_body_text_section(&stream).expect("파싱 실패"); + assert_eq!( + max_table_nesting(§ion.paragraphs), + 5, + "정상 깊이(5겹) 표 중첩이 보존되지 않았다 — 가드 과잉 차단" + ); +}