All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.ligoj.bootstrap.http.proxy.BackendProxyServlet Maven / Gradle / Ivy

/*
 * Licensed under MIT (https://github.com/ligoj/ligoj/blob/master/LICENSE)
 */
package org.ligoj.bootstrap.http.proxy;

import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.UnavailableException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Response;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpHeaderValue;
import org.eclipse.jetty.proxy.ProxyServlet;
import org.eclipse.jetty.util.Callback;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * Reverse proxy for business server.
 */
@Slf4j
public class BackendProxyServlet extends ProxyServlet {

	/**
	 * Header forwarded to back-end and containing the principal username or declared API user that will be checked on
	 * the other side.
	 */
	private static final String HEADER_USER = "SM_UniversalID".toUpperCase();

	private static final String COOKIE_JEE = "JSessionID".toUpperCase();

	private static final String HEADER_COOKIE = "cookie";

	/**
	 * Headers will not be forwarded from the back-end.
	 */
	private static final String[] IGNORE_HEADERS = {"expires", "x-content-type-options", "server",
			"visited", "date", "x-frame-options", "x-xss-protection", "pragma", "cache-control"};

	/**
	 * Header will be ignored when the value starts with the
	 */
	private static final Map IGNORE_HEADER_VALUE = new HashMap<>();

	static {
		IGNORE_HEADER_VALUE.put("set-cookie", COOKIE_JEE);
	}

	/**
	 * Managed plain page error.
	 */
	private static final Map MANAGED_PLAIN_ERROR = new HashMap<>();

