1 /*
2  * Copyright (C) 2004-2008 Jive Software. All rights reserved.
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, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package org.jivesoftware.openfire.container;
18 
19 import java.io.BufferedInputStream;
20 import java.io.File;
21 import java.io.FileInputStream;
22 import java.io.IOException;
23 import java.io.InputStream;
24 import java.nio.file.Path;
25 import java.util.Collections;
26 import java.util.Enumeration;
27 import java.util.HashSet;
28 import java.util.List;
29 import java.util.Map;
30 import java.util.Set;
31 import java.util.concurrent.ConcurrentHashMap;
32 
33 import org.dom4j.Document;
34 import org.jivesoftware.admin.FlashMessageTag;
35 import org.jivesoftware.admin.PluginFilter;
36 import org.jivesoftware.openfire.XMPPServer;
37 import org.jivesoftware.util.JiveGlobals;
38 import org.jivesoftware.util.LocaleUtils;
39 import org.jivesoftware.util.StringUtils;
40 import org.jivesoftware.util.SystemProperty;
41 import org.jivesoftware.util.WebXmlUtils;
42 import org.slf4j.Logger;
43 import org.slf4j.LoggerFactory;
44 
45 import javax.servlet.Filter;
46 import javax.servlet.FilterConfig;
47 import javax.servlet.GenericServlet;
48 import javax.servlet.Servlet;
49 import javax.servlet.ServletConfig;
50 import javax.servlet.ServletContext;
51 import javax.servlet.ServletException;
52 import javax.servlet.ServletOutputStream;
53 import javax.servlet.http.HttpServlet;
54 import javax.servlet.http.HttpServletRequest;
55 import javax.servlet.http.HttpServletResponse;
56 
57 /**
58  * The plugin servlet acts as a proxy for web requests (in the admin console)
59  * to plugins. Since plugins can be dynamically loaded and live in a different place
60  * than normal Openfire admin console files, it's not possible to have them
61  * added to the normal Openfire admin console web app directory.
62  * <p>
63  * The servlet listens for requests in the form {@code /plugins/[pluginName]/[JSP File]}
64  * (e.g. {@code /plugins/foo/example.jsp}). It also listens for non JSP requests in the
65  * form like {@code /plugins/[pluginName]/images/*.png|gif},
66  * {@code /plugins/[pluginName]/scripts/*.js|css} or
67  * {@code /plugins/[pluginName]/styles/*.css} (e.g.
68  * {@code /plugins/foo/images/example.gif}).
69  * </p>
70  * JSP files must be compiled and available via the plugin's class loader. The mapping
71  * between JSP name and servlet class files is defined in [pluginName]/web/web.xml.
72  * Typically, this file is auto-generated by the JSP compiler when packaging the plugin.
73  *
74  * @author Matt Tucker
75  */
76 public class PluginServlet extends HttpServlet {
77 
78     private static final Logger Log = LoggerFactory.getLogger(PluginServlet.class);
79     private static final String CSRF_ATTRIBUTE = "csrf";
80 
81     public static final SystemProperty<Boolean> ALLOW_LOCAL_FILE_READING = SystemProperty.Builder.ofType( Boolean.class )
82         .setKey( "plugins.servlet.allowLocalFileReading" )
83         .setDynamic( true )
84         .setDefaultValue( false )
85         .build();
86 
87     private static final Map<String, GenericServlet> servlets;  // mapped using lowercase path (OF-1105)
88     private static PluginManager pluginManager;
89     private static ServletConfig servletConfig;
90 
91     static {
92         servlets = new ConcurrentHashMap<>();
93     }
94 
95     private static final String PLUGINS_WEBROOT = "/plugins/";
96 
97     @Override
init(ServletConfig config)98     public void init(ServletConfig config) throws ServletException {
99         super.init(config);
100         servletConfig = config;
101     }
102 
103     @Override
service(HttpServletRequest request, HttpServletResponse response)104     public void service(HttpServletRequest request, HttpServletResponse response) {
105         String pathInfo = request.getPathInfo();
106         if (pathInfo == null) {
107             response.setStatus(HttpServletResponse.SC_NOT_FOUND);
108         }
109         else {
110             try {
111                 final PluginMetadata pluginMetadata = getPluginMetadataFromPath(pathInfo);
112                 if (pluginMetadata.isCsrfProtectionEnabled()) {
113                     if (!passesCsrf(request)) {
114                         request.getSession().setAttribute(FlashMessageTag.ERROR_MESSAGE_KEY, LocaleUtils.getLocalizedString("global.csrf.failed"));
115                         response.sendRedirect(request.getRequestURI());
116                         return;
117                     }
118                 }
119                 // Handle JSP requests.
120                 if (pathInfo.endsWith(".jsp")) {
121                     setCSRF(request);
122                     handleJSP(pathInfo, request, response);
123                 }
124                 // Handle servlet requests.
125                 else if (getServlet(pathInfo) != null) {
126                     setCSRF(request);
127                     handleServlet(pathInfo, request, response);
128                 }
129                 // Handle image/other requests.
130                 else {
131                     handleOtherRequest(pathInfo, response);
132                 }
133             }
134             catch (Exception e) {
135                 Log.error(e.getMessage(), e);
136                 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
137             }
138         }
139     }
140 
setCSRF(final HttpServletRequest request)141     private static void setCSRF(final HttpServletRequest request) {
142         final String csrf = StringUtils.randomString(32);
143         request.getSession().setAttribute(CSRF_ATTRIBUTE, csrf);
144         request.setAttribute(CSRF_ATTRIBUTE, csrf);
145     }
146 
passesCsrf(final HttpServletRequest request)147     private boolean passesCsrf(final HttpServletRequest request) {
148         if (request.getMethod().equals("GET")) {
149             // No CSRF's for GET requests
150             return true;
151         }
152 
153         final String sessionCsrf = (String) request.getSession().getAttribute(CSRF_ATTRIBUTE);
154         return sessionCsrf != null && sessionCsrf.equals(request.getParameter(CSRF_ATTRIBUTE));
155     }
156 
getPluginMetadataFromPath(final String pathInfo)157     private PluginMetadata getPluginMetadataFromPath(final String pathInfo) {
158         final String pluginName = pathInfo.split("/")[1];
159         return XMPPServer.getInstance().getPluginManager().getMetadata(pluginName);
160     }
161 
162     /**
163      * Registers all JSP page servlets for a plugin.
164      *
165      * @param manager the plugin manager.
166      * @param plugin the plugin.
167      * @param webXML the web.xml file containing JSP page names to servlet class file
168      *      mappings.
169      */
registerServlets( PluginManager manager, final Plugin plugin, File webXML)170     public static void registerServlets( PluginManager manager, final Plugin plugin, File webXML)
171     {
172         pluginManager = manager;
173 
174         if ( !webXML.exists() )
175         {
176             Log.error("Could not register plugin servlets, file " + webXML.getAbsolutePath() + " does not exist.");
177             return;
178         }
179 
180         // Find the name of the plugin directory given that the webXML file lives in plugins/[pluginName]/web/web.xml
181         final String pluginName = webXML.getParentFile().getParentFile().getParentFile().getName();
182         try
183         {
184             final Document webXmlDoc = WebXmlUtils.asDocument( webXML );
185 
186             final List<String> servletNames = WebXmlUtils.getServletNames( webXmlDoc );
187             for ( final String servletName : servletNames )
188             {
189                 Log.debug( "Loading servlet '{}' of plugin '{}'...", servletName, pluginName );
190 
191                 final String className = WebXmlUtils.getServletClassName( webXmlDoc, servletName );
192                 if ( className == null || className.isEmpty() )
193                 {
194                     Log.warn( "Could not load servlet '{}' of plugin '{}'. web-xml does not define a class name for this servlet.", servletName, pluginName );
195                     continue;
196                 }
197 
198                 try {
199                     final Class<?> theClass = manager.loadClass(plugin, className);
200 
201                     final Object instance = theClass.newInstance();
202                     if (!(instance instanceof GenericServlet)) {
203                         Log.warn("Could not load servlet '{}' of plugin '{}'. Its class ({}) is not an instance of javax.servlet.GenericServlet.", servletName, pluginName, className);
204                         continue;
205                     }
206 
207                     Log.debug("Initializing servlet '{}' of plugin '{}'...", servletName, pluginName);
208                     ((GenericServlet) instance).init(new ServletConfig() {
209                         @Override
210                         public String getServletName() {
211                             return servletName;
212                         }
213 
214                         @Override
215                         public ServletContext getServletContext() {
216                             return new PluginServletContext( servletConfig.getServletContext(), pluginManager, plugin );
217                         }
218 
219                         @Override
220                         public String getInitParameter( String s )
221                         {
222                             final Map<String, String> params = WebXmlUtils.getServletInitParams( webXmlDoc, servletName );
223                             if (params.isEmpty()) {
224                                 return null;
225                             }
226                             return params.get( s );
227                         }
228 
229                         @Override
230                         public Enumeration<String> getInitParameterNames()
231                         {
232                             final Map<String, String> params = WebXmlUtils.getServletInitParams( webXmlDoc, servletName );
233                             if (params.isEmpty()) {
234                                 return Collections.emptyEnumeration();
235                             }
236                             return Collections.enumeration( params.keySet() );
237                         }
238                     });
239 
240                     Log.debug("Registering servlet '{}' of plugin '{}' URL patterns.", servletName, pluginName);
241                     final Set<String> urlPatterns = WebXmlUtils.getServletUrlPatterns(webXmlDoc, servletName);
242                     for (final String urlPattern : urlPatterns) {
243                         final String path = (pluginName + urlPattern).toLowerCase();
244                         servlets.put(path, (GenericServlet) instance);
245                         Log.debug("Servlet '{}' registered on path: {}", servletName, path);
246                     }
247                     Log.debug("Servlet '{}' of plugin '{}' loaded successfully.", servletName, pluginName);
248                 } catch (final Exception e) {
249                     Log.warn("Exception attempting to load servlet '{}' ({}) of plugin '{}'", servletName, className, pluginName, e);
250                 }
251             }
252 
253 
254             final List<String> filterNames = WebXmlUtils.getFilterNames( webXmlDoc );
255             for ( final String filterName : filterNames )
256             {
257                 Log.debug( "Loading filter '{}' of plugin '{}'...", filterName, pluginName );
258                 final String className = WebXmlUtils.getFilterClassName( webXmlDoc, filterName );
259                 if ( className == null || className.isEmpty() )
260                 {
261                     Log.warn( "Could not load filter '{}' of plugin '{}'. web-xml does not define a class name for this filter.", filterName, pluginName );
262                     continue;
263                 }
264                 final Class<?> theClass = manager.loadClass( plugin, className );
265 
266                 final Object instance = theClass.newInstance();
267                 if ( !(instance instanceof Filter) )
268                 {
269                     Log.warn( "Could not load filter '{}' of plugin '{}'. Its class ({}) is not an instance of javax.servlet.Filter.", filterName, pluginName, className );
270                     continue;
271                 }
272 
273                 Log.debug( "Initializing filter '{}' of plugin '{}'...", filterName, pluginName );
274                 ( (Filter) instance ).init( new FilterConfig()
275                 {
276                     @Override
277                     public String getFilterName()
278                     {
279                         return filterName;
280                     }
281 
282                     @Override
283                     public ServletContext getServletContext()
284                     {
285                         return new PluginServletContext( servletConfig.getServletContext(), pluginManager, plugin );
286                     }
287 
288                     @Override
289                     public String getInitParameter( String s )
290                     {
291                         final Map<String, String> params = WebXmlUtils.getFilterInitParams( webXmlDoc, filterName );
292                         if (params.isEmpty()) {
293                             return null;
294                         }
295                         return params.get( s );
296                     }
297 
298                     @Override
299                     public Enumeration<String> getInitParameterNames()
300                     {
301                         final Map<String, String> params = WebXmlUtils.getFilterInitParams( webXmlDoc, filterName );
302                         if (params.isEmpty()) {
303                             return Collections.emptyEnumeration();
304                         }
305                         return Collections.enumeration( params.keySet() );
306                     }
307                 } );
308 
309                 Log.debug( "Registering filter '{}' of plugin '{}' URL patterns.", filterName, pluginName );
310                 final Set<String> urlPatterns = WebXmlUtils.getFilterUrlPatterns( webXmlDoc, filterName );
311                 for ( final String urlPattern : urlPatterns )
312                 {
313                     PluginFilter.addPluginFilter( urlPattern, ( (Filter) instance ) );
314                 }
315                 Log.debug( "Filter '{}' of plugin '{}' loaded successfully.", filterName, pluginName );
316             }
317         }
318         catch (Throwable e)
319         {
320             Log.error( "An unexpected problem occurred while attempting to register servlets for plugin '{}'.", plugin, e);
321         }
322     }
323 
324     /**
325      * Unregisters all JSP page servlets for a plugin.
326      *
327      * @param webXML the web.xml file containing JSP page names to servlet class file
328      *               mappings.
329      */
unregisterServlets(File webXML)330     public static void unregisterServlets(File webXML)
331     {
332         if ( !webXML.exists() )
333         {
334             Log.error("Could not unregister plugin servlets, file " + webXML.getAbsolutePath() + " does not exist.");
335             return;
336         }
337 
338         // Find the name of the plugin directory given that the webXML file lives in plugins/[pluginName]/web/web.xml
339         final String pluginName = webXML.getParentFile().getParentFile().getParentFile().getName();
340         try
341         {
342             final Document webXmlDoc = WebXmlUtils.asDocument( webXML );
343 
344             // Un-register and destroy all servlets.
345             final List<String> servletNames = WebXmlUtils.getServletNames( webXmlDoc );
346             for ( final String servletName : servletNames )
347             {
348                 Log.debug( "Unregistering servlet '{}' of plugin '{}'", servletName, pluginName );
349                 final Set<Servlet> toDestroy = new HashSet<>();
350                 final Set<String> urlPatterns = WebXmlUtils.getServletUrlPatterns( webXmlDoc, servletName );
351                 for ( final String urlPattern : urlPatterns )
352                 {
353                     final GenericServlet servlet = servlets.remove( ( pluginName + urlPattern ).toLowerCase() );
354                     if (servlet != null)
355                     {
356                         toDestroy.add( servlet );
357                     }
358                 }
359 
360                 for ( final Servlet servlet : toDestroy )
361                 {
362                     servlet.destroy();
363                 }
364 
365                 Log.debug( "Servlet '{}' of plugin '{}' unregistered and destroyed successfully.", servletName, pluginName );
366 
367             }
368 
369             // Un-register and destroy all servlet filters.
370             final List<String> filterNames = WebXmlUtils.getFilterNames( webXmlDoc );
371             for ( final String filterName : filterNames )
372             {
373                 Log.debug( "Unregistering filter '{}' of plugin '{}'", filterName, pluginName );
374                 final Set<Filter> toDestroy = new HashSet<>();
375                 final String className = WebXmlUtils.getFilterClassName( webXmlDoc, filterName );
376                 final Set<String> urlPatterns = WebXmlUtils.getFilterUrlPatterns( webXmlDoc, filterName );
377                 for ( final String urlPattern : urlPatterns )
378                 {
379                     final Filter filter = PluginFilter.removePluginFilter( urlPattern, className );
380                     if (filter != null)
381                     {
382                         toDestroy.add( filter );
383                     }
384                 }
385 
386                 for ( final Filter filter : toDestroy )
387                 {
388                     filter.destroy();
389                 }
390 
391                 Log.debug( "Filter '{}' of plugin '{}' unregistered and destroyesd successfully.", filterName, pluginName );
392             }
393         }
394         catch (Throwable e) {
395             Log.error( "An unexpected problem occurred while attempting to unregister servlets.", e);
396         }
397     }
398 
399     /**
400      * Registers a live servlet for a plugin programmatically, does not
401      * initialize the servlet.
402      *
403      * @param pluginManager the plugin manager
404      * @param plugin the owner of the servlet
405      * @param servlet the servlet.
406      * @param relativeUrl the relative url where the servlet should be bound
407      * @return the effective url that can be used to initialize the servlet
408      * @throws ServletException if the servlet is null
409      */
registerServlet(PluginManager pluginManager, Plugin plugin, GenericServlet servlet, String relativeUrl)410     public static String registerServlet(PluginManager pluginManager,
411             Plugin plugin, GenericServlet servlet, String relativeUrl)
412             throws ServletException {
413 
414         String pluginName = pluginManager.getPluginPath(plugin).getFileName().toString();
415         PluginServlet.pluginManager = pluginManager;
416         if (servlet == null) {
417             throw new ServletException("Servlet is missing");
418         }
419         String pluginServletUrl = pluginName + relativeUrl;
420         servlets.put((pluginName + relativeUrl).toLowerCase(), servlet);
421         return PLUGINS_WEBROOT + pluginServletUrl;
422 
423     }
424 
425     /**
426      * Unregister a live servlet for a plugin programmatically. Does not call
427      * the servlet destroy method.
428      *
429      * @param plugin the owner of the servlet
430      * @param url the relative url where servlet has been bound
431      * @return the unregistered servlet, so that it can be destroyed
432      * @throws ServletException if the URL is missing
433      */
unregisterServlet(Plugin plugin, String url)434     public static GenericServlet unregisterServlet(Plugin plugin, String url)
435             throws ServletException {
436         String pluginName = pluginManager.getPluginPath(plugin).getFileName().toString();
437         if (url == null) {
438             throw new ServletException("Servlet URL is missing");
439         }
440         String fullUrl = pluginName + url;
441         return servlets.remove(fullUrl.toLowerCase());
442     }
443 
444     /**
445      * Handles a request for a JSP page. It checks to see if a servlet is mapped
446      * for the JSP URL. If one is found, request handling is passed to it. If no
447      * servlet is found, a 404 error is returned.
448      *
449      * @param pathInfo the extra path info.
450      * @param request  the request object.
451      * @param response the response object.
452      * @throws ServletException if a servlet exception occurs while handling the request.
453      * @throws IOException      if an IOException occurs while handling the request.
454      */
handleJSP(String pathInfo, HttpServletRequest request, HttpServletResponse response)455     private void handleJSP(String pathInfo, HttpServletRequest request,
456                            HttpServletResponse response) throws ServletException, IOException {
457         // Strip the starting "/" from the path to find the JSP URL.
458         String jspURL = pathInfo.substring(1);
459         GenericServlet servlet = servlets.get(jspURL.toLowerCase());
460         if (servlet != null) {
461             Log.trace("Handling JSP at: {}", jspURL);
462             servlet.service(request, response);
463         }
464         else {
465             Log.trace("Unable to handle JSP. No registration for: {}", jspURL);
466             response.setStatus(HttpServletResponse.SC_NOT_FOUND);
467         }
468     }
469 
470     /**
471      * Handles a request for a Servlet. If one is found, request handling is passed to it.
472      * If no servlet is found, a 404 error is returned.
473      *
474      * @param pathInfo the extra path info.
475      * @param request  the request object.
476      * @param response the response object.
477      * @throws ServletException if a servlet exception occurs while handling the request.
478      * @throws IOException      if an IOException occurs while handling the request.
479      */
handleServlet(String pathInfo, HttpServletRequest request, HttpServletResponse response)480     private void handleServlet(String pathInfo, HttpServletRequest request,
481                                HttpServletResponse response) throws ServletException, IOException {
482         // Strip the starting "/" from the path to find the JSP URL.
483         GenericServlet servlet = getServlet(pathInfo);
484         if (servlet != null) {
485             Log.trace("Handling servlet at: {}", pathInfo);
486             servlet.service(request, response);
487         }
488         else {
489             Log.trace("Unable to handle servlet. No registration for: {}", pathInfo);
490             response.setStatus(HttpServletResponse.SC_NOT_FOUND);
491         }
492     }
493 
494     /**
495      * Returns the correct servlet with mapping checks.
496      *
497      * @param pathInfo the pathinfo to map to the servlet.
498      * @return the mapped servlet, or null if no servlet was found.
499      */
getServlet(String pathInfo)500     private GenericServlet getServlet(String pathInfo) {
501         pathInfo = pathInfo.substring(1).toLowerCase();
502 
503         GenericServlet servlet = servlets.get(pathInfo);
504         if (servlet == null) {
505             for (String key : servlets.keySet()) {
506                 int index = key.indexOf("/*");
507                 String searchkey = key;
508                 if (index != -1) {
509                     searchkey = key.substring(0, index);
510                 }
511                 if (searchkey.startsWith(pathInfo) || pathInfo.startsWith(searchkey)) {
512                     servlet = servlets.get(key);
513                     break;
514                 }
515             }
516         }
517         Log.trace("Found servlet {} for path {}", servlet != null ? servlet.getServletName() : "(none)", pathInfo);
518         return servlet;
519     }
520 
521 
522     /**
523      * Handles a request for other web items (images, etc.)
524      *
525      * @param pathInfo the extra path info.
526      * @param response the response object.
527      * @throws IOException if an IOException occurs while handling the request.
528      */
handleOtherRequest(String pathInfo, HttpServletResponse response)529     private void handleOtherRequest(String pathInfo, HttpServletResponse response) throws IOException {
530         String[] parts = pathInfo.split("/");
531         // Image request must be in correct format.
532         if (parts.length < 3) {
533             Log.trace("Unable to handle 'other' request (not enough path parts): {}", pathInfo);
534             response.setStatus(HttpServletResponse.SC_NOT_FOUND);
535             return;
536         }
537 
538         String contextPath = "";
539         int index = pathInfo.indexOf(parts[1]);
540         if (index != -1) {
541             contextPath = pathInfo.substring(index + parts[1].length());
542         }
543 
544         File pluginDirectory = new File(JiveGlobals.getHomeDirectory(), "plugins");
545         File file = new File(pluginDirectory, parts[1] + File.separator + "web" + contextPath);
546 
547         if ( !ALLOW_LOCAL_FILE_READING.getValue() ) {
548             // Ensure that the file that's being served is a file that is part of Openfire. This guards against
549             // accessing files from the operating system, or other files that shouldn't be accessible via the web (OF-1886).
550             final Path absoluteHome = new File( JiveGlobals.getHomeDirectory() ).toPath().normalize().toAbsolutePath();
551             final Path absoluteLookup = file.toPath().normalize().toAbsolutePath();
552             if ( !absoluteLookup.startsWith( absoluteHome ) )
553             {
554                 Log.trace("Unable to handle 'other' request (forbidden path): {}", pathInfo);
555                 response.setStatus( HttpServletResponse.SC_FORBIDDEN );
556                 return;
557             }
558         }
559 
560         if (!file.exists()) {
561             Log.trace("Unable to handle 'other' request (not found): {}", pathInfo);
562             response.setStatus(HttpServletResponse.SC_NOT_FOUND);
563             return;
564         }
565 
566         String contentType = getServletContext().getMimeType(pathInfo);
567         if (contentType == null) {
568             contentType = "text/plain";
569         }
570         response.setContentType(contentType);
571         // Write out the resource to the user.
572         Log.trace("Handling 'other' request with a response content type of {}: {}", contentType, pathInfo);
573         try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
574             try (ServletOutputStream out = response.getOutputStream()) {
575 
576                 // Set the size of the file.
577                 response.setContentLength((int) file.length());
578 
579                 // Use a 1K buffer.
580                 byte[] buf = new byte[1024];
581                 int len;
582                 while ((len = in.read(buf)) != -1) {
583                     out.write(buf, 0, len);
584                 }
585             }
586         }
587     }
588 }
589