| 1 | package edu.ucsb.cs.scaffold.services; | |
| 2 | ||
| 3 | import java.util.Set; | |
| 4 | import org.commonmark.node.AbstractVisitor; | |
| 5 | import org.commonmark.node.HtmlBlock; | |
| 6 | import org.commonmark.node.HtmlInline; | |
| 7 | import org.commonmark.node.Image; | |
| 8 | import org.commonmark.node.Link; | |
| 9 | import org.commonmark.node.Node; | |
| 10 | import org.commonmark.node.Paragraph; | |
| 11 | import org.commonmark.node.Text; | |
| 12 | import org.commonmark.parser.Parser; | |
| 13 | import org.commonmark.renderer.html.HtmlRenderer; | |
| 14 | import org.commonmark.renderer.markdown.MarkdownRenderer; | |
| 15 | import org.commonmark.renderer.text.TextContentRenderer; | |
| 16 | import org.owasp.html.HtmlPolicyBuilder; | |
| 17 | import org.owasp.html.PolicyFactory; | |
| 18 | import org.springframework.stereotype.Service; | |
| 19 | ||
| 20 | /** | |
| 21 | * Cleans user-supplied Markdown before it is stored. Markdown itself has no invalid syntax (every | |
| 22 | * string is a valid CommonMark document), so "linting" takes the form of reformatting to canonical | |
| 23 | * CommonMark. The security-relevant part is sanitizing raw HTML embedded in the Markdown: only the | |
| 24 | * HTML nodes of the parsed document are run through the OWASP sanitizer, so code samples such as | |
| 25 | * <code>if a < b:</code> in text or code blocks are left untouched. | |
| 26 | */ | |
| 27 | @Service | |
| 28 | public class MarkdownService { | |
| 29 | ||
| 30 | private static final Set<String> ALLOWED_LINK_SCHEMES = Set.of("http", "https", "mailto"); | |
| 31 | ||
| 32 | private static final PolicyFactory HTML_POLICY = | |
| 33 | new HtmlPolicyBuilder() | |
| 34 | .allowElements( | |
| 35 | "p", | |
| 36 | "b", | |
| 37 | "i", | |
| 38 | "em", | |
| 39 | "strong", | |
| 40 | "a", | |
| 41 | "h1", | |
| 42 | "h2", | |
| 43 | "h3", | |
| 44 | "h4", | |
| 45 | "h5", | |
| 46 | "h6", | |
| 47 | "ul", | |
| 48 | "ol", | |
| 49 | "li", | |
| 50 | "code", | |
| 51 | "pre", | |
| 52 | "blockquote", | |
| 53 | "hr", | |
| 54 | "br", | |
| 55 | "table", | |
| 56 | "thead", | |
| 57 | "tbody", | |
| 58 | "tr", | |
| 59 | "th", | |
| 60 | "td", | |
| 61 | "span", | |
| 62 | "div", | |
| 63 | "img") | |
| 64 | .allowAttributes("href") | |
| 65 | .onElements("a") | |
| 66 | .allowAttributes("class") | |
| 67 | .onElements("code", "span", "div") | |
| 68 | .allowAttributes("src", "alt") | |
| 69 | .onElements("img") | |
| 70 | .allowUrlProtocols("http", "https", "mailto") | |
| 71 | .requireRelNofollowOnLinks() | |
| 72 | .toFactory(); | |
| 73 | ||
| 74 | private final Parser parser = Parser.builder().build(); | |
| 75 | private final MarkdownRenderer markdownRenderer = MarkdownRenderer.builder().build(); | |
| 76 | private final TextContentRenderer textContentRenderer = TextContentRenderer.builder().build(); | |
| 77 | private final HtmlRenderer htmlRenderer = HtmlRenderer.builder().build(); | |
| 78 | ||
| 79 | /** | |
| 80 | * Sanitizes raw HTML embedded in the Markdown (dropping anything the OWASP policy rejects, | |
| 81 | * including scripts and javascript: URLs), removes unsafe link/image destinations, and reformats | |
| 82 | * the result to canonical CommonMark. | |
| 83 | * | |
| 84 | * @param markdown the raw Markdown, may be null | |
| 85 | * @return the cleaned Markdown, or null if the input was null | |
| 86 | */ | |
| 87 | public String clean(String markdown) { | |
| 88 |
1
1. clean : negated conditional → KILLED |
if (markdown == null) { |
| 89 |
1
1. clean : replaced return value with "" for edu/ucsb/cs/scaffold/services/MarkdownService::clean → KILLED |
return null; |
| 90 | } | |
| 91 | Node document = parser.parse(markdown); | |
| 92 |
1
1. clean : removed call to org/commonmark/node/Node::accept → KILLED |
document.accept( |
| 93 | new AbstractVisitor() { | |
| 94 | @Override | |
| 95 | public void visit(HtmlBlock htmlBlock) { | |
| 96 | String safe = HTML_POLICY.sanitize(htmlBlock.getLiteral()); | |
| 97 |
1
1. visit : negated conditional → KILLED |
if (safe.isBlank()) { |
| 98 |
1
1. visit : removed call to org/commonmark/node/HtmlBlock::unlink → KILLED |
htmlBlock.unlink(); |
| 99 | } else { | |
| 100 |
1
1. visit : removed call to org/commonmark/node/HtmlBlock::setLiteral → KILLED |
htmlBlock.setLiteral(safe); |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | @Override | |
| 105 | public void visit(HtmlInline htmlInline) { | |
| 106 | String safe = HTML_POLICY.sanitize(htmlInline.getLiteral()); | |
| 107 |
1
1. visit : negated conditional → KILLED |
if (safe.isBlank()) { |
| 108 |
1
1. visit : removed call to org/commonmark/node/HtmlInline::unlink → KILLED |
htmlInline.unlink(); |
| 109 | } else { | |
| 110 |
1
1. visit : removed call to org/commonmark/node/HtmlInline::setLiteral → KILLED |
htmlInline.setLiteral(safe); |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | @Override | |
| 115 | public void visit(Link link) { | |
| 116 |
1
1. visit : removed call to org/commonmark/node/Link::setDestination → KILLED |
link.setDestination(safeDestination(link.getDestination())); |
| 117 |
1
1. visit : removed call to edu/ucsb/cs/scaffold/services/MarkdownService$1::visitChildren → KILLED |
visitChildren(link); |
| 118 | } | |
| 119 | ||
| 120 | @Override | |
| 121 | public void visit(Image image) { | |
| 122 |
1
1. visit : removed call to org/commonmark/node/Image::setDestination → KILLED |
image.setDestination(safeDestination(image.getDestination())); |
| 123 |
1
1. visit : removed call to edu/ucsb/cs/scaffold/services/MarkdownService$1::visitChildren → KILLED |
visitChildren(image); |
| 124 | } | |
| 125 | }); | |
| 126 |
1
1. clean : replaced return value with "" for edu/ucsb/cs/scaffold/services/MarkdownService::clean → KILLED |
return markdownRenderer.render(document).strip(); |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * Like {@link #clean}, but for short single-line-ish fields such as concept/subconcept labels, | |
| 131 | * which have no legitimate use for embedded raw HTML. Rather than sanitizing (and possibly | |
| 132 | * dropping) HTML-looking fragments, any raw HTML is turned into literal text, so that CS notation | |
| 133 | * such as {@code List<Integer>} or {@code Node<T>} round-trips intact instead of being silently | |
| 134 | * stripped by the HTML sanitizer (which can otherwise reduce a label to nothing and make it fail | |
| 135 | * the "label may not be empty" check). The result is still safe to render with {@link | |
| 136 | * #toInlineHtml}, since that runs its own HTML sanitization pass at display time. | |
| 137 | * | |
| 138 | * @param markdown the raw Markdown, may be null | |
| 139 | * @return the cleaned Markdown, or null if the input was null | |
| 140 | */ | |
| 141 | public String cleanLabel(String markdown) { | |
| 142 |
1
1. cleanLabel : negated conditional → KILLED |
if (markdown == null) { |
| 143 |
1
1. cleanLabel : replaced return value with "" for edu/ucsb/cs/scaffold/services/MarkdownService::cleanLabel → KILLED |
return null; |
| 144 | } | |
| 145 | Node document = parser.parse(markdown); | |
| 146 |
1
1. cleanLabel : removed call to org/commonmark/node/Node::accept → KILLED |
document.accept( |
| 147 | new AbstractVisitor() { | |
| 148 | @Override | |
| 149 | public void visit(HtmlBlock htmlBlock) { | |
| 150 | Paragraph replacement = new Paragraph(); | |
| 151 |
1
1. visit : removed call to org/commonmark/node/Paragraph::appendChild → KILLED |
replacement.appendChild(new Text(htmlBlock.getLiteral())); |
| 152 |
1
1. visit : removed call to org/commonmark/node/HtmlBlock::insertAfter → KILLED |
htmlBlock.insertAfter(replacement); |
| 153 |
1
1. visit : removed call to org/commonmark/node/HtmlBlock::unlink → KILLED |
htmlBlock.unlink(); |
| 154 | } | |
| 155 | ||
| 156 | @Override | |
| 157 | public void visit(HtmlInline htmlInline) { | |
| 158 |
1
1. visit : removed call to org/commonmark/node/HtmlInline::insertAfter → KILLED |
htmlInline.insertAfter(new Text(htmlInline.getLiteral())); |
| 159 |
1
1. visit : removed call to org/commonmark/node/HtmlInline::unlink → KILLED |
htmlInline.unlink(); |
| 160 | } | |
| 161 | ||
| 162 | @Override | |
| 163 | public void visit(Link link) { | |
| 164 |
1
1. visit : removed call to org/commonmark/node/Link::setDestination → KILLED |
link.setDestination(safeDestination(link.getDestination())); |
| 165 |
1
1. visit : removed call to edu/ucsb/cs/scaffold/services/MarkdownService$2::visitChildren → KILLED |
visitChildren(link); |
| 166 | } | |
| 167 | ||
| 168 | @Override | |
| 169 | public void visit(Image image) { | |
| 170 |
1
1. visit : removed call to org/commonmark/node/Image::setDestination → KILLED |
image.setDestination(safeDestination(image.getDestination())); |
| 171 |
1
1. visit : removed call to edu/ucsb/cs/scaffold/services/MarkdownService$2::visitChildren → KILLED |
visitChildren(image); |
| 172 | } | |
| 173 | }); | |
| 174 |
1
1. cleanLabel : replaced return value with "" for edu/ucsb/cs/scaffold/services/MarkdownService::cleanLabel → KILLED |
return markdownRenderer.render(document).strip(); |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Renders Markdown to HTML for direct display (e.g. via {@code dangerouslySetInnerHTML} on the | |
| 179 | * frontend). The rendered HTML is itself run through the OWASP sanitizer before being returned, | |
| 180 | * so the result is safe to inject even if the stored Markdown was never cleaned (e.g. seed data | |
| 181 | * inserted directly via SQL). | |
| 182 | * | |
| 183 | * @param markdown the raw Markdown, may be null | |
| 184 | * @return sanitized HTML, or null if the input was null | |
| 185 | */ | |
| 186 | public String toHtml(String markdown) { | |
| 187 |
1
1. toHtml : negated conditional → KILLED |
if (markdown == null) { |
| 188 |
1
1. toHtml : replaced return value with "" for edu/ucsb/cs/scaffold/services/MarkdownService::toHtml → KILLED |
return null; |
| 189 | } | |
| 190 | String html = htmlRenderer.render(parser.parse(markdown)); | |
| 191 |
1
1. toHtml : replaced return value with "" for edu/ucsb/cs/scaffold/services/MarkdownService::toHtml → KILLED |
return HTML_POLICY.sanitize(html).strip(); |
| 192 | } | |
| 193 | ||
| 194 | /** | |
| 195 | * Like {@link #toHtml}, but for short, single-line Markdown meant to be displayed inline (e.g. a | |
| 196 | * concept label shown inside a {@code <span>}): the block-level {@code <p>} wrapper CommonMark | |
| 197 | * would normally add around a single paragraph is stripped, since nesting a block element inside | |
| 198 | * an inline container is invalid HTML. | |
| 199 | * | |
| 200 | * @param markdown the raw Markdown, may be null | |
| 201 | * @return sanitized inline HTML, or null if the input was null | |
| 202 | */ | |
| 203 | public String toInlineHtml(String markdown) { | |
| 204 | String html = toHtml(markdown); | |
| 205 | // == -1 rather than < 0: indexOf with a fromIndex of 3 can only return -1 or >= 3, so the | |
| 206 | // two are equivalent, but < 0 leaves an unkillable "boundary" mutant (<= 0) for pitest. | |
| 207 |
1
1. toInlineHtml : negated conditional → KILLED |
if (html != null |
| 208 |
1
1. toInlineHtml : negated conditional → KILLED |
&& html.startsWith("<p>") |
| 209 |
1
1. toInlineHtml : negated conditional → KILLED |
&& html.endsWith("</p>") |
| 210 |
1
1. toInlineHtml : negated conditional → KILLED |
&& html.indexOf("<p>", "<p>".length()) == -1) { |
| 211 |
2
1. toInlineHtml : replaced return value with "" for edu/ucsb/cs/scaffold/services/MarkdownService::toInlineHtml → KILLED 2. toInlineHtml : Replaced integer subtraction with addition → KILLED |
return html.substring("<p>".length(), html.length() - "</p>".length()); |
| 212 | } | |
| 213 |
1
1. toInlineHtml : replaced return value with "" for edu/ucsb/cs/scaffold/services/MarkdownService::toInlineHtml → KILLED |
return html; |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Returns the length of the plain text this Markdown renders to, with runs of whitespace | |
| 218 | * collapsed to a single space. Used to enforce limits on the <i>rendered</i> length of a field | |
| 219 | * independent of how verbose the Markdown markup is. | |
| 220 | */ | |
| 221 | public int renderedLength(String markdown) { | |
| 222 |
1
1. renderedLength : negated conditional → KILLED |
if (markdown == null) { |
| 223 | return 0; | |
| 224 | } | |
| 225 | String text = textContentRenderer.render(parser.parse(markdown)); | |
| 226 |
1
1. renderedLength : replaced int return with 0 for edu/ucsb/cs/scaffold/services/MarkdownService::renderedLength → KILLED |
return text.strip().replaceAll("\\s+", " ").length(); |
| 227 | } | |
| 228 | ||
| 229 | /** Allows relative destinations and http/https/mailto; anything else is dropped. */ | |
| 230 | static String safeDestination(String destination) { | |
| 231 | int colon = destination.indexOf(':'); | |
| 232 |
2
1. safeDestination : changed conditional boundary → KILLED 2. safeDestination : negated conditional → KILLED |
if (colon < 0) { |
| 233 |
1
1. safeDestination : replaced return value with "" for edu/ucsb/cs/scaffold/services/MarkdownService::safeDestination → KILLED |
return destination; |
| 234 | } | |
| 235 | String scheme = destination.substring(0, colon).toLowerCase(); | |
| 236 |
1
1. safeDestination : negated conditional → KILLED |
return ALLOWED_LINK_SCHEMES.contains(scheme) ? destination : ""; |
| 237 | } | |
| 238 | } | |
Mutations | ||
| 88 |
1.1 |
|
| 89 |
1.1 |
|
| 92 |
1.1 |
|
| 97 |
1.1 |
|
| 98 |
1.1 |
|
| 100 |
1.1 |
|
| 107 |
1.1 |
|
| 108 |
1.1 |
|
| 110 |
1.1 |
|
| 116 |
1.1 |
|
| 117 |
1.1 |
|
| 122 |
1.1 |
|
| 123 |
1.1 |
|
| 126 |
1.1 |
|
| 142 |
1.1 |
|
| 143 |
1.1 |
|
| 146 |
1.1 |
|
| 151 |
1.1 |
|
| 152 |
1.1 |
|
| 153 |
1.1 |
|
| 158 |
1.1 |
|
| 159 |
1.1 |
|
| 164 |
1.1 |
|
| 165 |
1.1 |
|
| 170 |
1.1 |
|
| 171 |
1.1 |
|
| 174 |
1.1 |
|
| 187 |
1.1 |
|
| 188 |
1.1 |
|
| 191 |
1.1 |
|
| 207 |
1.1 |
|
| 208 |
1.1 |
|
| 209 |
1.1 |
|
| 210 |
1.1 |
|
| 211 |
1.1 2.2 |
|
| 213 |
1.1 |
|
| 222 |
1.1 |
|
| 226 |
1.1 |
|
| 232 |
1.1 2.2 |
|
| 233 |
1.1 |
|
| 236 |
1.1 |