diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java index 205bb0fd..a8a86312 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java @@ -65,10 +65,10 @@ public class WikiContentNormalizer { * Strip nav/footer/script/style/aside and ad-like classes from HTML, then * return readable text. *

- * The output never carries markup: input that still contains tags is parsed - * and stripped; input that is already tag-free is returned with whitespace - * cleanup only. When the document cannot be parsed (too large, or jsoup - * throws) it is run through {@link #stripMarkupLossy(String)} so that + * The output never carries markup: input that still contains element + * structure is parsed and stripped; input with no element structure is + * reduced to its text content. When the document cannot be parsed (too + * large, or jsoup throws) it is run through {@link #stripMarkupLossy(String)} so that * script/style bodies and tags are dropped without a full parse — the raw * markup is never passed through verbatim. */ @@ -92,7 +92,15 @@ public class WikiContentNormalizer { boolean hasMarkup = (body != null && !body.children().isEmpty()) || (head != null && !head.children().isEmpty()); if (!hasMarkup) { - return collapseBlankLines(rawHtml); + // No element structure — text that was already tag-stripped upstream + // (an extracted .html/.htm upload). Return its whole text rather than + // the raw string: wholeText() keeps the original line breaks (so ATX + // headings stay on their own lines) but carries no tags, no attributes + // and no comment nodes. The raw string must never be returned here — + // a bare / skeleton can still hold event-handler + // attributes that would otherwise leak through verbatim. + Element textRoot = body != null ? body : doc; + return collapseBlankLines(textRoot.wholeText()); } // Drop structural noise. diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContentNormalizerSecurityTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContentNormalizerSecurityTest.java new file mode 100644 index 00000000..9fe7d08a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContentNormalizerSecurityTest.java @@ -0,0 +1,215 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Adversarial tests for {@link WikiContentNormalizer}. + *

+ * Each test feeds hostile HTML through the html / htm / url normalization path + * and asserts that no executable markup — tags, event-handler attributes, or + * script/style bodies — survives into the normalized output, while legitimate + * prose and headings are preserved. The normalized output is plain text by + * contract, so it must contain no tag tokens at all. + */ +class WikiContentNormalizerSecurityTest { + + private WikiContentNormalizer normalizer; + + /** Any HTML start or end tag token ({@code in is fully removed") + void scriptInHead() { + String p = "" + + "

Doc

Body text.

"; + assertFullyStripped("script-in-head", p, "STEAL_COOKIES"); + String out = normalizer.normalize("html", p); + assertTrue(out.contains("# Doc"), "heading survives"); + assertTrue(out.contains("Body text."), "body survives"); + } + + @Test + @DisplayName("img onerror handler does not survive") + void imgOnError() { + assertNoLiveMarkup("img-onerror", + "

Before.

After.

"); + } + + @Test + @DisplayName("svg onload handler does not survive") + void svgOnLoad() { + assertNoLiveMarkup("svg-onload", + "

Visible.

"); + } + + @Test + @DisplayName("iframe with javascript: src does not survive") + void iframeJavascriptSrc() { + assertNoLiveMarkup("iframe-js", + "

Visible.

"); + } + + @Test + @DisplayName("mixed-case

Body.

", "STEAL"); + } + + @Test + @DisplayName("anchor with javascript: href keeps only its visible text") + void anchorJavascriptHref() { + String p = "click here"; + assertNoLiveMarkup("anchor-js", p); + String out = normalizer.normalize("html", p); + assertTrue(out.contains("click here"), "anchor visible text survives"); + assertFalse(out.contains("javascript:"), "javascript: URI dropped"); + } + + @Test + @DisplayName("event-handler attribute on a heading is dropped, heading text kept") + void eventHandlerOnHeading() { + String p = "

Section Title

x

"; + assertNoLiveMarkup("onclick-heading", p); + String out = normalizer.normalize("html", p); + assertTrue(out.contains("## Section Title"), "heading text and level survive"); + } + + @Test + @DisplayName("nested / mutation script tags do not yield a live tag") + void mutationScript() { + assertNoLiveMarkup("mutation", + "ipt>STEAL()ipt>

Body.

"); + } + + @Test + @DisplayName("noscript-wrapped script is removed") + void noscriptWrappedScript() { + assertNoLiveMarkup("noscript", + "

Body.

"); + } + + @Test + @DisplayName("object and embed elements do not survive") + void objectAndEmbed() { + assertNoLiveMarkup("object-embed", + "" + + "

Body.

"); + } + + @Test + @DisplayName("style block with a url() payload is removed") + void styleBlockRemoved() { + assertFullyStripped("style-url", + "" + + "

Body.

", "STEAL"); + } + + // ──────────────── sniff-evasion: markup with no element children ──────────────── + + @Test + @DisplayName("event handler on the skeleton tag does not survive") + void handlerOnHtmlSkeleton() { + assertNoLiveMarkup("html-skeleton-handler", + "plain body text only"); + } + + @Test + @DisplayName("event handler on the skeleton tag does not survive") + void handlerOnBodySkeleton() { + assertNoLiveMarkup("body-skeleton-handler", + "just text, no child elements"); + } + + @Test + @DisplayName("script hidden inside an HTML comment does not survive") + void scriptHiddenInComment() { + assertNoLiveMarkup("comment-script", + ""); + } + + // ─────────────────────── oversized payload: lossy strip path ─────────────────────── + + @Test + @DisplayName("oversized HTML beyond the parse cap still has its \n" + "lorem ipsum dolor ".repeat(500_000); + assertTrue(huge.length() > 8 * 1024 * 1024, "payload must exceed the 8 MB parse cap"); + String out = normalizer.normalize("html", huge); + assertFalse(out.toLowerCase().contains("Details

Second paragraph.

"; + String out = normalizer.normalize("html", p); + assertTrue(out.contains("# Guide"), "h1 survives"); + assertTrue(out.contains("## Details"), "h2 survives"); + assertTrue(out.contains("First paragraph."), "first paragraph survives"); + assertTrue(out.contains("Second paragraph."), "second paragraph survives"); + } +}