
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.Request;
import org.eclipse.jetty.client.Response;
import org.eclipse.jetty.ee10.proxy.AsyncMiddleManServlet;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpHeaderValue;
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 AsyncMiddleManServlet {
/**
* 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_RESPONSE_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_RESPONSE_HEADER_VALUE = Map.of("set-cookie", COOKIE_JEE);
/**
* Managed plain page error.
*/
private static final Map MANAGED_PLAIN_ERROR = Map.of(
HttpServletResponse.SC_NOT_FOUND, HttpServletResponse.SC_NOT_FOUND,
HttpServletResponse.SC_METHOD_NOT_ALLOWED, HttpServletResponse.SC_NOT_FOUND,
HttpServletResponse.SC_FORBIDDEN, HttpServletResponse.SC_FORBIDDEN,
HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
HttpServletResponse.SC_BAD_REQUEST, HttpServletResponse.SC_BAD_REQUEST,
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()
/**
* `Access-Control-Allow-Origin` value of response from this route.
*/
private String corsOrigin; // NOSONAR - Initialized once, from #init()
/**
* `Vary` value of response from this route.
*/
private String corsVary; // 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) {
// Forward API user, if defined.
final var apiUser = getIdData(clientRequest, apiUserParameter, apiUserHeader);
final var apiKey = getIdData(clientRequest, apiKeyParameter, apiKeyHeader);
if (StringUtils.isNotBlank(apiUser) && StringUtils.isNotBlank(apiKey)) {
// When there is an API user,
addHeader(proxyRequest, HEADER_USER, apiUser);
addHeader(proxyRequest, apiKeyHeader, apiKey);
}
} else {
// Stateful authenticated user
addHeader(proxyRequest, HEADER_USER, clientRequest.getUserPrincipal().getName());
addHeader(proxyRequest, "SM_SESSIONID", clientRequest.getSession(false).getId());
}
// Forward all cookies but JSESSIONID.
final var cookies = StringUtils.trimToNull(Arrays.stream(
Objects.requireNonNullElse(clientRequest.getHeader(HEADER_COOKIE), "").split(";"))
.map(String::trim).filter(cookie -> !cookie.split("=")[0].trim().equals(COOKIE_JEE))
.collect(Collectors.joining("; ")));
if (cookies != null) {
addHeader(proxyRequest, HEADER_COOKIE, cookies);
}
}
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 onProxyResponseSuccess(final HttpServletRequest clientRequest, final HttpServletResponse proxyResponse, final Response serverResponse) {
final var plainStatus = needPlainPageErrorStatus(clientRequest, serverResponse.getStatus());
if (plainStatus == 0) {
super.onProxyResponseSuccess(clientRequest, proxyResponse, serverResponse);
} else {
log.debug("Full HTML non zero mapped status to {}", plainStatus);
final var asyncContext = clientRequest.getAsyncContext();
try {
// Standard 404/... page, abort the original response
final var dispatcher = getServletContext().getRequestDispatcher("/" + plainStatus + ".html");
dispatcher.forward(getRoot(clientRequest), proxyResponse);
} catch (final Exception e) {
log.error("onProxyResponseSuccess failed", e);
} finally {
asyncContext.complete();
}
}
}
@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());
if (clientRequest.isAsyncStarted()) {
clientRequest.getAsyncContext().complete();
}
}
}
/**
* Check, and return the lower value of given parameter.
*
* @param parameter the expected "init" parameter.
* @return the lower value of given parameter.
* @throws UnavailableException when required parameter is not defined.
*/
protected String getRequiredInitParameter(final String parameter)
throws UnavailableException {
final var value = StringUtils.trimToNull(getServletConfig().getInitParameter(parameter));
if (value == null) {
throw new UnavailableException("Init parameter '" + parameter + "' is required.");
}
return value.toLowerCase(Locale.ENGLISH);
}
@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"));
// Read API configuration
this.proxyTo = getRequiredInitParameter("proxyTo");
this.apiUserParameter = getRequiredInitParameter("apiUserParameter");
this.apiUserHeader = getRequiredInitParameter("apiUserHeader");
this.apiKeyParameter = getRequiredInitParameter("apiKeyParameter");
this.apiKeyHeader = getRequiredInitParameter("apiKeyHeader");
this.apiKeyCleanPattern = newCleanParameter(apiKeyParameter);
this.apiUserCleanPattern = newCleanParameter(apiUserParameter);
this.corsOrigin = getRequiredInitParameter("cors-origin");
this.corsVary = getRequiredInitParameter("cors-vary");
_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_RESPONSE_HEADERS, lowerCase) || IGNORE_RESPONSE_HEADER_VALUE.containsKey(lowerCase)
&& headerValue.startsWith(IGNORE_RESPONSE_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");
}
/**
* 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 clientRequest, HttpServletResponse proxyResponse, Response serverResponse) {
if (needPlainPageErrorStatus(clientRequest, serverResponse.getStatus()) == 0) {
super.onServerResponseHeaders(clientRequest, proxyResponse, serverResponse);
} else {
// Standard 404 page
proxyResponse.addHeader("Content-Type", "text/html");
}
proxyResponse.addHeader("Access-Control-Allow-Origin", corsOrigin);
proxyResponse.addHeader("Vary", corsVary);
}
/**
* 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);
ignoreRequestHeader.add(HEADER_USER);
ignoreRequestHeader.add(apiKeyHeader);
ignoreRequestHeader.add(apiUserHeader);
return ignoreRequestHeader;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy