001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.views.markdown; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.util.*; 022 023import org.commonmark.Extension; 024import org.commonmark.ext.gfm.tables.TablesExtension; 025import org.commonmark.node.Node; 026import org.commonmark.parser.Parser; 027import org.commonmark.renderer.html.AttributeProvider; 028import org.commonmark.renderer.html.HtmlRenderer; 029 030/** 031 * The default {@link MarkdownRenderer} implementation: a thin wrapper around 032 * <a class="doclink" href="https://github.com/commonmark/commonmark-java">commonmark-java</a> with the GFM tables 033 * extension enabled, HTML-escaped raw markup, and URL allowlisting on {@code <a href>} / {@code <img src>}. 034 * 035 * <p> 036 * The GFM tables extension is on by default because real-world documents (runbooks, onboarding guides, skills) 037 * routinely use pipe tables, and core CommonMark renders them as paragraphs of literal pipes. 038 * 039 * <p> 040 * Raw HTML in the markdown source is escaped ({@code escapeHtml(true)}), so a {@code <script>} or 041 * {@code <img onerror>} in a {@code SKILL.md} becomes visible text, not an element. Link and image URLs are 042 * restricted to {@code http}/{@code https}/{@code mailto}, fragments, and scheme-less relative paths; 043 * {@code javascript:}, {@code data:}, and {@code vbscript:} are stripped. 044 * 045 * <p> 046 * commonmark-java's {@link Parser} and {@link HtmlRenderer} are immutable and thread-safe once built, so a single 047 * instance of this class is safe to share across threads and reuse for every render. 048 * 049 * <h5 class='section'>See Also:</h5> 050 * <ul> 051 * <li class='jc'>{@link MarkdownRenderer} 052 * <li class='link'><a class="doclink" href="https://github.github.com/gfm/#tables-extension-">GFM tables extension</a> 053 * </ul> 054 * 055 * @since 10.0.0 056 */ 057public class CommonmarkMarkdownRenderer implements MarkdownRenderer { 058 059 private final Parser parser; 060 private final HtmlRenderer renderer; 061 062 /** 063 * Constructor. 064 * 065 * <p> 066 * Builds a parser/renderer pair with the GFM tables extension enabled, raw HTML escaped, and unsafe URLs 067 * stripped. 068 */ 069 public CommonmarkMarkdownRenderer() { 070 List<Extension> extensions = List.of(TablesExtension.create()); 071 parser = Parser.builder().extensions(extensions).build(); 072 renderer = HtmlRenderer.builder() 073 .extensions(extensions) 074 .escapeHtml(true) 075 .attributeProviderFactory(ctx -> new SafeUrlAttributeProvider()) 076 .build(); 077 } 078 079 @Override /* MarkdownRenderer */ 080 public String toHtml(String markdown) { 081 if (markdown == null) 082 throw iaex("Markdown source must not be null."); 083 return renderer.render(parser.parse(markdown)); 084 } 085 086 /** 087 * Whether {@code url} is safe to emit as an {@code href} or {@code src}: {@code http}/{@code https}/ 088 * {@code mailto}, a fragment, a same-origin path, or a scheme-less relative URL. 089 * 090 * @param url The candidate URL. May be <jk>null</jk>. 091 * @return <jk>true</jk> if the URL may be copied onto an element. 092 */ 093 public static boolean isSafeUrl(String url) { 094 if (ib(url)) 095 return false; 096 var t = url.trim(); 097 var lower = t.toLowerCase(Locale.ROOT); 098 if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:")) 099 return false; 100 if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) 101 return true; 102 if (t.charAt(0) == '#' || t.charAt(0) == '/') 103 return true; 104 return lower.indexOf(':') < 0; 105 } 106 107 private static final class SafeUrlAttributeProvider implements AttributeProvider { 108 @Override 109 public void setAttributes(Node node, String tagName, Map<String,String> attributes) { 110 if (eq(tagName, "a") && attributes.containsKey("href") && ! isSafeUrl(attributes.get("href"))) 111 attributes.remove("href"); 112 if (eq(tagName, "img") && attributes.containsKey("src") && ! isSafeUrl(attributes.get("src"))) 113 attributes.remove("src"); 114 } 115 } 116}