.classpath
@@ -77,6 +77,7 @@ <classpathentry kind="lib" path="ext/commons-pool2-2.0.jar" sourcepath="ext/src/commons-pool2-2.0.jar" /> <classpathentry kind="lib" path="ext/pf4j-0.9.0.jar" sourcepath="ext/src/pf4j-0.9.0.jar" /> <classpathentry kind="lib" path="ext/tika-core-1.5.jar" sourcepath="ext/src/tika-core-1.5.jar" /> <classpathentry kind="lib" path="ext/jsoup-1.7.3.jar" sourcepath="ext/src/jsoup-1.7.3.jar" /> <classpathentry kind="lib" path="ext/junit-4.11.jar" sourcepath="ext/src/junit-4.11.jar" /> <classpathentry kind="lib" path="ext/hamcrest-core-1.3.jar" sourcepath="ext/src/hamcrest-core-1.3.jar" /> <classpathentry kind="lib" path="ext/selenium-java-2.28.0.jar" sourcepath="ext/src/selenium-java-2.28.0.jar" /> build.moxie
@@ -178,6 +178,7 @@ - compile 'redis.clients:jedis:2.3.1' :war - compile 'ro.fortsoft.pf4j:pf4j:0.9.0' :war - compile 'org.apache.tika:tika-core:1.5' :war - compile 'org.jsoup:jsoup:1.7.3' :war - test 'junit' # Dependencies for Selenium web page testing - test 'org.seleniumhq.selenium:selenium-java:${selenium.version}' @jar gitblit.iml
@@ -801,6 +801,17 @@ </SOURCES> </library> </orderEntry> <orderEntry type="module-library"> <library name="jsoup-1.7.3.jar"> <CLASSES> <root url="jar://$MODULE_DIR$/ext/jsoup-1.7.3.jar!/" /> </CLASSES> <JAVADOC /> <SOURCES> <root url="jar://$MODULE_DIR$/ext/src/jsoup-1.7.3.jar!/" /> </SOURCES> </library> </orderEntry> <orderEntry type="module-library" scope="TEST"> <library name="junit-4.11.jar"> <CLASSES> src/main/java/com/gitblit/FederationClient.java
@@ -36,6 +36,8 @@ import com.gitblit.service.FederationPullService; import com.gitblit.utils.FederationUtils; import com.gitblit.utils.StringUtils; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; /** * Command-line client to pull federated Gitblit repositories. @@ -92,7 +94,8 @@ } // configure the Gitblit singleton for minimal, non-server operation RuntimeManager runtime = new RuntimeManager(settings, baseFolder).start(); XssFilter xssFilter = new AllowXssFilter(); RuntimeManager runtime = new RuntimeManager(settings, xssFilter, baseFolder).start(); NoopNotificationManager notifications = new NoopNotificationManager().start(); UserManager users = new UserManager(runtime, null).start(); RepositoryManager repositories = new RepositoryManager(runtime, null, users).start(); src/main/java/com/gitblit/MigrateTickets.java
@@ -39,6 +39,8 @@ import com.gitblit.tickets.ITicketService; import com.gitblit.tickets.RedisTicketService; import com.gitblit.utils.StringUtils; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; /** * A command-line tool to move all tickets from one ticket service to another. @@ -134,7 +136,8 @@ settings.overrideSetting(Keys.web.activityCacheDays, 0); settings.overrideSetting(ITicketService.SETTING_UPDATE_DIFFSTATS, false); IRuntimeManager runtimeManager = new RuntimeManager(settings, baseFolder).start(); XssFilter xssFilter = new AllowXssFilter(); IRuntimeManager runtimeManager = new RuntimeManager(settings, xssFilter, baseFolder).start(); IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, null, null).start(); String inputServiceName = settings.getString(Keys.tickets.service, BranchTicketService.class.getSimpleName()); src/main/java/com/gitblit/ReindexTickets.java
@@ -33,6 +33,8 @@ import com.gitblit.tickets.ITicketService; import com.gitblit.tickets.RedisTicketService; import com.gitblit.utils.StringUtils; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; /** * A command-line tool to reindex all tickets in all repositories when the @@ -126,7 +128,8 @@ settings.overrideSetting(Keys.git.enableMirroring, false); settings.overrideSetting(Keys.web.activityCacheDays, 0); IRuntimeManager runtimeManager = new RuntimeManager(settings, baseFolder).start(); XssFilter xssFilter = new AllowXssFilter(); IRuntimeManager runtimeManager = new RuntimeManager(settings, xssFilter, baseFolder).start(); IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, null, null).start(); String serviceName = settings.getString(Keys.tickets.service, BranchTicketService.class.getSimpleName()); src/main/java/com/gitblit/guice/CoreModule.java
@@ -39,7 +39,9 @@ import com.gitblit.manager.UserManager; import com.gitblit.tickets.ITicketService; import com.gitblit.transport.ssh.IPublicKeyManager; import com.gitblit.utils.JSoupXssFilter; import com.gitblit.utils.WorkQueue; import com.gitblit.utils.XssFilter; import com.google.inject.AbstractModule; /** @@ -54,6 +56,7 @@ protected void configure() { bind(IStoredSettings.class).toInstance(new FileSettings()); bind(XssFilter.class).to(JSoupXssFilter.class); // bind complex providers bind(IPublicKeyManager.class).toProvider(IPublicKeyManagerProvider.class); src/main/java/com/gitblit/manager/GitblitManager.java
@@ -76,6 +76,8 @@ import com.gitblit.transport.ssh.IPublicKeyManager; import com.gitblit.transport.ssh.SshKey; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.XssFilter; import com.gitblit.utils.HttpUtils; import com.gitblit.utils.JsonUtils; import com.gitblit.utils.ObjectCache; import com.gitblit.utils.StringUtils; @@ -591,6 +593,11 @@ return runtimeManager.getInjector(); } @Override public XssFilter getXssFilter() { return runtimeManager.getXssFilter(); } /* * NOTIFICATION MANAGER */ src/main/java/com/gitblit/manager/IRuntimeManager.java
@@ -24,6 +24,7 @@ import com.gitblit.IStoredSettings; import com.gitblit.models.ServerSettings; import com.gitblit.models.ServerStatus; import com.gitblit.utils.XssFilter; import com.google.inject.Injector; public interface IRuntimeManager extends IManager { @@ -118,4 +119,11 @@ * @since 1.4.0 */ boolean updateSettings(Map<String, String> updatedSettings); /** * Returns the HTML sanitizer used to clean user content. * * @return the HTML sanitizer */ XssFilter getXssFilter(); } src/main/java/com/gitblit/manager/RuntimeManager.java
@@ -32,6 +32,7 @@ import com.gitblit.models.ServerStatus; import com.gitblit.models.SettingModel; import com.gitblit.utils.StringUtils; import com.gitblit.utils.XssFilter; import com.google.inject.Inject; import com.google.inject.Injector; import com.google.inject.Singleton; @@ -42,6 +43,8 @@ private final Logger logger = LoggerFactory.getLogger(getClass()); private final IStoredSettings settings; private final XssFilter xssFilter; private final ServerStatus serverStatus; @@ -55,14 +58,15 @@ private Injector injector; @Inject public RuntimeManager(IStoredSettings settings) { this(settings, null); public RuntimeManager(IStoredSettings settings, XssFilter xssFilter) { this(settings, xssFilter, null); } public RuntimeManager(IStoredSettings settings, File baseFolder) { public RuntimeManager(IStoredSettings settings, XssFilter xssFilter, File baseFolder) { this.settings = settings; this.settingsModel = new ServerSettings(); this.serverStatus = new ServerStatus(); this.xssFilter = xssFilter; this.baseFolder = baseFolder == null ? new File("") : baseFolder; } @@ -229,4 +233,15 @@ serverStatus.heapFree = Runtime.getRuntime().freeMemory(); return serverStatus; } /** * Returns the XSS filter. * * @return the XSS filter */ @Override public XssFilter getXssFilter() { return xssFilter; } } src/main/java/com/gitblit/utils/JSoupXssFilter.java
New file @@ -0,0 +1,92 @@ /* * Copyright 2014 gitblit.com. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gitblit.utils; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.safety.Cleaner; import org.jsoup.safety.Whitelist; import com.google.inject.Inject; import com.google.inject.Singleton; /** * Implementation of an XSS filter based on JSoup. * * @author James Moger * */ @Singleton public class JSoupXssFilter implements XssFilter { private final Cleaner none; private final Cleaner relaxed; @Inject public JSoupXssFilter() { none = new Cleaner(Whitelist.none()); relaxed = new Cleaner(getRelaxedWhiteList()); } @Override public String none(String input) { return clean(input, none); } @Override public String relaxed(String input) { return clean(input, relaxed); } protected String clean(String input, Cleaner cleaner) { Document unsafe = Jsoup.parse(input); Document safe = cleaner.clean(unsafe); return safe.body().html(); } /** * Builds & returns a loose HTML whitelist similar to Github. * * https://github.com/github/markup/tree/master#html-sanitization * @return a loose HTML whitelist */ protected Whitelist getRelaxedWhiteList() { return new Whitelist() .addTags( "a", "b", "blockquote", "br", "caption", "cite", "code", "col", "colgroup", "dd", "del", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "ins", "kbd", "li", "ol", "p", "pre", "q", "samp", "small", "strike", "strong", "sub", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "tt", "u", "ul", "var") .addAttributes("a", "href", "title") .addAttributes("blockquote", "cite") .addAttributes("col", "span", "width") .addAttributes("colgroup", "span", "width") .addAttributes("img", "align", "alt", "height", "src", "title", "width") .addAttributes("ol", "start", "type") .addAttributes("q", "cite") .addAttributes("table", "summary", "width") .addAttributes("td", "abbr", "axis", "colspan", "rowspan", "width") .addAttributes("th", "abbr", "axis", "colspan", "rowspan", "scope", "width") .addAttributes("ul", "type") .addEnforcedAttribute("a", "rel", "nofollow") ; } } src/main/java/com/gitblit/utils/XssFilter.java
New file @@ -0,0 +1,64 @@ /* * Copyright 2014 gitblit.com. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gitblit.utils; /** * Defines the contract for an XSS filter implementation. * * @author James Moger * */ public interface XssFilter { /** * Returns a filtered version of the input value that contains no html * elements. * * @param input * @return a plain text value */ String none(String input); /** * Returns a filtered version of the input that contains structural html * elements. * * @param input * @return a filtered html value */ String relaxed(String input); /** * A NOOP XSS filter. * * @author James Moger * */ public class AllowXssFilter implements XssFilter { @Override public String none(String input) { return input; } @Override public String relaxed(String input) { return input; } } } src/main/java/com/gitblit/wicket/GitBlitWebApp.java
@@ -47,6 +47,7 @@ import com.gitblit.manager.IUserManager; import com.gitblit.tickets.ITicketService; import com.gitblit.transport.ssh.IPublicKeyManager; import com.gitblit.utils.XssFilter; import com.gitblit.wicket.pages.ActivityPage; import com.gitblit.wicket.pages.BlamePage; import com.gitblit.wicket.pages.BlobDiffPage; @@ -109,6 +110,8 @@ private final IStoredSettings settings; private final XssFilter xssFilter; private final IRuntimeManager runtimeManager; private final IPluginManager pluginManager; @@ -148,6 +151,7 @@ this.publicKeyManagerProvider = publicKeyManagerProvider; this.ticketServiceProvider = ticketServiceProvider; this.settings = runtimeManager.getSettings(); this.xssFilter = runtimeManager.getXssFilter(); this.runtimeManager = runtimeManager; this.pluginManager = pluginManager; this.notificationManager = notificationManager; @@ -265,7 +269,7 @@ if (!settings.getBoolean(Keys.web.mountParameters, true)) { parameters = new String[] {}; } mount(new GitblitParamUrlCodingStrategy(settings, location, clazz, parameters)); mount(new GitblitParamUrlCodingStrategy(settings, xssFilter, location, clazz, parameters)); // map the mount point to the cache control definition if (clazz.isAnnotationPresent(CacheControl.class)) { @@ -322,6 +326,14 @@ } /* (non-Javadoc) * @see com.gitblit.wicket.Webapp#xssFilter() */ @Override public XssFilter xssFilter() { return xssFilter; } /* (non-Javadoc) * @see com.gitblit.wicket.Webapp#isDebugMode() */ @Override src/main/java/com/gitblit/wicket/GitblitParamUrlCodingStrategy.java
@@ -1,189 +1,221 @@ /* * Copyright 2011 gitblit.com. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gitblit.wicket; import java.text.MessageFormat; import java.util.HashSet; import java.util.Map; import java.util.Set; import org.apache.wicket.IRequestTarget; import org.apache.wicket.Page; import org.apache.wicket.request.RequestParameters; import org.apache.wicket.request.target.coding.MixedParamUrlCodingStrategy; import org.apache.wicket.util.string.AppendingStringBuffer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.gitblit.IStoredSettings; import com.gitblit.Keys; /** * Simple subclass of mixed parameter url coding strategy that works around the * encoded forward-slash issue that is present in some servlet containers. * * https://issues.apache.org/jira/browse/WICKET-1303 * http://tomcat.apache.org/security-6.html * * @author James Moger * */ public class GitblitParamUrlCodingStrategy extends MixedParamUrlCodingStrategy { private final String[] parameterNames; private Logger logger = LoggerFactory.getLogger(GitblitParamUrlCodingStrategy.class); private IStoredSettings settings; /** * Construct. * * @param <C> * @param mountPath * mount path (not empty) * @param bookmarkablePageClass * class of mounted page (not null) * @param parameterNames * the parameter names (not null) */ public <C extends Page> GitblitParamUrlCodingStrategy( IStoredSettings settings, String mountPath, Class<C> bookmarkablePageClass, String[] parameterNames) { super(mountPath, bookmarkablePageClass, parameterNames); this.parameterNames = parameterNames; this.settings = settings; } /** * Url encodes a string that is mean for a URL path (e.g., between slashes) * * @param string * string to be encoded * @return encoded string */ @Override protected String urlEncodePathComponent(String string) { char altChar = settings.getChar(Keys.web.forwardSlashCharacter, '/'); if (altChar != '/') { string = string.replace('/', altChar); } return super.urlEncodePathComponent(string); } /** * Returns a decoded value of the given value (taken from a URL path * section) * * @param value * @return Decodes the value */ @Override protected String urlDecodePathComponent(String value) { char altChar = settings.getChar(Keys.web.forwardSlashCharacter, '/'); if (altChar != '/') { value = value.replace(altChar, '/'); } return super.urlDecodePathComponent(value); } /** * Gets the decoded request target. * * @param requestParameters * the request parameters * @return the decoded request target */ @Override public IRequestTarget decode(RequestParameters requestParameters) { final String parametersFragment = requestParameters.getPath().substring( getMountPath().length()); logger.debug(MessageFormat .format("REQ: {0} PARAMS {1}", getMountPath(), parametersFragment)); return super.decode(requestParameters); } /** * @see org.apache.wicket.request.target.coding.AbstractRequestTargetUrlCodingStrategy#appendParameters(org.apache.wicket.util.string.AppendingStringBuffer, * java.util.Map) */ @Override protected void appendParameters(AppendingStringBuffer url, Map<String, ?> parameters) { if (!url.endsWith("/")) { url.append("/"); } Set<String> parameterNamesToAdd = new HashSet<String>(parameters.keySet()); // Find index of last specified parameter boolean foundParameter = false; int lastSpecifiedParameter = parameterNames.length; while (lastSpecifiedParameter != 0 && !foundParameter) { foundParameter = parameters.containsKey(parameterNames[--lastSpecifiedParameter]); } if (foundParameter) { for (int i = 0; i <= lastSpecifiedParameter; i++) { String parameterName = parameterNames[i]; final Object param = parameters.get(parameterName); String value = param instanceof String[] ? ((String[])param)[0] : ((param == null) ? null : param.toString()); if (value == null) { value = ""; } if (!url.endsWith("/")) { url.append("/"); } url.append(urlEncodePathComponent(value)); parameterNamesToAdd.remove(parameterName); } } if (!parameterNamesToAdd.isEmpty()) { boolean first = true; for (String parameterName : parameterNamesToAdd) { final Object param = parameters.get(parameterName); if (param instanceof String[]) { String [] values = (String[]) param; for (String value : values) { url.append(first ? '?' : '&'); url.append(urlEncodeQueryComponent(parameterName)).append("=").append( urlEncodeQueryComponent(value)); first = false; } } else { url.append(first ? '?' : '&'); String value = String.valueOf(param); url.append(urlEncodeQueryComponent(parameterName)).append("=").append( urlEncodeQueryComponent(value)); } first = false; } } } /* * Copyright 2011 gitblit.com. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gitblit.wicket; import java.util.HashSet; import java.util.Map; import java.util.Set; import org.apache.wicket.IRequestTarget; import org.apache.wicket.Page; import org.apache.wicket.protocol.http.request.WebRequestCodingStrategy; import org.apache.wicket.request.RequestParameters; import org.apache.wicket.request.target.coding.MixedParamUrlCodingStrategy; import org.apache.wicket.util.string.AppendingStringBuffer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.gitblit.IStoredSettings; import com.gitblit.Keys; import com.gitblit.utils.XssFilter; /** * Simple subclass of mixed parameter url coding strategy that works around the * encoded forward-slash issue that is present in some servlet containers. * * https://issues.apache.org/jira/browse/WICKET-1303 * http://tomcat.apache.org/security-6.html * * @author James Moger * */ public class GitblitParamUrlCodingStrategy extends MixedParamUrlCodingStrategy { private final String[] parameterNames; private Logger logger = LoggerFactory.getLogger(GitblitParamUrlCodingStrategy.class); private IStoredSettings settings; private XssFilter xssFilter; /** * Construct. * * @param <C> * @param mountPath * mount path (not empty) * @param bookmarkablePageClass * class of mounted page (not null) * @param parameterNames * the parameter names (not null) */ public <C extends Page> GitblitParamUrlCodingStrategy( IStoredSettings settings, XssFilter xssFilter, String mountPath, Class<C> bookmarkablePageClass, String[] parameterNames) { super(mountPath, bookmarkablePageClass, parameterNames); this.parameterNames = parameterNames; this.settings = settings; this.xssFilter = xssFilter; } /** * Url encodes a string that is mean for a URL path (e.g., between slashes) * * @param string * string to be encoded * @return encoded string */ @Override protected String urlEncodePathComponent(String string) { char altChar = settings.getChar(Keys.web.forwardSlashCharacter, '/'); if (altChar != '/') { string = string.replace('/', altChar); } return super.urlEncodePathComponent(string); } /** * Returns a decoded value of the given value (taken from a URL path * section) * * @param value * @return Decodes the value */ @Override protected String urlDecodePathComponent(String value) { char altChar = settings.getChar(Keys.web.forwardSlashCharacter, '/'); if (altChar != '/') { value = value.replace(altChar, '/'); } return super.urlDecodePathComponent(value); } /** * Gets the decoded request target. * * @param requestParameters * the request parameters * @return the decoded request target */ @Override public IRequestTarget decode(RequestParameters requestParameters) { Map<String, Object> parameterMap = (Map<String, Object>) requestParameters.getParameters(); for (Map.Entry<String, Object> entry : parameterMap.entrySet()) { String parameter = entry.getKey(); if (parameter.startsWith(WebRequestCodingStrategy.NAME_SPACE)) { // ignore Wicket parameters continue; } // sanitize Gitblit request parameters Object o = entry.getValue(); if (o instanceof String) { String value = o.toString(); String safeValue = xssFilter.none(value); if (!value.equals(safeValue)) { logger.warn("XSS filter triggered on {} URL parameter: {}={}", getMountPath(), parameter, value); parameterMap.put(parameter, safeValue); } } else if (o instanceof String[]) { String[] values = (String[]) o; for (int i = 0; i < values.length; i++) { String value = values[i].toString(); String safeValue = xssFilter.none(value); if (!value.equals(safeValue)) { logger.warn("XSS filter triggered on {} URL parameter: {}={}", getMountPath(), parameter, value); values[i] = safeValue; } } } } return super.decode(requestParameters); } /** * @see org.apache.wicket.request.target.coding.AbstractRequestTargetUrlCodingStrategy#appendParameters(org.apache.wicket.util.string.AppendingStringBuffer, * java.util.Map) */ @Override protected void appendParameters(AppendingStringBuffer url, Map<String, ?> parameters) { if (!url.endsWith("/")) { url.append("/"); } Set<String> parameterNamesToAdd = new HashSet<String>(parameters.keySet()); // Find index of last specified parameter boolean foundParameter = false; int lastSpecifiedParameter = parameterNames.length; while (lastSpecifiedParameter != 0 && !foundParameter) { foundParameter = parameters.containsKey(parameterNames[--lastSpecifiedParameter]); } if (foundParameter) { for (int i = 0; i <= lastSpecifiedParameter; i++) { String parameterName = parameterNames[i]; final Object param = parameters.get(parameterName); String value = param instanceof String[] ? ((String[])param)[0] : ((param == null) ? null : param.toString()); if (value == null) { value = ""; } if (!url.endsWith("/")) { url.append("/"); } url.append(urlEncodePathComponent(value)); parameterNamesToAdd.remove(parameterName); } } if (!parameterNamesToAdd.isEmpty()) { boolean first = true; for (String parameterName : parameterNamesToAdd) { final Object param = parameters.get(parameterName); if (param instanceof String[]) { String [] values = (String[]) param; for (String value : values) { url.append(first ? '?' : '&'); url.append(urlEncodeQueryComponent(parameterName)).append("=").append( urlEncodeQueryComponent(value)); first = false; } } else { url.append(first ? '?' : '&'); String value = String.valueOf(param); url.append(urlEncodeQueryComponent(parameterName)).append("=").append( urlEncodeQueryComponent(value)); } first = false; } } } } src/main/java/com/gitblit/wicket/GitblitWicketApp.java
@@ -18,6 +18,7 @@ import com.gitblit.manager.IUserManager; import com.gitblit.tickets.ITicketService; import com.gitblit.transport.ssh.IPublicKeyManager; import com.gitblit.utils.XssFilter; public interface GitblitWicketApp { @@ -31,6 +32,8 @@ public abstract IStoredSettings settings(); public abstract XssFilter xssFilter(); /** * Is Gitblit running in debug mode? * src/main/java/com/gitblit/wicket/MarkupProcessor.java
@@ -1,457 +1,473 @@ /* * Copyright 2013 gitblit.com. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gitblit.wicket; import static org.pegdown.FastEncoder.encode; import java.io.Serializable; import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import org.apache.wicket.Page; import org.apache.wicket.RequestCycle; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.mylyn.wikitext.confluence.core.ConfluenceLanguage; import org.eclipse.mylyn.wikitext.core.parser.Attributes; import org.eclipse.mylyn.wikitext.core.parser.MarkupParser; import org.eclipse.mylyn.wikitext.core.parser.builder.HtmlDocumentBuilder; import org.eclipse.mylyn.wikitext.core.parser.markup.MarkupLanguage; import org.eclipse.mylyn.wikitext.mediawiki.core.MediaWikiLanguage; import org.eclipse.mylyn.wikitext.textile.core.TextileLanguage; import org.eclipse.mylyn.wikitext.tracwiki.core.TracWikiLanguage; import org.eclipse.mylyn.wikitext.twiki.core.TWikiLanguage; import org.pegdown.DefaultVerbatimSerializer; import org.pegdown.LinkRenderer; import org.pegdown.ToHtmlSerializer; import org.pegdown.VerbatimSerializer; import org.pegdown.ast.ExpImageNode; import org.pegdown.ast.RefImageNode; import org.pegdown.ast.WikiLinkNode; import org.pegdown.plugins.ToHtmlSerializerPlugin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.gitblit.IStoredSettings; import com.gitblit.Keys; import com.gitblit.models.PathModel; import com.gitblit.servlet.RawServlet; import com.gitblit.utils.JGitUtils; import com.gitblit.utils.MarkdownUtils; import com.gitblit.utils.StringUtils; import com.gitblit.wicket.pages.DocPage; import com.google.common.base.Joiner; /** * Processes markup content and generates html with repository-relative page and * image linking. * * @author James Moger * */ public class MarkupProcessor { public enum MarkupSyntax { PLAIN, MARKDOWN, TWIKI, TRACWIKI, TEXTILE, MEDIAWIKI, CONFLUENCE } private Logger logger = LoggerFactory.getLogger(getClass()); private final IStoredSettings settings; public MarkupProcessor(IStoredSettings settings) { this.settings = settings; } public List<String> getMarkupExtensions() { List<String> list = new ArrayList<String>(); list.addAll(settings.getStrings(Keys.web.confluenceExtensions)); list.addAll(settings.getStrings(Keys.web.markdownExtensions)); list.addAll(settings.getStrings(Keys.web.mediawikiExtensions)); list.addAll(settings.getStrings(Keys.web.textileExtensions)); list.addAll(settings.getStrings(Keys.web.tracwikiExtensions)); list.addAll(settings.getStrings(Keys.web.twikiExtensions)); return list; } public List<String> getAllExtensions() { List<String> list = getMarkupExtensions(); list.add("txt"); list.add("TXT"); return list; } private List<String> getRoots() { return settings.getStrings(Keys.web.documents); } private String [] getEncodings() { return settings.getStrings(Keys.web.blobEncodings).toArray(new String[0]); } private MarkupSyntax determineSyntax(String documentPath) { String ext = StringUtils.getFileExtension(documentPath).toLowerCase(); if (StringUtils.isEmpty(ext)) { return MarkupSyntax.PLAIN; } if (settings.getStrings(Keys.web.confluenceExtensions).contains(ext)) { return MarkupSyntax.CONFLUENCE; } else if (settings.getStrings(Keys.web.markdownExtensions).contains(ext)) { return MarkupSyntax.MARKDOWN; } else if (settings.getStrings(Keys.web.mediawikiExtensions).contains(ext)) { return MarkupSyntax.MEDIAWIKI; } else if (settings.getStrings(Keys.web.textileExtensions).contains(ext)) { return MarkupSyntax.TEXTILE; } else if (settings.getStrings(Keys.web.tracwikiExtensions).contains(ext)) { return MarkupSyntax.TRACWIKI; } else if (settings.getStrings(Keys.web.twikiExtensions).contains(ext)) { return MarkupSyntax.TWIKI; } return MarkupSyntax.PLAIN; } public boolean hasRootDocs(Repository r) { List<String> roots = getRoots(); List<String> extensions = getAllExtensions(); List<PathModel> paths = JGitUtils.getFilesInPath(r, null, null); for (PathModel path : paths) { if (!path.isTree()) { String ext = StringUtils.getFileExtension(path.name).toLowerCase(); String name = StringUtils.stripFileExtension(path.name).toLowerCase(); if (roots.contains(name)) { if (StringUtils.isEmpty(ext) || extensions.contains(ext)) { return true; } } } } return false; } public List<MarkupDocument> getRootDocs(Repository r, String repositoryName, String commitId) { List<String> roots = getRoots(); List<MarkupDocument> list = getDocs(r, repositoryName, commitId, roots); return list; } public MarkupDocument getReadme(Repository r, String repositoryName, String commitId) { List<MarkupDocument> list = getDocs(r, repositoryName, commitId, Arrays.asList("readme")); if (list.isEmpty()) { return null; } return list.get(0); } private List<MarkupDocument> getDocs(Repository r, String repositoryName, String commitId, List<String> names) { List<String> extensions = getAllExtensions(); String [] encodings = getEncodings(); Map<String, MarkupDocument> map = new HashMap<String, MarkupDocument>(); RevCommit commit = JGitUtils.getCommit(r, commitId); List<PathModel> paths = JGitUtils.getFilesInPath(r, null, commit); for (PathModel path : paths) { if (!path.isTree()) { String ext = StringUtils.getFileExtension(path.name).toLowerCase(); String name = StringUtils.stripFileExtension(path.name).toLowerCase(); if (names.contains(name)) { if (StringUtils.isEmpty(ext) || extensions.contains(ext)) { String markup = JGitUtils.getStringContent(r, commit.getTree(), path.name, encodings); MarkupDocument doc = parse(repositoryName, commitId, path.name, markup); map.put(name, doc); } } } } // return document list in requested order List<MarkupDocument> list = new ArrayList<MarkupDocument>(); for (String name : names) { if (map.containsKey(name)) { list.add(map.get(name)); } } return list; } public MarkupDocument parse(String repositoryName, String commitId, String documentPath, String markupText) { final MarkupSyntax syntax = determineSyntax(documentPath); final MarkupDocument doc = new MarkupDocument(documentPath, markupText, syntax); if (markupText != null) { try { switch (syntax){ case CONFLUENCE: parse(doc, repositoryName, commitId, new ConfluenceLanguage()); break; case MARKDOWN: parse(doc, repositoryName, commitId); break; case MEDIAWIKI: parse(doc, repositoryName, commitId, new MediaWikiLanguage()); break; case TEXTILE: parse(doc, repositoryName, commitId, new TextileLanguage()); break; case TRACWIKI: parse(doc, repositoryName, commitId, new TracWikiLanguage()); break; case TWIKI: parse(doc, repositoryName, commitId, new TWikiLanguage()); break; default: doc.html = MarkdownUtils.transformPlainText(markupText); break; } } catch (Exception e) { logger.error("failed to transform " + syntax, e); } } if (doc.html == null) { // failed to transform markup if (markupText == null) { markupText = String.format("Document <b>%1$s</b> not found in <em>%2$s</em>", documentPath, repositoryName); } markupText = MessageFormat.format("<div class=\"alert alert-error\"><strong>{0}:</strong> {1}</div>{2}", "Error", "failed to parse markup", markupText); doc.html = StringUtils.breakLinesForHtml(markupText); } return doc; } /** * Parses the markup using the specified markup language * * @param doc * @param repositoryName * @param commitId * @param lang */ private void parse(final MarkupDocument doc, final String repositoryName, final String commitId, MarkupLanguage lang) { StringWriter writer = new StringWriter(); HtmlDocumentBuilder builder = new HtmlDocumentBuilder(writer) { @Override public void image(Attributes attributes, String imagePath) { String url; if (imagePath.indexOf("://") == -1) { // relative image String path = doc.getRelativePath(imagePath); String contextUrl = RequestCycle.get().getRequest().getRelativePathPrefixToContextRoot(); url = RawServlet.asLink(contextUrl, repositoryName, commitId, path); } else { // absolute image url = imagePath; } super.image(attributes, url); } @Override public void link(Attributes attributes, String hrefOrHashName, String text) { String url; if (hrefOrHashName.charAt(0) != '#') { if (hrefOrHashName.indexOf("://") == -1) { // relative link String path = doc.getRelativePath(hrefOrHashName); url = getWicketUrl(DocPage.class, repositoryName, commitId, path); } else { // absolute link url = hrefOrHashName; } } else { // page-relative hash link url = hrefOrHashName; } super.link(attributes, url, text); } }; // avoid the <html> and <body> tags builder.setEmitAsDocument(false); MarkupParser parser = new MarkupParser(lang); parser.setBuilder(builder); parser.parse(doc.markup); doc.html = writer.toString(); } /** * Parses the document as Markdown using Pegdown. * * @param doc * @param repositoryName * @param commitId */ private void parse(final MarkupDocument doc, final String repositoryName, final String commitId) { LinkRenderer renderer = new LinkRenderer() { @Override public Rendering render(ExpImageNode node, String text) { if (node.url.indexOf("://") == -1) { // repository-relative image link String path = doc.getRelativePath(node.url); String contextUrl = RequestCycle.get().getRequest().getRelativePathPrefixToContextRoot(); String url = RawServlet.asLink(contextUrl, repositoryName, commitId, path); return new Rendering(url, text); } // absolute image link return new Rendering(node.url, text); } @Override public Rendering render(RefImageNode node, String url, String title, String alt) { Rendering rendering; if (url.indexOf("://") == -1) { // repository-relative image link String path = doc.getRelativePath(url); String contextUrl = RequestCycle.get().getRequest().getRelativePathPrefixToContextRoot(); String wurl = RawServlet.asLink(contextUrl, repositoryName, commitId, path); rendering = new Rendering(wurl, alt); } else { // absolute image link rendering = new Rendering(url, alt); } return StringUtils.isEmpty(title) ? rendering : rendering.withAttribute("title", encode(title)); } @Override public Rendering render(WikiLinkNode node) { String path = doc.getRelativePath(node.getText()); String name = getDocumentName(path); String url = getWicketUrl(DocPage.class, repositoryName, commitId, path); return new Rendering(url, name); } }; doc.html = MarkdownUtils.transformMarkdown(doc.markup, renderer); } private String getWicketUrl(Class<? extends Page> pageClass, final String repositoryName, final String commitId, final String document) { String fsc = settings.getString(Keys.web.forwardSlashCharacter, "/"); String encodedPath = document.replace(' ', '-'); try { encodedPath = URLEncoder.encode(encodedPath, "UTF-8"); } catch (UnsupportedEncodingException e) { logger.error(null, e); } encodedPath = encodedPath.replace("/", fsc).replace("%2F", fsc); String url = RequestCycle.get().urlFor(pageClass, WicketUtils.newPathParameter(repositoryName, commitId, encodedPath)).toString(); return url; } private String getDocumentName(final String document) { // extract document name String name = StringUtils.stripFileExtension(document); name = name.replace('_', ' '); if (name.indexOf('/') > -1) { name = name.substring(name.lastIndexOf('/') + 1); } return name; } public static class MarkupDocument implements Serializable { private static final long serialVersionUID = 1L; public final String documentPath; public final String markup; public final MarkupSyntax syntax; public String html; MarkupDocument(String documentPath, String markup, MarkupSyntax syntax) { this.documentPath = documentPath; this.markup = markup; this.syntax = syntax; } String getCurrentPath() { String basePath = ""; if (documentPath.indexOf('/') > -1) { basePath = documentPath.substring(0, documentPath.lastIndexOf('/') + 1); if (basePath.charAt(0) == '/') { return basePath.substring(1); } } return basePath; } String getRelativePath(String ref) { if (ref.charAt(0) == '/') { // absolute path in repository return ref.substring(1); } else { // resolve relative repository path String cp = getCurrentPath(); if (StringUtils.isEmpty(cp)) { return ref; } // this is a simple relative path resolver List<String> currPathStrings = new ArrayList<String>(Arrays.asList(cp.split("/"))); String file = ref; while (file.startsWith("../")) { // strip ../ from the file reference // drop the last path element file = file.substring(3); currPathStrings.remove(currPathStrings.size() - 1); } currPathStrings.add(file); String path = Joiner.on("/").join(currPathStrings); return path; } } } /** * This class implements a workaround for a bug reported in issue-379. * The bug was introduced by my own pegdown pull request #115. * * @author James Moger * */ public static class WorkaroundHtmlSerializer extends ToHtmlSerializer { public WorkaroundHtmlSerializer(final LinkRenderer linkRenderer) { super(linkRenderer, Collections.<String, VerbatimSerializer>singletonMap(VerbatimSerializer.DEFAULT, DefaultVerbatimSerializer.INSTANCE), Collections.<ToHtmlSerializerPlugin>emptyList()); } private void printAttribute(String name, String value) { printer.print(' ').print(name).print('=').print('"').print(value).print('"'); } /* Reimplement print image tag to eliminate a trailing double-quote */ @Override protected void printImageTag(LinkRenderer.Rendering rendering) { printer.print("<img"); printAttribute("src", rendering.href); printAttribute("alt", rendering.text); for (LinkRenderer.Attribute attr : rendering.attributes) { printAttribute(attr.name, attr.value); } printer.print("/>"); } } } /* * Copyright 2013 gitblit.com. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gitblit.wicket; import static org.pegdown.FastEncoder.encode; import java.io.Serializable; import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import org.apache.wicket.Page; import org.apache.wicket.RequestCycle; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.mylyn.wikitext.confluence.core.ConfluenceLanguage; import org.eclipse.mylyn.wikitext.core.parser.Attributes; import org.eclipse.mylyn.wikitext.core.parser.MarkupParser; import org.eclipse.mylyn.wikitext.core.parser.builder.HtmlDocumentBuilder; import org.eclipse.mylyn.wikitext.core.parser.markup.MarkupLanguage; import org.eclipse.mylyn.wikitext.mediawiki.core.MediaWikiLanguage; import org.eclipse.mylyn.wikitext.textile.core.TextileLanguage; import org.eclipse.mylyn.wikitext.tracwiki.core.TracWikiLanguage; import org.eclipse.mylyn.wikitext.twiki.core.TWikiLanguage; import org.pegdown.DefaultVerbatimSerializer; import org.pegdown.LinkRenderer; import org.pegdown.ToHtmlSerializer; import org.pegdown.VerbatimSerializer; import org.pegdown.ast.ExpImageNode; import org.pegdown.ast.RefImageNode; import org.pegdown.ast.WikiLinkNode; import org.pegdown.plugins.ToHtmlSerializerPlugin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.gitblit.IStoredSettings; import com.gitblit.Keys; import com.gitblit.models.PathModel; import com.gitblit.servlet.RawServlet; import com.gitblit.utils.JGitUtils; import com.gitblit.utils.MarkdownUtils; import com.gitblit.utils.StringUtils; import com.gitblit.utils.XssFilter; import com.gitblit.wicket.pages.DocPage; import com.google.common.base.Joiner; /** * Processes markup content and generates html with repository-relative page and * image linking. * * @author James Moger * */ public class MarkupProcessor { public enum MarkupSyntax { PLAIN, MARKDOWN, TWIKI, TRACWIKI, TEXTILE, MEDIAWIKI, CONFLUENCE } private Logger logger = LoggerFactory.getLogger(getClass()); private final IStoredSettings settings; private final XssFilter xssFilter; public static List<String> getMarkupExtensions(IStoredSettings settings) { List<String> list = new ArrayList<String>(); list.addAll(settings.getStrings(Keys.web.confluenceExtensions)); list.addAll(settings.getStrings(Keys.web.markdownExtensions)); list.addAll(settings.getStrings(Keys.web.mediawikiExtensions)); list.addAll(settings.getStrings(Keys.web.textileExtensions)); list.addAll(settings.getStrings(Keys.web.tracwikiExtensions)); list.addAll(settings.getStrings(Keys.web.twikiExtensions)); return list; } public MarkupProcessor(IStoredSettings settings, XssFilter xssFilter) { this.settings = settings; this.xssFilter = xssFilter; } public List<String> getMarkupExtensions() { return getMarkupExtensions(settings); } public List<String> getAllExtensions() { List<String> list = getMarkupExtensions(settings); list.add("txt"); list.add("TXT"); return list; } private List<String> getRoots() { return settings.getStrings(Keys.web.documents); } private String [] getEncodings() { return settings.getStrings(Keys.web.blobEncodings).toArray(new String[0]); } private MarkupSyntax determineSyntax(String documentPath) { String ext = StringUtils.getFileExtension(documentPath).toLowerCase(); if (StringUtils.isEmpty(ext)) { return MarkupSyntax.PLAIN; } if (settings.getStrings(Keys.web.confluenceExtensions).contains(ext)) { return MarkupSyntax.CONFLUENCE; } else if (settings.getStrings(Keys.web.markdownExtensions).contains(ext)) { return MarkupSyntax.MARKDOWN; } else if (settings.getStrings(Keys.web.mediawikiExtensions).contains(ext)) { return MarkupSyntax.MEDIAWIKI; } else if (settings.getStrings(Keys.web.textileExtensions).contains(ext)) { return MarkupSyntax.TEXTILE; } else if (settings.getStrings(Keys.web.tracwikiExtensions).contains(ext)) { return MarkupSyntax.TRACWIKI; } else if (settings.getStrings(Keys.web.twikiExtensions).contains(ext)) { return MarkupSyntax.TWIKI; } return MarkupSyntax.PLAIN; } public boolean hasRootDocs(Repository r) { List<String> roots = getRoots(); List<String> extensions = getAllExtensions(); List<PathModel> paths = JGitUtils.getFilesInPath(r, null, null); for (PathModel path : paths) { if (!path.isTree()) { String ext = StringUtils.getFileExtension(path.name).toLowerCase(); String name = StringUtils.stripFileExtension(path.name).toLowerCase(); if (roots.contains(name)) { if (StringUtils.isEmpty(ext) || extensions.contains(ext)) { return true; } } } } return false; } public List<MarkupDocument> getRootDocs(Repository r, String repositoryName, String commitId) { List<String> roots = getRoots(); List<MarkupDocument> list = getDocs(r, repositoryName, commitId, roots); return list; } public MarkupDocument getReadme(Repository r, String repositoryName, String commitId) { List<MarkupDocument> list = getDocs(r, repositoryName, commitId, Arrays.asList("readme")); if (list.isEmpty()) { return null; } return list.get(0); } private List<MarkupDocument> getDocs(Repository r, String repositoryName, String commitId, List<String> names) { List<String> extensions = getAllExtensions(); String [] encodings = getEncodings(); Map<String, MarkupDocument> map = new HashMap<String, MarkupDocument>(); RevCommit commit = JGitUtils.getCommit(r, commitId); List<PathModel> paths = JGitUtils.getFilesInPath(r, null, commit); for (PathModel path : paths) { if (!path.isTree()) { String ext = StringUtils.getFileExtension(path.name).toLowerCase(); String name = StringUtils.stripFileExtension(path.name).toLowerCase(); if (names.contains(name)) { if (StringUtils.isEmpty(ext) || extensions.contains(ext)) { String markup = JGitUtils.getStringContent(r, commit.getTree(), path.name, encodings); MarkupDocument doc = parse(repositoryName, commitId, path.name, markup); map.put(name, doc); } } } } // return document list in requested order List<MarkupDocument> list = new ArrayList<MarkupDocument>(); for (String name : names) { if (map.containsKey(name)) { list.add(map.get(name)); } } return list; } public MarkupDocument parse(String repositoryName, String commitId, String documentPath, String markupText) { final MarkupSyntax syntax = determineSyntax(documentPath); final MarkupDocument doc = new MarkupDocument(documentPath, markupText, syntax); if (markupText != null) { try { switch (syntax){ case CONFLUENCE: parse(doc, repositoryName, commitId, new ConfluenceLanguage()); break; case MARKDOWN: parse(doc, repositoryName, commitId); break; case MEDIAWIKI: parse(doc, repositoryName, commitId, new MediaWikiLanguage()); break; case TEXTILE: parse(doc, repositoryName, commitId, new TextileLanguage()); break; case TRACWIKI: parse(doc, repositoryName, commitId, new TracWikiLanguage()); break; case TWIKI: parse(doc, repositoryName, commitId, new TWikiLanguage()); break; default: doc.html = MarkdownUtils.transformPlainText(markupText); break; } } catch (Exception e) { logger.error("failed to transform " + syntax, e); } } if (doc.html == null) { // failed to transform markup if (markupText == null) { markupText = String.format("Document <b>%1$s</b> not found in <em>%2$s</em>", documentPath, repositoryName); } markupText = MessageFormat.format("<div class=\"alert alert-error\"><strong>{0}:</strong> {1}</div>{2}", "Error", "failed to parse markup", markupText); doc.html = StringUtils.breakLinesForHtml(markupText); } return doc; } /** * Parses the markup using the specified markup language * * @param doc * @param repositoryName * @param commitId * @param lang */ private void parse(final MarkupDocument doc, final String repositoryName, final String commitId, MarkupLanguage lang) { StringWriter writer = new StringWriter(); HtmlDocumentBuilder builder = new HtmlDocumentBuilder(writer) { @Override public void image(Attributes attributes, String imagePath) { String url; if (imagePath.indexOf("://") == -1) { // relative image String path = doc.getRelativePath(imagePath); String contextUrl = RequestCycle.get().getRequest().getRelativePathPrefixToContextRoot(); url = RawServlet.asLink(contextUrl, repositoryName, commitId, path); } else { // absolute image url = imagePath; } super.image(attributes, url); } @Override public void link(Attributes attributes, String hrefOrHashName, String text) { String url; if (hrefOrHashName.charAt(0) != '#') { if (hrefOrHashName.indexOf("://") == -1) { // relative link String path = doc.getRelativePath(hrefOrHashName); url = getWicketUrl(DocPage.class, repositoryName, commitId, path); } else { // absolute link url = hrefOrHashName; } } else { // page-relative hash link url = hrefOrHashName; } super.link(attributes, url, text); } }; // avoid the <html> and <body> tags builder.setEmitAsDocument(false); MarkupParser parser = new MarkupParser(lang); parser.setBuilder(builder); parser.parse(doc.markup); final String content = writer.toString(); final String safeContent = xssFilter.relaxed(content); doc.html = safeContent; } /** * Parses the document as Markdown using Pegdown. * * @param doc * @param repositoryName * @param commitId */ private void parse(final MarkupDocument doc, final String repositoryName, final String commitId) { LinkRenderer renderer = new LinkRenderer() { @Override public Rendering render(ExpImageNode node, String text) { if (node.url.indexOf("://") == -1) { // repository-relative image link String path = doc.getRelativePath(node.url); String contextUrl = RequestCycle.get().getRequest().getRelativePathPrefixToContextRoot(); String url = RawServlet.asLink(contextUrl, repositoryName, commitId, path); return new Rendering(url, text); } // absolute image link return new Rendering(node.url, text); } @Override public Rendering render(RefImageNode node, String url, String title, String alt) { Rendering rendering; if (url.indexOf("://") == -1) { // repository-relative image link String path = doc.getRelativePath(url); String contextUrl = RequestCycle.get().getRequest().getRelativePathPrefixToContextRoot(); String wurl = RawServlet.asLink(contextUrl, repositoryName, commitId, path); rendering = new Rendering(wurl, alt); } else { // absolute image link rendering = new Rendering(url, alt); } return StringUtils.isEmpty(title) ? rendering : rendering.withAttribute("title", encode(title)); } @Override public Rendering render(WikiLinkNode node) { String path = doc.getRelativePath(node.getText()); String name = getDocumentName(path); String url = getWicketUrl(DocPage.class, repositoryName, commitId, path); return new Rendering(url, name); } }; final String content = MarkdownUtils.transformMarkdown(doc.markup, renderer); final String safeContent = xssFilter.relaxed(content); doc.html = safeContent; } private String getWicketUrl(Class<? extends Page> pageClass, final String repositoryName, final String commitId, final String document) { String fsc = settings.getString(Keys.web.forwardSlashCharacter, "/"); String encodedPath = document.replace(' ', '-'); try { encodedPath = URLEncoder.encode(encodedPath, "UTF-8"); } catch (UnsupportedEncodingException e) { logger.error(null, e); } encodedPath = encodedPath.replace("/", fsc).replace("%2F", fsc); String url = RequestCycle.get().urlFor(pageClass, WicketUtils.newPathParameter(repositoryName, commitId, encodedPath)).toString(); return url; } private String getDocumentName(final String document) { // extract document name String name = StringUtils.stripFileExtension(document); name = name.replace('_', ' '); if (name.indexOf('/') > -1) { name = name.substring(name.lastIndexOf('/') + 1); } return name; } public static class MarkupDocument implements Serializable { private static final long serialVersionUID = 1L; public final String documentPath; public final String markup; public final MarkupSyntax syntax; public String html; MarkupDocument(String documentPath, String markup, MarkupSyntax syntax) { this.documentPath = documentPath; this.markup = markup; this.syntax = syntax; } String getCurrentPath() { String basePath = ""; if (documentPath.indexOf('/') > -1) { basePath = documentPath.substring(0, documentPath.lastIndexOf('/') + 1); if (basePath.charAt(0) == '/') { return basePath.substring(1); } } return basePath; } String getRelativePath(String ref) { if (ref.charAt(0) == '/') { // absolute path in repository return ref.substring(1); } else { // resolve relative repository path String cp = getCurrentPath(); if (StringUtils.isEmpty(cp)) { return ref; } // this is a simple relative path resolver List<String> currPathStrings = new ArrayList<String>(Arrays.asList(cp.split("/"))); String file = ref; while (file.startsWith("../")) { // strip ../ from the file reference // drop the last path element file = file.substring(3); currPathStrings.remove(currPathStrings.size() - 1); } currPathStrings.add(file); String path = Joiner.on("/").join(currPathStrings); return path; } } } /** * This class implements a workaround for a bug reported in issue-379. * The bug was introduced by my own pegdown pull request #115. * * @author James Moger * */ public static class WorkaroundHtmlSerializer extends ToHtmlSerializer { public WorkaroundHtmlSerializer(final LinkRenderer linkRenderer) { super(linkRenderer, Collections.<String, VerbatimSerializer>singletonMap(VerbatimSerializer.DEFAULT, DefaultVerbatimSerializer.INSTANCE), Collections.<ToHtmlSerializerPlugin>emptyList()); } private void printAttribute(String name, String value) { printer.print(' ').print(name).print('=').print('"').print(value).print('"'); } /* Reimplement print image tag to eliminate a trailing double-quote */ @Override protected void printImageTag(LinkRenderer.Rendering rendering) { printer.print("<img"); printAttribute("src", rendering.href); printAttribute("alt", rendering.text); for (LinkRenderer.Attribute attr : rendering.attributes) { printAttribute(attr.name, attr.value); } printer.print("/>"); } } } src/main/java/com/gitblit/wicket/SafeTextModel.java
New file @@ -0,0 +1,96 @@ package com.gitblit.wicket; import org.apache.wicket.model.IModel; import org.apache.wicket.model.Model; import org.apache.wicket.util.lang.Objects; import org.parboiled.common.StringUtils; import org.slf4j.LoggerFactory; public class SafeTextModel implements IModel<String> { private static final long serialVersionUID = 1L; public enum Mode { relaxed, none } private final Mode mode; private String value; public static SafeTextModel none() { return new SafeTextModel(Mode.none); } public static SafeTextModel none(String value) { return new SafeTextModel(Mode.none); } public static SafeTextModel relaxed() { return new SafeTextModel(Mode.relaxed); } public static SafeTextModel relaxed(String value) { return new SafeTextModel(Mode.relaxed); } public SafeTextModel(Mode mode) { this.mode = mode; } public SafeTextModel(String value, Mode mode) { this.value = value; this.mode = mode; } @Override public void detach() { } @Override public String getObject() { if (StringUtils.isEmpty(value)) { return value; } String safeValue; switch (mode) { case none: safeValue = GitBlitWebApp.get().xssFilter().none(value); break; default: safeValue = GitBlitWebApp.get().xssFilter().relaxed(value); break; } if (!value.equals(safeValue)) { LoggerFactory.getLogger(getClass()).warn("XSS filter trigggered on suspicious form field value {}", value); } return safeValue; } @Override public void setObject(String input) { this.value = input; } @Override public int hashCode() { return Objects.hashCode(value); } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (!(obj instanceof Model<?>)) { return false; } Model<?> that = (Model<?>)obj; return Objects.equal(value, that.getObject()); } } src/main/java/com/gitblit/wicket/WicketUtils.java
@@ -42,6 +42,7 @@ import com.gitblit.Constants; import com.gitblit.Constants.AccessPermission; import com.gitblit.Constants.FederationPullStatus; import com.gitblit.IStoredSettings; import com.gitblit.Keys; import com.gitblit.models.FederationModel; import com.gitblit.models.Metric; @@ -186,9 +187,9 @@ return newImage(wicketId, "file_settings_16x16.png"); } MarkupProcessor processor = new MarkupProcessor(GitBlitWebApp.get().settings()); String ext = StringUtils.getFileExtension(filename).toLowerCase(); if (processor.getMarkupExtensions().contains(ext)) { IStoredSettings settings = GitBlitWebApp.get().settings(); if (MarkupProcessor.getMarkupExtensions(settings).contains(ext)) { return newImage(wicketId, "file_world_16x16.png"); } return newImage(wicketId, "file_16x16.png"); src/main/java/com/gitblit/wicket/pages/BlobPage.java
@@ -79,7 +79,7 @@ } // see if we should redirect to the doc page MarkupProcessor processor = new MarkupProcessor(app().settings()); MarkupProcessor processor = new MarkupProcessor(app().settings(), app().xssFilter()); for (String ext : processor.getMarkupExtensions()) { if (ext.equals(extension)) { setResponsePage(DocPage.class, params); src/main/java/com/gitblit/wicket/pages/DocPage.java
@@ -43,7 +43,7 @@ super(params); final String path = WicketUtils.getPath(params).replace("%2f", "/").replace("%2F", "/"); MarkupProcessor processor = new MarkupProcessor(app().settings()); MarkupProcessor processor = new MarkupProcessor(app().settings(), app().xssFilter()); Repository r = getRepository(); RevCommit commit = JGitUtils.getCommit(r, objectId); src/main/java/com/gitblit/wicket/pages/DocsPage.java
@@ -49,7 +49,7 @@ public DocsPage(PageParameters params) { super(params); MarkupProcessor processor = new MarkupProcessor(app().settings()); MarkupProcessor processor = new MarkupProcessor(app().settings(), app().xssFilter()); Repository r = getRepository(); RevCommit head = JGitUtils.getCommit(r, null); src/main/java/com/gitblit/wicket/pages/EditTicketPage.java
@@ -50,6 +50,8 @@ import com.gitblit.tickets.TicketResponsible; import com.gitblit.utils.StringUtils; import com.gitblit.wicket.GitBlitWebSession; import com.gitblit.wicket.SafeTextModel; import com.gitblit.wicket.SafeTextModel.Mode; import com.gitblit.wicket.WicketUtils; import com.gitblit.wicket.panels.MarkdownTextArea; @@ -110,8 +112,8 @@ } typeModel = Model.of(ticket.type); titleModel = Model.of(ticket.title); topicModel = Model.of(ticket.topic == null ? "" : ticket.topic); titleModel = SafeTextModel.none(ticket.title); topicModel = SafeTextModel.none(ticket.topic == null ? "" : ticket.topic); responsibleModel = Model.of(); milestoneModel = Model.of(); mergeToModel = Model.of(ticket.mergeTo == null ? getRepositoryModel().mergeTo : ticket.mergeTo); @@ -134,7 +136,7 @@ form.add(new TextField<String>("title", titleModel)); form.add(new TextField<String>("topic", topicModel)); final IModel<String> markdownPreviewModel = new Model<String>(); final SafeTextModel markdownPreviewModel = new SafeTextModel(Mode.none); descriptionPreview = new Label("descriptionPreview", markdownPreviewModel); descriptionPreview.setEscapeModelStrings(false); descriptionPreview.setOutputMarkupId(true); src/main/java/com/gitblit/wicket/pages/NewTicketPage.java
@@ -46,6 +46,8 @@ import com.gitblit.tickets.TicketResponsible; import com.gitblit.utils.StringUtils; import com.gitblit.wicket.GitBlitWebSession; import com.gitblit.wicket.SafeTextModel; import com.gitblit.wicket.SafeTextModel.Mode; import com.gitblit.wicket.WicketUtils; import com.gitblit.wicket.panels.MarkdownTextArea; @@ -87,8 +89,8 @@ } typeModel = Model.of(TicketModel.Type.defaultType); titleModel = Model.of(); topicModel = Model.of(); titleModel = SafeTextModel.none(); topicModel = SafeTextModel.none(); mergeToModel = Model.of(Repository.shortenRefName(getRepositoryModel().mergeTo)); responsibleModel = Model.of(); milestoneModel = Model.of(); @@ -103,7 +105,7 @@ form.add(new TextField<String>("title", titleModel)); form.add(new TextField<String>("topic", topicModel)); final IModel<String> markdownPreviewModel = new Model<String>(); final SafeTextModel markdownPreviewModel = new SafeTextModel(Mode.none); descriptionPreview = new Label("descriptionPreview", markdownPreviewModel); descriptionPreview.setEscapeModelStrings(false); descriptionPreview.setOutputMarkupId(true); src/main/java/com/gitblit/wicket/pages/RepositoryPage.java
@@ -550,7 +550,8 @@ String html; switch (model.commitMessageRenderer) { case MARKDOWN: html = MessageFormat.format("<div class='commit_message'>{0}</div>", content); String safeContent = app().xssFilter().relaxed(content); html = MessageFormat.format("<div class='commit_message'>{0}</div>", safeContent); break; default: html = MessageFormat.format("<pre class='commit_message'>{0}</pre>", content); src/main/java/com/gitblit/wicket/pages/SummaryPage.java
@@ -138,7 +138,7 @@ MarkupDocument markupDoc = null; RevCommit head = JGitUtils.getCommit(r, null); if (head != null) { MarkupProcessor processor = new MarkupProcessor(app().settings()); MarkupProcessor processor = new MarkupProcessor(app().settings(), app().xssFilter()); markupDoc = processor.getReadme(r, repositoryName, getBestCommitId(head)); } if (markupDoc == null || markupDoc.markup == null) { src/main/java/com/gitblit/wicket/panels/CommentPanel.java
@@ -19,13 +19,14 @@ import org.apache.wicket.ajax.markup.html.form.AjaxButton; import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.form.Form; import org.apache.wicket.model.IModel; import org.apache.wicket.model.Model; import com.gitblit.models.RepositoryModel; import com.gitblit.models.TicketModel; import com.gitblit.models.TicketModel.Change; import com.gitblit.models.UserModel; import com.gitblit.wicket.SafeTextModel; import com.gitblit.wicket.SafeTextModel.Mode; import com.gitblit.wicket.WicketUtils; import com.gitblit.wicket.pages.BasePage; @@ -89,7 +90,7 @@ } }.setVisible(ticket != null && ticket.number > 0)); final IModel<String> markdownPreviewModel = new Model<String>(); final SafeTextModel markdownPreviewModel = new SafeTextModel(Mode.none); markdownPreview = new Label("markdownPreview", markdownPreviewModel); markdownPreview.setEscapeModelStrings(false); markdownPreview.setOutputMarkupId(true); src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java
@@ -20,12 +20,12 @@ import org.apache.wicket.ajax.form.AjaxFormComponentUpdatingBehavior; import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.form.TextArea; import org.apache.wicket.model.IModel; import org.apache.wicket.model.PropertyModel; import org.apache.wicket.util.time.Duration; import com.gitblit.utils.MarkdownUtils; import com.gitblit.wicket.GitBlitWebApp; import com.gitblit.wicket.SafeTextModel; public class MarkdownTextArea extends TextArea { @@ -35,7 +35,7 @@ protected String text = ""; public MarkdownTextArea(String id, final IModel<String> previewModel, final Label previewLabel) { public MarkdownTextArea(String id, final SafeTextModel previewModel, final Label previewLabel) { super(id); setModel(new PropertyModel(this, "text")); add(new AjaxFormComponentUpdatingBehavior("onblur") { @@ -65,7 +65,7 @@ setOutputMarkupId(true); } protected void renderPreview(IModel<String> previewModel) { protected void renderPreview(SafeTextModel previewModel) { if (text == null) { return; } src/test/java/com/gitblit/tests/AuthenticationManagerTest.java
@@ -26,6 +26,8 @@ import com.gitblit.manager.UserManager; import com.gitblit.models.UserModel; import com.gitblit.tests.mock.MemorySettings; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; /** * Class for testing local authentication. @@ -42,7 +44,8 @@ } IAuthenticationManager newAuthenticationManager() { RuntimeManager runtime = new RuntimeManager(getSettings(), GitBlitSuite.BASEFOLDER).start(); XssFilter xssFilter = new AllowXssFilter(); RuntimeManager runtime = new RuntimeManager(getSettings(), xssFilter, GitBlitSuite.BASEFOLDER).start(); users = new UserManager(runtime, null).start(); AuthenticationManager auth = new AuthenticationManager(runtime, users).start(); return auth; src/test/java/com/gitblit/tests/BranchTicketServiceTest.java
@@ -29,6 +29,8 @@ import com.gitblit.models.RepositoryModel; import com.gitblit.tickets.BranchTicketService; import com.gitblit.tickets.ITicketService; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; /** * Tests the branch ticket service. @@ -50,8 +52,8 @@ protected ITicketService getService(boolean deleteAll) throws Exception { IStoredSettings settings = getSettings(deleteAll); IRuntimeManager runtimeManager = new RuntimeManager(settings).start(); XssFilter xssFilter = new AllowXssFilter(); IRuntimeManager runtimeManager = new RuntimeManager(settings, xssFilter).start(); IPluginManager pluginManager = new PluginManager(runtimeManager).start(); INotificationManager notificationManager = new NotificationManager(settings).start(); IUserManager userManager = new UserManager(runtimeManager, pluginManager).start(); src/test/java/com/gitblit/tests/FileTicketServiceTest.java
@@ -29,6 +29,8 @@ import com.gitblit.models.RepositoryModel; import com.gitblit.tickets.FileTicketService; import com.gitblit.tickets.ITicketService; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; /** * Tests the file ticket service. @@ -49,8 +51,8 @@ protected ITicketService getService(boolean deleteAll) throws Exception { IStoredSettings settings = getSettings(deleteAll); IRuntimeManager runtimeManager = new RuntimeManager(settings).start(); XssFilter xssFilter = new AllowXssFilter(); IRuntimeManager runtimeManager = new RuntimeManager(settings, xssFilter).start(); IPluginManager pluginManager = new PluginManager(runtimeManager).start(); INotificationManager notificationManager = new NotificationManager(settings).start(); IUserManager userManager = new UserManager(runtimeManager, pluginManager).start(); src/test/java/com/gitblit/tests/HtpasswdAuthenticationTest.java
@@ -32,6 +32,8 @@ import com.gitblit.manager.UserManager; import com.gitblit.models.UserModel; import com.gitblit.tests.mock.MemorySettings; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; /** * Test the Htpasswd user service. @@ -74,7 +76,8 @@ } private HtpasswdAuthProvider newHtpasswdAuthentication(IStoredSettings settings) { RuntimeManager runtime = new RuntimeManager(settings, GitBlitSuite.BASEFOLDER).start(); XssFilter xssFilter = new AllowXssFilter(); RuntimeManager runtime = new RuntimeManager(settings, xssFilter, GitBlitSuite.BASEFOLDER).start(); UserManager users = new UserManager(runtime, null).start(); HtpasswdAuthProvider htpasswd = new HtpasswdAuthProvider(); htpasswd.setup(runtime, users); @@ -82,7 +85,8 @@ } private AuthenticationManager newAuthenticationManager(IStoredSettings settings) { RuntimeManager runtime = new RuntimeManager(settings, GitBlitSuite.BASEFOLDER).start(); XssFilter xssFilter = new AllowXssFilter(); RuntimeManager runtime = new RuntimeManager(settings, xssFilter, GitBlitSuite.BASEFOLDER).start(); UserManager users = new UserManager(runtime, null).start(); HtpasswdAuthProvider htpasswd = new HtpasswdAuthProvider(); htpasswd.setup(runtime, users); src/test/java/com/gitblit/tests/LdapAuthenticationTest.java
@@ -39,6 +39,8 @@ import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; import com.gitblit.tests.mock.MemorySettings; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; import com.unboundid.ldap.listener.InMemoryDirectoryServer; import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; import com.unboundid.ldap.listener.InMemoryListenerConfig; @@ -96,7 +98,8 @@ } private LdapAuthProvider newLdapAuthentication(IStoredSettings settings) { RuntimeManager runtime = new RuntimeManager(settings, GitBlitSuite.BASEFOLDER).start(); XssFilter xssFilter = new AllowXssFilter(); RuntimeManager runtime = new RuntimeManager(settings, xssFilter, GitBlitSuite.BASEFOLDER).start(); userManager = new UserManager(runtime, null).start(); LdapAuthProvider ldap = new LdapAuthProvider(); ldap.setup(runtime, userManager); @@ -104,7 +107,8 @@ } private AuthenticationManager newAuthenticationManager(IStoredSettings settings) { RuntimeManager runtime = new RuntimeManager(settings, GitBlitSuite.BASEFOLDER).start(); XssFilter xssFilter = new AllowXssFilter(); RuntimeManager runtime = new RuntimeManager(settings, xssFilter, GitBlitSuite.BASEFOLDER).start(); AuthenticationManager auth = new AuthenticationManager(runtime, userManager); auth.addAuthenticationProvider(newLdapAuthentication(settings)); return auth; src/test/java/com/gitblit/tests/LuceneExecutorTest.java
@@ -34,6 +34,8 @@ import com.gitblit.tests.mock.MemorySettings; import com.gitblit.utils.FileUtils; import com.gitblit.utils.JGitUtils; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; /** * Tests Lucene indexing and querying. @@ -48,7 +50,8 @@ private LuceneService newLuceneExecutor() { MemorySettings settings = new MemorySettings(); settings.put(Keys.git.repositoriesFolder, GitBlitSuite.REPOSITORIES); RuntimeManager runtime = new RuntimeManager(settings, GitBlitSuite.BASEFOLDER).start(); XssFilter xssFilter = new AllowXssFilter(); RuntimeManager runtime = new RuntimeManager(settings, xssFilter, GitBlitSuite.BASEFOLDER).start(); UserManager users = new UserManager(runtime, null).start(); RepositoryManager repos = new RepositoryManager(runtime, null, users); return new LuceneService(settings, repos); src/test/java/com/gitblit/tests/RedisTicketServiceTest.java
@@ -30,6 +30,8 @@ import com.gitblit.models.RepositoryModel; import com.gitblit.tickets.ITicketService; import com.gitblit.tickets.RedisTicketService; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; /** * Tests the Redis ticket service. @@ -57,8 +59,8 @@ protected ITicketService getService(boolean deleteAll) throws Exception { IStoredSettings settings = getSettings(deleteAll); IRuntimeManager runtimeManager = new RuntimeManager(settings).start(); XssFilter xssFilter = new AllowXssFilter(); IRuntimeManager runtimeManager = new RuntimeManager(settings, xssFilter).start(); IPluginManager pluginManager = new PluginManager(runtimeManager).start(); INotificationManager notificationManager = new NotificationManager(settings).start(); IUserManager userManager = new UserManager(runtimeManager, pluginManager).start(); src/test/java/com/gitblit/tests/RedmineAuthenticationTest.java
@@ -13,6 +13,8 @@ import com.gitblit.manager.UserManager; import com.gitblit.models.UserModel; import com.gitblit.tests.mock.MemorySettings; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; public class RedmineAuthenticationTest extends GitblitUnitTest { @@ -25,7 +27,8 @@ } RedmineAuthProvider newRedmineAuthentication(IStoredSettings settings) { RuntimeManager runtime = new RuntimeManager(settings, GitBlitSuite.BASEFOLDER).start(); XssFilter xssFilter = new AllowXssFilter(); RuntimeManager runtime = new RuntimeManager(settings, xssFilter, GitBlitSuite.BASEFOLDER).start(); UserManager users = new UserManager(runtime, null).start(); RedmineAuthProvider redmine = new RedmineAuthProvider(); redmine.setup(runtime, users); @@ -37,7 +40,8 @@ } AuthenticationManager newAuthenticationManager() { RuntimeManager runtime = new RuntimeManager(getSettings(), GitBlitSuite.BASEFOLDER).start(); XssFilter xssFilter = new AllowXssFilter(); RuntimeManager runtime = new RuntimeManager(getSettings(), xssFilter, GitBlitSuite.BASEFOLDER).start(); UserManager users = new UserManager(runtime, null).start(); RedmineAuthProvider redmine = new RedmineAuthProvider(); redmine.setup(runtime, users); src/test/java/com/gitblit/tests/mock/MockRuntimeManager.java
@@ -28,6 +28,8 @@ import com.gitblit.models.ServerSettings; import com.gitblit.models.ServerStatus; import com.gitblit.models.SettingModel; import com.gitblit.utils.XssFilter; import com.gitblit.utils.XssFilter.AllowXssFilter; import com.google.inject.Injector; public class MockRuntimeManager implements IRuntimeManager { @@ -134,6 +136,11 @@ } @Override public XssFilter getXssFilter() { return new AllowXssFilter(); } @Override public boolean updateSettings(Map<String, String> updatedSettings) { return settings.saveSettings(updatedSettings); }