	// Initialize the mappings
	static {
		MANAGED_PLAIN_ERROR.put(HttpServletResponse.SC_NOT_FOUND, HttpServletResponse.SC_NOT_FOUND);
		MANAGED_PLAIN_ERROR.put(HttpServletResponse.SC_METHOD_NOT_ALLOWED, HttpServletResponse.SC_NOT_FOUND);
		MANAGED_PLAIN_ERROR.put(HttpServletResponse.SC_FORBIDDEN, HttpServletResponse.SC_FORBIDDEN);
		MANAGED_PLAIN_ERROR.put(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
				HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
		MANAGED_PLAIN_ERROR.put(HttpServletResponse.SC_BAD_REQUEST, HttpServletResponse.SC_BAD_REQUEST);
		MANAGED_PLAIN_ERROR.put(HttpServletResponse.SC_SERVICE_UNAVAILABLE, HttpServletResponse.SC_SERVICE_UNAVAILABLE);
	}

	/**
	 * SID
	 */
	private static final long serialVersionUID = -3387356144222298075L;

	/**
	 * Target backend's endpoint.
	 */
	private String proxyTo; // NOSONAR - Initialized once, from #init()

	/**
	 * Prefix activating this backend
	 */
	private String prefix; // NOSONAR - Initialized once, from #init()

	/**
	 * Name of query parameter containing the API key
	 */
	private String apiKeyParameter; // NOSONAR - Initialized once, from #init()

	/**
	 * Name of query parameter containing the API username.
	 */
	private String apiUserParameter; // NOSONAR - Initialized once, from #init()

	/**
	 * Name of header containing the API username.
	 */
	private String apiUserHeader; // NOSONAR - Initialized once, from #init()

	/**
	 * Name of header containing the API key.
	 */
	private String apiKeyHeader; // NOSONAR - Initialized once, from #init()

	/**
	 * Pattern capturing the API key to filter.
	 */
	private Pattern apiKeyCleanPattern; // NOSONAR - Initialized once, from #init()

	/**
	 * Pattern capturing the API user to filter.
	 */
	private Pattern apiUserCleanPattern; // NOSONAR - Initialized once, from #init()

	private void addHeader(final Request proxyRequest, final String name, final String value) {
		proxyRequest.headers(headers -> headers.add(name, value));
	}

	@Override
	protected void addProxyHeaders(final HttpServletRequest clientRequest, final Request proxyRequest) {
		super.addProxyHeaders(clientRequest, proxyRequest);

		if (clientRequest.getUserPrincipal() != null) {
			// Stateful authenticated user
			addHeader(proxyRequest, HEADER_USER, clientRequest.getUserPrincipal().getName());
			addHeader(proxyRequest, "SM_SESSIONID", clientRequest.getSession(false).getId());
		} else {
			// Forward API user, if defined.
			final var apiUser = getIdData(clientRequest, apiUserParameter, apiUserHeader);
			final var apiKey = getIdData(clientRequest, apiKeyParameter, apiKeyHeader);

			if (apiUser != null && apiKey != null) {
				// When there is an API user,
				addHeader(proxyRequest, HEADER_USER, apiUser);
				addHeader(proxyRequest, apiKeyHeader, apiKey);
			}
		}

		// Forward all cookies but JSESSIONID.
		final var cookies = clientRequest.getHeader(HEADER_COOKIE);
		if (cookies != null) {
			addHeader(proxyRequest, HEADER_COOKIE,
					StringUtils.trimToNull(Arrays.stream(cookies.split(";"))
							.filter(cookie -> !cookie.split("=")[0].trim().equals(COOKIE_JEE)).map(String::trim)
							.collect(Collectors.joining("; "))));
		}
	}

	private String getIdData(final HttpServletRequest req, final String parameter, final String header) {
		return ObjectUtils.defaultIfNull(StringUtils.trimToNull(req.getParameter(parameter)),
				StringUtils.trimToNull(req.getHeader(header)));
	}

	@Override
	protected void onProxyResponseFailure(final HttpServletRequest clientRequest,
			final HttpServletResponse proxyResponse, final Response serverResponse, final Throwable failure) {
		_log.warn("Proxy error", failure);

		if (proxyResponse.isCommitted()) {
			// Parent behavior
			super.onProxyResponseFailure(clientRequest, proxyResponse, serverResponse, failure);
		} else {
			proxyResponse.resetBuffer();
			if (failure instanceof TimeoutException) {
				proxyResponse.setStatus(HttpServletResponse.SC_GATEWAY_TIMEOUT);
			} else {

				// Unavailable business server as JSON response
				proxyResponse.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
				proxyResponse.setContentType("application/json");
				try {
					proxyResponse.getOutputStream()
							.write("{\"code\":\"business-down\"}".getBytes(StandardCharsets.UTF_8));
				} catch (final IOException ioe) {
					_log.warn("Broken proxy stream", ioe);
				}
			}
			proxyResponse.setHeader(HttpHeader.CONNECTION.asString(), HttpHeaderValue.CLOSE.asString());
			final var asyncContext = clientRequest.getAsyncContext();
			asyncContext.complete();
		}
	}

	/**
	 * Check, and return the lower value of given parameter.
	 *
	 * @param parameter    the expected "init" parameter.
	 * @param defaultValue the optional default value. May be null.
	 * @return the lower value of given parameter.
	 * @throws UnavailableException when required parameter is not defined.
	 */
	protected String getRequiredInitParameter(final String parameter, final String defaultValue)
			throws UnavailableException {
		final var value = ObjectUtils
				.defaultIfNull(StringUtils.trimToNull(getServletConfig().getInitParameter(parameter)), defaultValue);
		if (value == null) {
			throw new UnavailableException("Init parameter '" + parameter + "' is required.");
		}
		return value;
	}

	/**
	 * Check, and return the lower value of given parameter.
	 *
	 * @param parameter the expected "init" parameter.
	 * @return the lower value of given parameter.
	 */
	private String getRequiredSystemInitParameter(final String parameter) throws UnavailableException {
		final var parameterValue = StringUtils.trimToNull(getRequiredInitParameter(parameter, null));
		final var value = StringUtils.trimToNull(System.getProperty(parameterValue));
		if (value == null) {
			throw new UnavailableException("Init parameter '" + parameter
					+ "' is defined, but points to a non defined system property '" + parameterValue + "'");
		}
		return value;
	}

	@Override
	public void init() throws ServletException {
		super.init();
		final var config = getServletConfig();

		// Read "proxy to" end point URL from "Servlet" configuration and system
		this.prefix = getServletContext().getContextPath() + StringUtils.trimToEmpty(config.getInitParameter("prefix"));
		this.proxyTo = getRequiredSystemInitParameter("proxyToKey");

		// Read API configuration
		this.apiUserParameter = getRequiredInitParameter("apiUserParameter", "api-user");
		this.apiUserHeader = getRequiredInitParameter("apiUserHeader", "x-api-user");
		this.apiKeyParameter = getRequiredInitParameter("apiKeyParameter", "api-key");
		this.apiKeyHeader = getRequiredInitParameter("apiKeyHeader", "x-api-key");
		this.apiKeyCleanPattern = newCleanParameter(apiKeyParameter);
		this.apiUserCleanPattern = newCleanParameter(apiUserParameter);

		_log.info("Proxying {} --> {}", this.prefix, this.proxyTo);
	}

	/**
	 * New pattern detecting a parameter value inside a query.
	 */
	private Pattern newCleanParameter(final String parameter) {
		return Pattern.compile("^((.*&))?" + parameter + "=[a-zA-Z0-9\\-]+(&(.*))?$");
	}

	@Override
	protected String rewriteTarget(final HttpServletRequest request) {
		var path = request.getRequestURI();
		if (!path.startsWith(this.prefix)) {
			// No match
			return null;
		}

		// Append the query string
		path = newPathWithQueryString(request);

		final var proxyUrl = this.proxyTo + path.substring(this.prefix.length());
		try {
			final var rewrittenURI = new URI(proxyUrl).normalize();
			if (validateDestination(rewrittenURI.getHost(), rewrittenURI.getPort())) {
				// It's a valid and up target
				return rewrittenURI.toString();
			}
		} catch (final URISyntaxException x) {
			// Invalid query
			log.info("Invalid URI {} built from path {}", proxyUrl, request.getRequestURI(), x);
		}
		return null;
	}

	/**
	 * Build a complete URI with original query string, but without API key.
	 */
	private String newPathWithQueryString(final HttpServletRequest request) {
		final var path = request.getRequestURI();
		var query = request.getQueryString();

		if (query == null) {
			// No query, return only the path
			return path;
		}
		if (request.getParameter(apiKeyParameter) == null) {
			// No API Key, return an untouched query string
			return path + "?" + query;
		}

		// Don't forward API key as parameter, only as a header
		query = StringUtils
				.trimToNull(removeApiParameter(removeApiParameter(query, apiKeyCleanPattern), apiUserCleanPattern));
		if (query == null) {
			// The new query, without API key is empty
			return path;
		}

		// Return a query without API key
		return path + "?" + query;
	}

	/**
	 * Remove API key parameter from the given query.
	 */
	private String removeApiParameter(final String query, final Pattern pattern) {
		final var apiMatcher = pattern.matcher(query);
		if (apiMatcher.find()) {
			// API Token is defined as a query parameter, we can remove it
			return ObjectUtils.defaultIfNull(apiMatcher.group(2), "")
					+ ObjectUtils.defaultIfNull(apiMatcher.group(4), "");
		}
		return query;
	}

	@Override
	protected String filterServerResponseHeader(final HttpServletRequest clientRequest, final Response serverResponse,
			final String headerName, final String headerValue) {
		// Filter some headers
		final var lowerCase = StringUtils.lowerCase(headerName);
		return ArrayUtils.contains(IGNORE_HEADERS, lowerCase) || IGNORE_HEADER_VALUE.containsKey(lowerCase)
				&& headerValue.startsWith(IGNORE_HEADER_VALUE.get(lowerCase)) ? null : headerValue;
	}

	/**
	 * Indicates the request was in API or not.
	 *
	 * @param request The original request.
	 * @return true for API request.
	 */
	public static boolean isApiRequest(final HttpServletRequest request) {
		return "XMLHttpRequest".equalsIgnoreCase(StringUtils.trimToEmpty(request.getHeader("X-Requested-With")))
				|| !StringUtils.trimToEmpty(request.getHeader("User-Agent")).contains("Mozilla");
	}

	@Override
	protected void onResponseContent(final HttpServletRequest request, final HttpServletResponse response,
			final Response proxyResponse, final byte[] buffer, final int offset, final int length,
			final Callback callback) {
		final var plainStatus = needPlainPageErrorStatus(request, proxyResponse.getStatus());
		if (plainStatus == 0) {
			super.onResponseContent(request, response, proxyResponse, buffer, offset, length, callback);
		} else {
			try {
				// Standard 404/... page, abort the original response
				final var dispatcher = getServletContext().getRequestDispatcher("/" + plainStatus + ".html");
				dispatcher.forward(getRoot(request), response);
				callback.succeeded();
			} catch (final Exception e) {
				callback.failed(e);
			}
		}
	}

	/**
	 * Is it a 404 like error?
	 *
	 * @param status the current status.
	 * @return the nearest managed status.
	 */
	protected int getManagedPlainPageError(final int status) {
		return ObjectUtils.defaultIfNull(MANAGED_PLAIN_ERROR.get(status), 0);
	}

	/**
	 * Is it a non AJAX 404 like error? .
	 *
	 * @param request the current request.
	 * @param status  the current status.
	 * @return 0 or the status to display.
	 */
	protected int needPlainPageErrorStatus(final HttpServletRequest request, final int status) {
		final var plainStatus = getManagedPlainPageError(status);
		return plainStatus == 0 || isApiRequest(request) ? 0 : plainStatus;
	}

	@Override
	protected void onServerResponseHeaders(final HttpServletRequest request, final HttpServletResponse response,
			final Response proxyResponse) {
		if (needPlainPageErrorStatus(request, proxyResponse.getStatus()) == 0) {
			super.onServerResponseHeaders(request, response, proxyResponse);
		} else {
			// Standard 404 page
			response.addHeader("Content-Type", "text/html");
		}
		response.addHeader("Access-Control-Allow-Origin", "*");
	}

	/**
	 * Return root request.
	 *
	 * @param request the current request.
	 * @return the root (container) {@link ServletRequest}.
	 */
	protected ServletRequest getRoot(final ServletRequest request) {
		return request instanceof HttpServletRequestWrapper hReq ? getRoot(hReq.getRequest()) : request;
	}

	@Override
	protected Set findConnectionHeaders(final HttpServletRequest clientRequest) {
		final var ignoreRequestHeader = new HashSet<>(
				CollectionUtils.emptyIfNull(super.findConnectionHeaders(clientRequest)));

		// Drop cookie headers forward from FRONT to BACK by default, only filtered ones will be added
		ignoreRequestHeader.add(HEADER_COOKIE);
		return ignoreRequestHeader;
	}
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy