001package org.jsoup.safety;
002
003import org.jsoup.helper.Validate;
004import org.jsoup.nodes.Attribute;
005import org.jsoup.nodes.Attributes;
006import org.jsoup.nodes.DataNode;
007import org.jsoup.nodes.Document;
008import org.jsoup.nodes.Element;
009import org.jsoup.nodes.Node;
010import org.jsoup.nodes.NodeInternals;
011import org.jsoup.nodes.Range;
012import org.jsoup.nodes.TextNode;
013import org.jsoup.parser.ParseErrorList;
014import org.jsoup.parser.Parser;
015import org.jsoup.select.NodeVisitor;
016
017import java.net.MalformedURLException;
018import java.net.URL;
019import java.util.List;
020
021import static org.jsoup.internal.SharedConstants.DummyUri;
022
023/**
024 The {@link Safelist}-based HTML cleaner. Use to ensure that end-user provided HTML contains only the elements and attributes
025 that you are expecting; no junk, and no cross-site scripting attacks!
026 <p>
027 The HTML cleaner parses the input as HTML and then runs it through a safelist, so the output HTML can only contain
028 HTML that is allowed by the safelist.
029 </p>
030 <p>
031 It is assumed that the input HTML is a body fragment; the clean methods only pull from the source's body, and the
032 canned safelists only allow body-contained tags.
033 </p>
034 <p>
035 Rather than interacting directly with a Cleaner object, generally see the {@code clean} methods in {@link org.jsoup.Jsoup}.
036 </p>
037 <p>
038 A Cleaner may be reused across multiple documents and shared across concurrent threads once its {@link Safelist} has
039 been configured. The cleaner uses the supplied safelist directly, so later safelist changes affect later cleaning
040 calls. If you need a variant of an existing configuration, use {@link Safelist#Safelist(Safelist)} to make a copy.
041 </p>
042 */
043public class Cleaner {
044    private final Safelist safelist;
045
046    /**
047     Create a new cleaner, that sanitizes documents using the supplied safelist.
048     @param safelist safe-list to clean with
049     */
050    public Cleaner(Safelist safelist) {
051        Validate.notNull(safelist);
052        this.safelist = safelist;
053    }
054
055    /**
056     Creates a new, clean document, from the original dirty document, containing only elements allowed by the safelist.
057     The original document is not modified. Only elements from the dirty document's <code>body</code> are used. The
058     OutputSettings of the original document are cloned into the clean document.
059     @param dirtyDocument Untrusted base document to clean.
060     @return cleaned document.
061     */
062    public Document clean(Document dirtyDocument) {
063        Validate.notNull(dirtyDocument);
064
065        Document clean = Document.createShell(dirtyDocument.baseUri());
066        copySafeNodes(dirtyDocument.body(), clean.body());
067        clean.outputSettings(dirtyDocument.outputSettings().clone());
068
069        return clean;
070    }
071
072    /**
073     Determines if the input document's <b>body</b> is valid, against the safelist. It is considered valid if all the
074     tags and attributes in the input HTML are allowed by the safelist, and that there is no content in the
075     <code>head</code>.
076     <p>
077     This method is intended to be used in a user interface as a validator for user input. Note that regardless of the
078     output of this method, the input document <b>must always</b> be normalized using a method such as
079     {@link #clean(Document)}, and the result of that method used to store or serialize the document before later reuse
080     such as presentation to end users. This ensures that enforced attributes are set correctly, and that any
081     differences between how a given browser and how jsoup parses the input HTML are normalized.
082     </p>
083     <p>Example:
084     <pre>{@code
085     Document inputDoc = Jsoup.parse(inputHtml);
086     Cleaner cleaner = new Cleaner(Safelist.relaxed());
087     boolean isValid = cleaner.isValid(inputDoc);
088     Document normalizedDoc = cleaner.clean(inputDoc);
089     }</pre>
090     </p>
091     @param dirtyDocument document to test
092     @return true if no tags or attributes need to be removed; false if they do
093     */
094    public boolean isValid(Document dirtyDocument) {
095        Validate.notNull(dirtyDocument);
096
097        Document clean = Document.createShell(dirtyDocument.baseUri());
098        int numDiscarded = copySafeNodes(dirtyDocument.body(), clean.body());
099        return numDiscarded == 0
100            && dirtyDocument.head().childNodes().isEmpty(); // because we only look at the body, but we start from a shell, make sure there's nothing in the head
101    }
102
103    /**
104     Determines if the input document's <b>body HTML</b> is valid, against the safelist. It is considered valid if all
105     the tags and attributes in the input HTML are allowed by the safelist.
106     <p>
107     This method is intended to be used in a user interface as a validator for user input. Note that regardless of the
108     output of this method, the input document <b>must always</b> be normalized using a method such as
109     {@link #clean(Document)}, and the result of that method used to store or serialize the document before later reuse
110     such as presentation to end users. This ensures that enforced attributes are set correctly, and that any
111     differences between how a given browser and how jsoup parses the input HTML are normalized.
112     </p>
113     <p>Example:
114     <pre>{@code
115     Document inputDoc = Jsoup.parse(inputHtml);
116     Cleaner cleaner = new Cleaner(Safelist.relaxed());
117     boolean isValid = cleaner.isValidBodyHtml(inputHtml);
118     Document normalizedDoc = cleaner.clean(inputDoc);
119     }</pre>
120     </p>
121     @param bodyHtml HTML fragment to test
122     @return true if no tags or attributes need to be removed; false if they do
123     */
124    public boolean isValidBodyHtml(String bodyHtml) {
125        String baseUri = (safelist.preserveRelativeLinks()) ? DummyUri : ""; // fake base URI to allow relative URLs to remain valid
126        Document clean = Document.createShell(baseUri);
127        Document dirty = Document.createShell(baseUri);
128        ParseErrorList errorList = ParseErrorList.tracking(1);
129        List<Node> nodes = Parser.parseFragment(bodyHtml, dirty.body(), baseUri, errorList);
130        dirty.body().insertChildren(0, nodes);
131        int numDiscarded = copySafeNodes(dirty.body(), clean.body());
132        return numDiscarded == 0 && errorList.isEmpty();
133    }
134
135    /**
136     Iterates the input and copies trusted nodes (tags, attributes, text) into the destination.
137     */
138    private final class CleaningVisitor implements NodeVisitor {
139        private int numDiscarded = 0;
140        private final Element root;
141        private Element destination; // current element to append nodes to
142
143        private CleaningVisitor(Element root, Element destination) {
144            this.root = root;
145            this.destination = destination;
146        }
147
148        @Override public void head(Node source, int depth) {
149            if (source instanceof Element) {
150                Element sourceEl = (Element) source;
151
152                if (safelist.isSafeTag(sourceEl.normalName())) { // safe, clone and copy safe attrs
153                    ElementMeta meta = createSafeElement(sourceEl);
154                    Element destChild = meta.el;
155                    destination.appendChild(destChild);
156
157                    numDiscarded += meta.numAttribsDiscarded;
158                    destination = destChild;
159                } else if (source != root) { // not a safe tag, so don't add. don't count root against discarded.
160                    numDiscarded++;
161                }
162            } else if (source instanceof TextNode) {
163                TextNode sourceText = (TextNode) source;
164                TextNode destText = new TextNode(sourceText.getWholeText());
165                destination.appendChild(destText);
166            } else if (source instanceof DataNode && safelist.isSafeTag(source.parent().normalName())) {
167                DataNode sourceData = (DataNode) source;
168                DataNode destData = new DataNode(sourceData.getWholeData());
169                destination.appendChild(destData);
170            } else { // else, we don't care about comments, xml proc instructions, etc
171                numDiscarded++;
172            }
173        }
174
175        @Override public void tail(Node source, int depth) {
176            if (source instanceof Element && safelist.isSafeTag(source.normalName())) {
177                destination = destination.parent(); // would have descended, so pop destination stack
178            }
179        }
180    }
181
182    private int copySafeNodes(Element source, Element dest) {
183        CleaningVisitor cleaningVisitor = new CleaningVisitor(source, dest);
184        cleaningVisitor.traverse(source);
185        return cleaningVisitor.numDiscarded;
186    }
187
188    private ElementMeta createSafeElement(Element sourceEl) {
189        Element dest = sourceEl.shallowClone(); // reuses tag, clones attributes and preserves any user data
190        String sourceTag = sourceEl.tagName();
191        Attributes destAttrs = dest.attributes();
192        dest.clearAttributes(); // clear all non-internal attributes, ready for safe copy
193
194        int numDiscarded = 0;
195        Attributes sourceAttrs = sourceEl.attributes();
196        for (Attribute sourceAttr : sourceAttrs) {
197            if (safelist.isSafeAttribute(sourceTag, sourceEl, sourceAttr)) { // will keep this attr
198                String key = sourceAttr.getKey();
199                String value = sourceAttr.getValue();
200
201                if (safelist.shouldAbsUrl(sourceTag, key)) { // configured to make absolute urls for this key (href)
202                    value = sourceEl.absUrl(key);
203                    if (value.isEmpty()) // could not be made abs; leave as-is to allow custom unknown protocols
204                        value = sourceAttr.getValue();
205                }
206                Range.AttributeRange range = sourceAttrs.sourceRange(key);
207                destAttrs.put(key, value);
208                NodeInternals.attributeRange(destAttrs, key, range);
209            } else
210                numDiscarded++;
211        }
212
213        Attributes enforcedAttrs = safelist.getEnforcedAttributes(sourceTag);
214        // special case for <a href rel=nofollow>, only apply to external links:
215        if (sourceEl.nameIs("a") && enforcedAttrs.get("rel").equals("nofollow")) {
216            String href = sourceEl.absUrl("href");
217            if (!href.isEmpty()) {
218                try {
219                    URL baseUrl = new URL(sourceEl.baseUri());
220                    URL linkUrl = new URL(href);
221                    String baseHost = baseUrl.getHost();
222                    if (!baseHost.isEmpty() && baseHost.equalsIgnoreCase(linkUrl.getHost())) // same site, so don't set the nofollow
223                        enforcedAttrs.remove("rel");
224                } catch (MalformedURLException ignored) {}
225            }
226        }
227
228        // apply enforced attributes case-insensitively, so a preserved-case source attr is canonicalized to the enforced key
229        for (Attribute enforcedAttr : enforcedAttrs) {
230            destAttrs.removeIgnoreCase(enforcedAttr.getKey());
231            destAttrs.put(enforcedAttr.getKey(), enforcedAttr.getValue());
232        }
233        dest.attributes().addAll(destAttrs); // re-attach, if removed in clear
234        return new ElementMeta(dest, numDiscarded);
235    }
236
237    private static class ElementMeta {
238        Element el;
239        int numAttribsDiscarded;
240
241        ElementMeta(Element el, int numAttribsDiscarded) {
242            this.el = el;
243            this.numAttribsDiscarded = numAttribsDiscarded;
244        }
245    }
246
247}