Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/parser/body_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,56 @@ pub fn parse_body_text_section(data: &[u8]) -> Result<Section, BodyTextError> {
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<u32> = const { std::cell::Cell::new(0) };
}

/// `parse_paragraph` 진입 시 재귀 깊이를 +1 하고 이탈(Drop, 오류 전파·조기 반환 포함) 시
/// 되돌리는 RAII 가드. 상한 초과면 스택을 고갈시키기 전에 오류로 거부한다.
struct SectionDepthGuard;

impl SectionDepthGuard {
fn enter() -> Result<SectionDepthGuard, BodyTextError> {
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<Paragraph, BodyTextError> {
// [#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()));
}
Expand Down
92 changes: 92 additions & 0 deletions src/parser/body_text/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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, &para));
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,
&para,
));
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(&section.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(&section.paragraphs),
5,
"정상 깊이(5겹) 표 중첩이 보존되지 않았다 — 가드 과잉 차단"
);
}