/* * Copyright (C) 2005-2008 Jive Software. All rights reserved. * * 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 org.jivesoftware.openfire.http; import java.io.*; import java.net.InetAddress; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.cert.X509Certificate; import java.util.Date; import javax.servlet.*; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang.StringEscapeUtils; import org.dom4j.Document; import org.dom4j.DocumentHelper; import org.dom4j.Element; import org.dom4j.QName; import org.dom4j.io.XMPPPacketReader; import org.jivesoftware.openfire.auth.UnauthorizedException; import org.jivesoftware.openfire.net.MXParser; import org.jivesoftware.util.JiveGlobals; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; /** * Servlet which handles requests to the HTTP binding service. It determines if there is currently * an {@link HttpSession} related to the connection or if one needs to be created and then passes it * off to the {@link HttpBindManager} for processing of the client request and formulating of the * response. * * @author Alexander Wenckus */ public class HttpBindServlet extends HttpServlet { private static final Logger Log = LoggerFactory.getLogger(HttpBindServlet.class); private HttpSessionManager sessionManager; private HttpBindManager boshManager; private static XmlPullParserFactory factory; static { try { factory = XmlPullParserFactory.newInstance(MXParser.class.getName(), null); } catch (XmlPullParserException e) { Log.error("Error creating a parser factory", e); } } private ThreadLocal<XMPPPacketReader> localReader = new ThreadLocal<>(); public HttpBindServlet() { } @Override public void init(ServletConfig servletConfig) throws ServletException { super.init(servletConfig); boshManager = HttpBindManager.getInstance(); sessionManager = boshManager.getSessionManager(); sessionManager.start(); } @Override public void destroy() { super.destroy(); sessionManager.stop(); } @Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // add CORS headers for all HTTP responses (errors, etc.) if (boshManager.isCORSEnabled()) { if (boshManager.isAllOriginsAllowed()) { // Set the Access-Control-Allow-Origin header to * to allow all Origin to do the CORS response.setHeader("Access-Control-Allow-Origin", HttpBindManager.HTTP_BIND_CORS_ALLOW_ORIGIN_DEFAULT); } else { // Get the Origin header from the request and check if it is in the allowed Origin Map. // If it is allowed write it back to the Access-Control-Allow-Origin header of the respond. final String origin = request.getHeader("Origin"); if (boshManager.isThisOriginAllowed(origin)) { response.setHeader("Access-Control-Allow-Origin", origin); } } response.setHeader("Access-Control-Allow-Methods", HttpBindManager.HTTP_BIND_CORS_ALLOW_METHODS_DEFAULT); response.setHeader("Access-Control-Allow-Headers", HttpBindManager.HTTP_BIND_CORS_ALLOW_HEADERS_DEFAULT); response.setHeader("Access-Control-Max-Age", HttpBindManager.HTTP_BIND_CORS_MAX_AGE_DEFAULT); } super.service(request, response); } @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { final AsyncContext context = request.startAsync(); boolean isScriptSyntaxEnabled = boshManager.isScriptSyntaxEnabled(); if(!isScriptSyntaxEnabled) { sendLegacyError(context, BoshBindingError.itemNotFound); return; } String queryString = request.getQueryString(); if (queryString == null || "".equals(queryString)) { sendLegacyError(context, BoshBindingError.badRequest); return; } else if ("isBoshAvailable".equals(queryString)) { response.setStatus(HttpServletResponse.SC_OK); context.complete(); return; } queryString = URLDecoder.decode(queryString, "UTF-8"); processContent(context, queryString); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { final AsyncContext context = request.startAsync(); // Asynchronously reads the POSTed input, then triggers #processContent. try { request.getInputStream().setReadListener(new ReadListenerImpl(context)); } catch (IllegalStateException e) { Log.warn("Error when setting read listener", e); context.complete(); } } protected void processContent(AsyncContext context, String content) throws IOException { final String remoteAddress = getRemoteAddress(context); // Parse document from the content. Document document; try { document = getPacketReader().read(new StringReader(content), "UTF-8"); } catch (Exception ex) { Log.warn("Error parsing request data from [" + remoteAddress + "]", ex); sendLegacyError(context, BoshBindingError.badRequest); return; } if (document == null) { Log.info("The result of parsing request data from [" + remoteAddress + "] was a null-object."); sendLegacyError(context, BoshBindingError.badRequest); return; } final Element node = document.getRootElement(); if (node == null || !"body".equals(node.getName())) { Log.info("Root element 'body' is missing from parsed request data from [" + remoteAddress + "]"); sendLegacyError(context, BoshBindingError.badRequest); return; } final long rid = getLongAttribute(node.attributeValue("rid"), -1); if (rid <= 0) { Log.info("Root element 'body' does not contain a valid RID attribute value in parsed request data from [" + remoteAddress + "]"); sendLegacyError(context, BoshBindingError.badRequest, "Body-element is missing a RID (Request ID) value, or the provided value is a non-positive integer."); return; } // Process the parsed document. final String sid = node.attributeValue("sid"); if (sid == null) { // When there's no Session ID, this should be a request to create a new session. If there's additional content, // something is wrong. if (node.elements().size() > 0) { // invalid session request; missing sid Log.info("Root element 'body' does not contain a SID attribute value in parsed request data from [" + remoteAddress + "]"); sendLegacyError(context, BoshBindingError.badRequest); return; } // We have a new session createNewSession(context, node); } else { // When there exists a Session ID, new data for an existing session is being provided. handleSessionRequest(sid, context, node); } } protected void createNewSession(AsyncContext context, Element rootNode) throws IOException { final long rid = getLongAttribute(rootNode.attributeValue("rid"), -1); try { final X509Certificate[] certificates = (X509Certificate[]) context.getRequest().getAttribute("javax.servlet.request.X509Certificate"); final HttpConnection connection = new HttpConnection(rid, context.getRequest().isSecure(), certificates, context); final InetAddress address = InetAddress.getByName(context.getRequest().getRemoteAddr()); connection.setSession(sessionManager.createSession(address, rootNode, connection)); if (JiveGlobals.getBooleanProperty("log.httpbind.enabled", false)) { Log.info(new Date() + ": HTTP RECV(" + connection.getSession().getStreamID().getID() + "): " + rootNode.asXML()); } } catch (UnauthorizedException | HttpBindException e) { // Server wasn't initialized yet. sendLegacyError(context, BoshBindingError.internalServerError, "Server has not finished initialization." ); } } private void handleSessionRequest(String sid, AsyncContext context, Element rootNode) throws IOException { if (JiveGlobals.getBooleanProperty("log.httpbind.enabled", false)) { Log.info(new Date() + ": HTTP RECV(" + sid + "): " + rootNode.asXML()); } HttpSession session = sessionManager.getSession(sid); if (session == null) { if (Log.isDebugEnabled()) { Log.debug("Client provided invalid session: " + sid + ". [" + context.getRequest().getRemoteAddr() + "]"); } sendLegacyError(context, BoshBindingError.itemNotFound, "Invalid SID value."); return; } final long rid = getLongAttribute(rootNode.attributeValue("rid"), -1); synchronized (session) { try { session.forwardRequest(rid, context.getRequest().isSecure(), rootNode, context); } catch (HttpBindException e) { sendError(session, context, e.getBindingError()); } catch (HttpConnectionClosedException nc) { Log.error("Error sending packet to client.", nc); context.complete(); } } } private XMPPPacketReader getPacketReader() { // Reader is associated with a new XMPPPacketReader XMPPPacketReader reader = localReader.get(); if (reader == null) { reader = new XMPPPacketReader(); reader.setXPPFactory(factory); localReader.set(reader); } return reader; } public static void respond(HttpSession session, AsyncContext context, String content, boolean async) throws IOException { final HttpServletResponse response = ((HttpServletResponse) context.getResponse()); final HttpServletRequest request = ((HttpServletRequest) context.getRequest()); response.setStatus(HttpServletResponse.SC_OK); response.setContentType("GET".equals(request.getMethod()) ? "text/javascript" : "text/xml"); response.setCharacterEncoding("UTF-8"); if ("GET".equals(request.getMethod())) { if (JiveGlobals.getBooleanProperty("xmpp.httpbind.client.no-cache.enabled", true)) { // Prevent caching of responses response.addHeader("Cache-Control", "no-store"); response.addHeader("Cache-Control", "no-cache"); response.addHeader("Pragma", "no-cache"); } content = "_BOSH_(\"" + StringEscapeUtils.escapeJavaScript(content) + "\")"; } if (JiveGlobals.getBooleanProperty("log.httpbind.enabled", false)) { System.out.println(new Date() + ": HTTP SENT(" + session.getStreamID().getID() + "): " + content); } final byte[] byteContent = content.getBytes(StandardCharsets.UTF_8); // BOSH communication should not use Chunked encoding. // This is prevented by explicitly setting the Content-Length header. response.setContentLength(byteContent.length); if (async) { response.getOutputStream().setWriteListener(new WriteListenerImpl(context, byteContent)); } else { context.getResponse().getOutputStream().write(byteContent); context.getResponse().getOutputStream().flush(); context.complete(); } } private void sendError(HttpSession session, AsyncContext context, BoshBindingError bindingError) throws IOException { if (JiveGlobals.getBooleanProperty("log.httpbind.enabled", false)) { System.out.println(new Date() + ": HTTP ERR(" + session.getStreamID().getID() + "): " + bindingError.getErrorType().getType() + ", " + bindingError.getCondition() + "."); } try { if ((session.getMajorVersion() == 1 && session.getMinorVersion() >= 6) || session.getMajorVersion() > 1) { final String errorBody = createErrorBody(bindingError.getErrorType().getType(), bindingError.getCondition()); respond(session, context, errorBody, true); } else { sendLegacyError(context, bindingError); } } finally { if (bindingError.getErrorType() == BoshBindingError.Type.terminate) { session.close(); } } } protected static void sendLegacyError(AsyncContext context, BoshBindingError error, String message) throws IOException { final HttpServletResponse response = (HttpServletResponse) context.getResponse(); if (message == null || message.trim().length() == 0) { response.sendError(error.getLegacyErrorCode()); } else { response.sendError(error.getLegacyErrorCode(), message); } context.complete(); } protected static void sendLegacyError(AsyncContext context, BoshBindingError error) throws IOException { sendLegacyError(context, error, null); } protected static String createErrorBody(String type, String condition) { final Element body = DocumentHelper.createElement( QName.get( "body", "http://jabber.org/protocol/httpbind" ) ); body.addAttribute("type", type); body.addAttribute("condition", condition); return body.asXML(); } protected static long getLongAttribute(String value, long defaultValue) { if (value == null || "".equals(value)) { return defaultValue; } try { return Long.valueOf(value); } catch (Exception ex) { return defaultValue; } } protected static int getIntAttribute(String value, int defaultValue) { if (value == null || "".equals(value)) { return defaultValue; } try { return Integer.valueOf(value); } catch (Exception ex) { return defaultValue; } } protected static String getRemoteAddress(AsyncContext context) { String remoteAddress = null; if (context.getRequest() != null && context.getRequest().getRemoteAddr() != null) { remoteAddress = context.getRequest().getRemoteAddr(); } if (remoteAddress == null || remoteAddress.trim().length() == 0) { remoteAddress = "<UNKNOWN ADDRESS>"; } return remoteAddress; } class ReadListenerImpl implements ReadListener { private final AsyncContext context; private final ByteArrayOutputStream outStream = new ByteArrayOutputStream(1024); private final String remoteAddress; ReadListenerImpl(AsyncContext context) { this.context = context; this.remoteAddress = getRemoteAddress(context); } @Override public void onDataAvailable() throws IOException { Log.trace("Data is available to be read from [" + remoteAddress + "]"); final ServletInputStream inputStream = context.getRequest().getInputStream(); byte b[] = new byte[1024]; int length; while (inputStream.isReady() && (length = inputStream.read(b)) != -1) { outStream.write(b, 0, length); } } @Override public void onAllDataRead() throws IOException { Log.trace("All data has been read from [" + remoteAddress + "]"); processContent(context, outStream.toString(StandardCharsets.UTF_8.name())); } @Override public void onError(Throwable throwable) { Log.warn("Error reading request data from [" + remoteAddress + "]", throwable); try { sendLegacyError(context, BoshBindingError.badRequest); } catch (IOException ex) { Log.debug("Error while sending an error to ["+remoteAddress +"] in response to an earlier data-read failure.", ex); } } } private static class WriteListenerImpl implements WriteListener { private final AsyncContext context; private final byte[] data; private final String remoteAddress; private volatile boolean written; public WriteListenerImpl(AsyncContext context, byte[] data) { this.context = context; this.data = data; this.remoteAddress = getRemoteAddress(context); } @Override public void onWritePossible() throws IOException { // This method may be invoked multiple times and by different threads, e.g. when writing large byte arrays. Log.trace("Data can be written to [" + remoteAddress + "]"); ServletOutputStream servletOutputStream = context.getResponse().getOutputStream(); while (servletOutputStream.isReady()) { // Make sure a write/complete operation is only done, if no other write is pending, i.e. if isReady() == true // Otherwise WritePendingException is thrown. if (!written) { written = true; servletOutputStream.write(data); // After this write isReady() may return false, indicating the write is not finished. // In this case onWritePossible() is invoked again as soon as the isReady() == true again, // in which case we would only complete the request. } else { context.complete(); } } } @Override public void onError(Throwable throwable) { Log.warn("Error writing response data to [" + remoteAddress + "]", throwable); context.complete(); } } }