diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs
index 4f2e4384f..3ba14405f 100644
--- a/crates/formats/src/html.rs
+++ b/crates/formats/src/html.rs
@@ -28,7 +28,14 @@ const MAX_STACK: usize = 256;
/// Decode an entity body (`amp`, `#38`, `#x26`) to its text.
pub fn decode_entity(name: &str) -> Option {
if let Some(num) = name.strip_prefix('#') {
- let v = if let Some(h) = num.strip_prefix(['x', 'X']) { u32::from_str_radix(h, 16).ok()? } else { num.parse::().ok()? };
+ let (digits, radix) = match num.strip_prefix(['x', 'X']) {
+ Some(h) => (h, 16),
+ None => (num, 10),
+ };
+ if !digits.chars().all(|c| c.is_digit(radix)) {
+ return None;
+ }
+ let v = u32::from_str_radix(digits, radix).ok()?;
let c = match v {
0 => '\u{FFFD}',
0x80..=0x9F => crate::txt::cp1252(v as u8),
@@ -1782,6 +1789,13 @@ pub fn export_flow(flow: &Flow, lang: &str) -> String {
mod tests {
use super::*;
+ #[test]
+ fn numeric_entity_rejects_sign() {
+ assert_eq!(unescape("a+65;b"), "a+65;b");
+ assert_eq!(unescape("a+41;b"), "a+41;b");
+ assert_eq!(unescape("aAAb"), "aAAb");
+ }
+
fn texts(f: &Flow) -> Vec {
f.blocks
.iter()