package io.cattle.platform.iaas.api.request.handler;
import io.cattle.platform.api.auth.Policy;
import io.cattle.platform.api.utils.ApiUtils;
import io.cattle.platform.archaius.util.ArchaiusUtil;
import io.cattle.platform.core.constants.AccountConstants;
import io.cattle.platform.core.constants.CommonStatesConstants;
import io.cattle.platform.core.constants.ProjectConstants;
import io.cattle.platform.core.model.MachineDriver;
import io.cattle.platform.iaas.api.servlet.filter.ProxyPreFilter;
import io.cattle.platform.object.ObjectManager;
import io.cattle.platform.object.meta.ObjectMetaDataManager;
import io.cattle.platform.object.util.DataAccessor;
import io.cattle.platform.util.type.Named;
import io.github.ibuildthecloud.gdapi.condition.Condition;
import io.github.ibuildthecloud.gdapi.condition.ConditionType;
import io.github.ibuildthecloud.gdapi.context.ApiContext;
import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException;
import io.github.ibuildthecloud.gdapi.request.ApiRequest;
import io.github.ibuildthecloud.gdapi.request.handler.AbstractResponseGenerator;
import io.github.ibuildthecloud.gdapi.util.ResponseCodes;
import java.io.IOException;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.ProxySelector;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import javax.net.ssl.SSLContext;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.fluent.Executor;
import org.apache.http.client.fluent.Request;
import org.apache.http.client.fluent.Response;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.LayeredConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLInitializationException;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.InputStreamEntity;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.impl.conn.SystemDefaultRoutePlanner;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.HTTP;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.netflix.config.DynamicBooleanProperty;
import com.netflix.config.DynamicStringListProperty;
public class GenericWhitelistedProxy extends AbstractResponseGenerator implements Named {
public static final String ALLOWED_HOST = GenericWhitelistedProxy.class.getName() + "allowed.host";
public static final String SET_HOST_CURRENT_HOST = GenericWhitelistedProxy.class.getName() + "set_host_current_host";
public static final String REDIRECTS = GenericWhitelistedProxy.class.getName() + "redirects";
public static final String PARSE_FORM = GenericWhitelistedProxy.class.getName() + "parseform";
public static final String REQUIRE_ROLE = GenericWhitelistedProxy.class.getName() + "roles";
public static final String METHOD_ROLE = GenericWhitelistedProxy.class.getName() + "methodRoles";
private static final DynamicBooleanProperty ALLOW_PROXY = ArchaiusUtil.getBoolean("api.proxy.allow");
private static final DynamicStringListProperty PROXY_WHITELIST = ArchaiusUtil.getList("api.proxy.whitelist");
private static final String FORWARD_PROTO = "X-Forwarded-Proto";
private static final String API_AUTH = "X-API-AUTH-HEADER";
private static final Set<String> BAD_HEADERS = new HashSet<>(Arrays.asList(HTTP.TARGET_HOST.toLowerCase(), "authorization",
HTTP.TRANSFER_ENCODING.toLowerCase(), HTTP.CONTENT_LEN.toLowerCase(), API_AUTH.toLowerCase()));
private static final String AUTH_ACCESS_TOKEN = "access_token";
private static final Executor EXECUTOR;
private static final Executor NO_REDIRECT_EXECUTOR;
private List<String> allowedPaths;
private boolean noAuthProxy = false;
private String name;
static {
LayeredConnectionSocketFactory ssl = null;
try {
ssl = SSLConnectionSocketFactory.getSystemSocketFactory();
} catch (final SSLInitializationException ex) {
final SSLContext sslcontext;
try {
sslcontext = SSLContext.getInstance(SSLConnectionSocketFactory.TLS);
sslcontext.init(null, null, null);
ssl = new SSLConnectionSocketFactory(sslcontext);
} catch (final SecurityException ignore) {
} catch (final KeyManagementException ignore) {
} catch (final NoSuchAlgorithmException ignore) {
}
}
final Registry<ConnectionSocketFactory> sfr = RegistryBuilder.<ConnectionSocketFactory>create()
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", ssl != null ? ssl : SSLConnectionSocketFactory.getSocketFactory())
.build();
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(sfr);
cm.setDefaultMaxPerRoute(100);
cm.setMaxTotal(200);
HttpClient httpClient = HttpClientBuilder.create()
.setConnectionManager(cm)
.setRoutePlanner(new SystemDefaultRoutePlanner(ProxySelector.getDefault()))
.build();
HttpClient noRdhttpClient = HttpClientBuilder.create()
.setConnectionManager(cm)
.setDefaultRequestConfig(RequestConfig.copy(RequestConfig.DEFAULT).setRedirectsEnabled(false).build())
.setRoutePlanner(new SystemDefaultRoutePlanner(ProxySelector.getDefault()))
.build();
EXECUTOR = Executor.newInstance(httpClient);
NO_REDIRECT_EXECUTOR = Executor.newInstance(noRdhttpClient);
}
Cache<String, Boolean> allowCache = CacheBuilder.newBuilder()
.expireAfterAccess(24, TimeUnit.HOURS)
.maximumSize(100)
.build();
@Inject
ObjectManager objectManager;
public GenericWhitelistedProxy(String name) {
super();
this.name = name;
}
protected boolean isAllowed(HttpServletRequest servletRequest, String host) {
boolean allowHost = Boolean.TRUE.equals(servletRequest.getAttribute(ALLOWED_HOST));
if (allowHost) {
return true;
}
if (isWhitelisted(host)) {
return true;
}
Boolean value = allowCache.getIfPresent(host);
if (value == null) {
List<MachineDriver> drivers = objectManager.find(MachineDriver.class, ObjectMetaDataManager.STATE_FIELD,
new Condition(ConditionType.NE, CommonStatesConstants.PURGED));
for (MachineDriver driver : drivers) {
String url = DataAccessor.fieldString(driver, "uiUrl");
if (url != null) {
try {
URL parsed = new URL(url);
allowCache.put(parsed.getHost(), true);
} catch (MalformedURLException e) {
}
}
}
}
value = allowCache.getIfPresent(host);
return value == null ? false : value;
}
@SuppressWarnings("unchecked")
@Override
protected void generate(final ApiRequest request) throws IOException {
if (!ALLOW_PROXY.get())
return;
if (!"proxy".equals(request.getType())) {
return;
}
HttpServletRequest servletRequest = request.getServletContext().getRequest();
boolean setCurrentHost = Boolean.TRUE.equals(servletRequest.getAttribute(SET_HOST_CURRENT_HOST));
boolean redirects = !Boolean.FALSE.equals(servletRequest.getAttribute(REDIRECTS));
boolean parseForm = Boolean.TRUE.equals(servletRequest.getAttribute(PARSE_FORM));
Set<String> requiredRoles = (Set<String>) servletRequest.getAttribute(REQUIRE_ROLE);
Set<String> methodRoles = (Set<String>) servletRequest.getAttribute(METHOD_ROLE);
String redirect = servletRequest.getRequestURI();
redirect = StringUtils.substringAfter(redirect, "/proxy/");
if (redirect.startsWith("http")) {
/* We don't allow // so http:// will be http:/ and same with https. So we fixup here */
redirect = redirect.replaceFirst("^http:/([^/])", "http://$1");
redirect = redirect.replaceFirst("^https:/([^/])", "https://$1");
}
if (!StringUtils.startsWith(redirect, "http")) {
redirect = "https://" + redirect;
}
URIBuilder uri;
try {
uri = new URIBuilder(redirect);
} catch (URISyntaxException e) {
throw new ClientVisibleException(ResponseCodes.BAD_REQUEST, "InvalidRedirect", "The redirect is invalid/empty", null);
}
String queryInfo = servletRequest.getQueryString();
if (queryInfo != null) {
uri.setCustomQuery(URLDecoder.decode(queryInfo, "UTF-8"));
}
try {
redirect = uri.build().toString();
} catch (URISyntaxException e) {
throw new ClientVisibleException(ResponseCodes.BAD_REQUEST, "InvalidRedirect", "The redirect is invalid", null);
}
String host = uri.getPort() > 0 ? String.format("%s:%s", uri.getHost(), uri.getPort()) : uri.getHost();
if (!isAllowed(servletRequest, host)) {
throw new ClientVisibleException(ResponseCodes.FORBIDDEN);
}
boolean matchesAllowedPath = false;
if(isNoAuthProxy()) {
if (uri.getPath() != null && allowedPaths != null) {
for(String path : allowedPaths) {
if(uri.getPath().startsWith(path)) {
matchesAllowedPath = true;
}
}
}
if(!matchesAllowedPath){
return;
}
}
Request temp;
String method = servletRequest.getMethod();
if (servletRequest instanceof ProxyPreFilter.Request) {
method = ((ProxyPreFilter.Request)servletRequest).getRealMethod();
}
switch (method) {
case "POST":
temp = Request.Post(redirect);
break;
case "GET":
temp = Request.Get(redirect);
break;
case "PUT":
temp = Request.Put(redirect);
break;
case "DELETE":
temp = Request.Delete(redirect);
break;
case "HEAD":
temp = Request.Head(redirect);
break;
default:
throw new ClientVisibleException(ResponseCodes.BAD_REQUEST, "Invalid method", "The method " + method + " is not supported", null);
}
// This isn't always available. As is the case for proxy protocol
String xForwardedProto = servletRequest.getHeader(FORWARD_PROTO);
if (xForwardedProto == null && request.getRequestUrl() != null && request.getRequestUrl().startsWith("https")) {
temp.addHeader(FORWARD_PROTO, "https");
}
boolean isFormContent = false;
for (String headerName : (List<String>)Collections.list(servletRequest.getHeaderNames())) {
if (BAD_HEADERS.contains(headerName.toLowerCase())) {
continue;
}
for (String headerVal : (List<String>)Collections.list(servletRequest.getHeaders(headerName))) {
if(parseForm && HTTP.CONTENT_TYPE.equalsIgnoreCase(headerName.toLowerCase())){
if(ContentType.APPLICATION_FORM_URLENCODED.getMimeType().equalsIgnoreCase(headerVal.toLowerCase())) {
isFormContent = true;
}
}
temp.addHeader(headerName, StringUtils.removeStart(headerVal, "rancher:"));
}
}
String authHeader = servletRequest.getHeader(API_AUTH);
if (authHeader != null) {
temp.setHeader("Authorization", authHeader);
} else {
if (uri.getPath() != null && uri.getPath().startsWith("/v1-auth/")) {
//set the auth service access token
String externalAccessToken = (String) request.getAttribute(AUTH_ACCESS_TOKEN);
if(!StringUtils.isBlank(externalAccessToken)) {
String bearerToken = " Bearer "+ externalAccessToken;
temp.setHeader("Authorization", bearerToken);
}
}
}
if (setCurrentHost) {
temp.setHeader("Host", request.getResponseUrlBase().replaceFirst("^https?://", ""));
} else {
temp.setHeader("Host", host);
}
String projectHeader = "";
Set<String> roles = null;
String roleString = "";
Policy policy = ApiUtils.getPolicy();
if (policy != null) {
projectHeader = ApiContext.getContext().getIdFormatter()
.formatId(AccountConstants.TYPE, Long.toString(policy.getAccountId()))
.toString();
roles = policy.getRoles();
roleString = StringUtils.join(roles, ",");
}
temp.setHeader(ProjectConstants.PROJECT_HEADER, projectHeader);
temp.setHeader(ProjectConstants.ROLES_HEADER, roleString);
authorize(method, requiredRoles, roles, methodRoles);
if ("POST".equals(method) || "PUT".equals(method)) {
if(isFormContent) {
Map<String, String[]> map = servletRequest.getParameterMap();
List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>();
for (String name : map.keySet()) {
String[] array = map.get(name);
for (int i = 0; i < array.length; i++) {
nameValuePairs.add(new BasicNameValuePair(name, array[i]));
}
}
temp.bodyForm(nameValuePairs);
} else {
int length = servletRequest.getContentLength();
InputStreamEntity entity = new InputStreamEntity(request.getInputStream(), length);
temp.body(entity);
}
}
Response res = redirects ? EXECUTOR.execute(temp) : NO_REDIRECT_EXECUTOR.execute(temp);
res.handleResponse(new ResponseHandler<Object>() {
@Override
public Object handleResponse(HttpResponse response) throws ClientProtocolException, IOException {
int statusCode = response.getStatusLine().getStatusCode();
request.setResponseObject(new Object());
request.setResponseCode(statusCode);
request.commit();
OutputStream writer = request.getServletContext().getResponse().getOutputStream();
Header[] headers = response.getAllHeaders();
for (int i = 0; i < headers.length; i++) {
request.getServletContext().getResponse().setHeader(headers[i].getName(), headers[i].getValue());
}
HttpEntity entity = response.getEntity();
if (entity != null) {
entity.writeTo(writer);
}
return null;
}
});
}
private static void authorize(String method, Set<String> requiredRoles, Set<String> roles, Set<String> methods) {
if (methods != null && methods.size() > 0) {
if (!methods.contains(method)) {
return;
}
}
if (requiredRoles == null || requiredRoles.isEmpty()) {
return;
}
boolean ok = false;
if (roles != null) {
for (String role : roles) {
if (requiredRoles.contains(role)) {
ok = true;
break;
}
}
}
if (!ok) {
throw new ClientVisibleException(ResponseCodes.FORBIDDEN);
}
}
private boolean isWhitelisted(String host) {
for (String valid : PROXY_WHITELIST.get()) {
if (valid.equals(host)) {
return true;
}
if (valid.startsWith("*") && host.endsWith(valid.substring(1))) {
return true;
}
}
return false;
}
public List<String> getAllowedPaths() {
return allowedPaths;
}
public void setAllowedPaths(List<String> allowedPaths) {
this.allowedPaths = allowedPaths;
}
public boolean isNoAuthProxy() {
return noAuthProxy;
}
public void setNoAuthProxy(String noAuthProxy) {
this.noAuthProxy = Boolean.parseBoolean(noAuthProxy);
}
public void setName(String name) {
this.name = name;
}
@Override
public String getName() {
return name;
}
}