Fix rich() eating raw < in math, title-case chapter titles

Sanitizer root cause: a '<' followed by space/digit (e.g. '\(a < 0\)')
was parsed as a tag, failed allowlist, and got DELETED along with its
content — unbalancing KaTeX delimiters and killing all math after it
on the page. Now only '<' followed by letter, /, ! or ? starts a tag;
everything else becomes &lt;. 4 regression tests added.

CSS: .zp-chk-wrap and .zp-subtopic-row labels no longer inherit the
generic uppercase rule, so chapter names render in DB title case
('Quadratic Equations', not 'QUADRATIC EQUATIONS').
This commit is contained in:
dev 2026-10-16 14:38:23 +05:30
parent 5b732080d6
commit 600ff95315
2 changed files with 54 additions and 1 deletions

View file

@ -26,6 +26,14 @@ pub fn rich(s: &str) -> String {
"br", "p", "sup", "sub", "b", "strong", "i", "em", "u", "ol", "ul",
"li", "table", "tbody", "thead", "tr", "th", "td", "span",
];
// A `<` only starts a tag when followed by a letter, `/`, `!` or `?`
// (i.e. `<p>`, `</p>`, `<!--`, `<?`). A `<` followed by a space, digit,
// `=` or end-of-string is literal math text (`\(a < 0\)`) and must be
// escaped, not parsed as a tag — otherwise the sanitizer eats the `<`
// and its content, unbalancing the KaTeX delimiters downstream.
fn is_tag_start(after_lt: &str) -> bool {
matches!(after_lt.chars().next(), Some('a'..='z' | 'A'..='Z' | '/' | '!' | '?'))
}
let mut o = String::with_capacity(s.len());
let mut rest = s;
while let Some(idx) = rest.find('<') {
@ -37,6 +45,12 @@ pub fn rich(s: &str) -> String {
other => o.push(other),
}
}
let after_lt = &after[1..];
if !is_tag_start(after_lt) {
o.push_str("&lt;");
rest = after_lt;
continue;
}
if let Some(end) = after.find('>') {
let inner = after[1..end].trim();
let (closing, name) = match inner.strip_prefix('/') {
@ -1247,4 +1261,37 @@ pub fn shared(ctx: &Ctx, title: &str, duration: i64, questions: &[Question], pap
cta = cta
);
shell(ctx, None, title, body)
}
}
#[cfg(test)]
mod rich_tests {
use super::rich;
#[test]
fn raw_less_than_in_math_survives() {
let input = r"<p>B. \(\operatorname{Im}\left(z_{1}\right) < 0\) and \(\operatorname{Im}\left(z_{2}\right) > 0\)</p>";
let out = rich(input);
// The `<` must be escaped, not eaten; delimiters stay balanced.
assert!(out.contains("&lt;"), "raw < must become &lt;: {out}");
assert_eq!(out.matches(r"\(").count(), 2, "both openers survive: {out}");
assert_eq!(out.matches(r"\)").count(), 2, "both closers survive: {out}");
}
#[test]
fn real_tags_still_pass_through() {
let out = rich("<p>Hello <b>world</b><br>line</p>");
assert_eq!(out, "<p>Hello <b>world</b><br>line</p>");
}
#[test]
fn script_tags_still_stripped() {
let out = rich("<p>x</p><script>alert(1)</script>");
assert!(!out.contains("script"), "script tags must go: {out}");
assert!(out.contains("alert(1)"), "inner text stays: {out}");
}
#[test]
fn stray_lt_at_end_escaped() {
let out = rich("a < b and c <");
assert_eq!(out, "a &lt; b and c &lt;");
}
}

View file

@ -1837,6 +1837,9 @@ button.pal-fab {
gap: 14px;
flex: 1;
cursor: pointer;
text-transform: none;
letter-spacing: normal;
margin: 0;
}
.zp-chk-wrap input[type=checkbox] {
@ -1929,6 +1932,9 @@ button.pal-fab {
cursor: pointer;
font-size: 0.85rem;
color: #cbd5e1;
text-transform: none;
letter-spacing: normal;
margin: 0;
}
.zp-subtopic-row:hover {