View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import java.util.Arrays;
19  
20  import jakarta.annotation.PostConstruct;
21  
22  import org.apache.logging.log4j.LogManager;
23  import org.apache.logging.log4j.Logger;
24  import org.commonmark.Extension;
25  import org.commonmark.ext.gfm.tables.TablesExtension;
26  import org.commonmark.parser.Parser;
27  import org.commonmark.renderer.html.HtmlRenderer;
28  import org.owasp.html.HtmlPolicyBuilder;
29  import org.owasp.html.PolicyFactory;
30  
31  /**
32   * Renders markdown to sanitized HTML for safe display in the chat interface.
33   * Uses commonmark for markdown parsing and OWASP HTML Sanitizer for XSS prevention.
34   */
35  public class MarkdownRenderer {
36  
37      private static final Logger logger = LogManager.getLogger(MarkdownRenderer.class);
38  
39      private Parser markdownParser;
40      private HtmlRenderer htmlRenderer;
41      private PolicyFactory htmlSanitizer;
42  
43      /**
44       * Default constructor.
45       */
46      public MarkdownRenderer() {
47          // empty
48      }
49  
50      /**
51       * Initializes the markdown parser, HTML renderer, and sanitizer.
52       */
53      @PostConstruct
54      public void init() {
55          // Configure commonmark with table extension
56          final Iterable<Extension> extensions = Arrays.asList(TablesExtension.create());
57  
58          markdownParser = Parser.builder().extensions(extensions).build();
59  
60          htmlRenderer = HtmlRenderer.builder().extensions(extensions).softbreak("<br/>").build();
61  
62          // Configure OWASP HTML Sanitizer with allowed tags
63          htmlSanitizer = new HtmlPolicyBuilder()
64                  // Headings
65                  .allowElements("h1", "h2", "h3", "h4", "h5", "h6")
66                  // Text formatting
67                  .allowElements("p", "br", "hr")
68                  .allowElements("strong", "em", "b", "i", "u", "s", "del")
69                  // Lists
70                  .allowElements("ul", "ol", "li")
71                  // Code
72                  .allowElements("code", "pre")
73                  // Blockquote
74                  .allowElements("blockquote")
75                  // Tables
76                  .allowElements("table", "thead", "tbody", "tr", "th", "td")
77                  // Links - only allow http/https protocols
78                  .allowElements("a")
79                  .allowUrlProtocols("http", "https")
80                  .allowAttributes("href")
81                  .onElements("a")
82                  .requireRelNofollowOnLinks()
83                  // Images - only allow http/https protocols
84                  .allowElements("img")
85                  .allowUrlProtocols("http", "https")
86                  .allowAttributes("src", "alt", "title")
87                  .onElements("img")
88                  // Span and div for formatting
89                  .allowElements("span", "div")
90                  // Class attributes for styling code blocks
91                  .allowAttributes("class")
92                  .onElements("code", "pre", "span", "div")
93                  .toFactory();
94  
95          if (logger.isDebugEnabled()) {
96              logger.debug("MarkdownRenderer initialized with commonmark and OWASP sanitizer");
97          }
98      }
99  
100     /**
101      * Renders markdown text to sanitized HTML.
102      *
103      * @param markdown the markdown text to render
104      * @return sanitized HTML string
105      */
106     public String render(final String markdown) {
107         if (markdown == null || markdown.isEmpty()) {
108             return "";
109         }
110 
111         try {
112             // Parse markdown to AST
113             final var document = markdownParser.parse(markdown);
114 
115             // Render AST to HTML
116             final String html = htmlRenderer.render(document);
117 
118             // Sanitize HTML to prevent XSS
119             final String sanitizedHtml = htmlSanitizer.sanitize(html);
120 
121             if (logger.isDebugEnabled()) {
122                 logger.debug("Rendered markdown. inputLength={}, outputLength={}", markdown.length(), sanitizedHtml.length());
123             }
124 
125             return sanitizedHtml;
126         } catch (final Exception e) {
127             logger.warn("Failed to render markdown, returning escaped plain text. error={}", e.getMessage());
128             // Fallback to escaped plain text
129             return escapeHtml(markdown);
130         }
131     }
132 
133     /**
134      * Escapes HTML special characters for safe display.
135      *
136      * @param text the text to escape
137      * @return HTML-escaped text
138      */
139     private String escapeHtml(final String text) {
140         if (text == null) {
141             return "";
142         }
143         return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;");
144     }
145 
146     /**
147      * Checks if the renderer is properly initialized.
148      *
149      * @return true if initialized
150      */
151     public boolean isInitialized() {
152         return markdownParser != null && htmlRenderer != null && htmlSanitizer != null;
153     }
154 }