Skip to content

Commit

Permalink
HBASE-17115 Define UI admins via an ACL
Browse files Browse the repository at this point in the history
The Hadoop AccessControlList allows us to specify admins of the webUI
via a list of users and/or groups. Admins of the WebUI can mutate the
system, potentially seeing sensitive data or modifying the system.

hbase.security.authentication.spnego.admin.users is a comma-separated
list of users who are admins.
hbase.security.authentication.spnego.admin.groups is a comma-separated
list of groups whose membership are admins. Either of these
configuration properties may also contain an asterisk (*) which denotes
"anything" (any user or group).

To maintain previous semantics, the UI defaults to accepting any user as an
admin. Previously, when a user was denied from some endpoint that was
designated for admins, they received an HTTP/401. In this case, it is
more correct to return HTTP/403 as they were correctly authenticated,
but they were disallowed from fetching the given resource.

The test is based off of work by Nihal Jain in HBASE-20472.

Co-authored-by: Nihal Jain <nihaljain.cs@gmail.com>
  • Loading branch information
joshelser and NihalJain committed Dec 12, 2019
1 parent 85a0819 commit e64d118
Show file tree
Hide file tree
Showing 9 changed files with 392 additions and 46 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ public class HttpServer implements FilterContainer {
"signature.secret.file";
public static final String HTTP_AUTHENTICATION_SIGNATURE_SECRET_FILE_KEY =
HTTP_AUTHENTICATION_PREFIX + HTTP_AUTHENTICATION_SIGNATURE_SECRET_FILE_SUFFIX;
public static final String HTTP_SPNEGO_AUTHENTICATION_ADMIN_USERS_KEY =
HTTP_SPNEGO_AUTHENTICATION_PREFIX + "admin.users";
public static final String HTTP_SPNEGO_AUTHENTICATION_ADMIN_GROUPS_KEY =
HTTP_SPNEGO_AUTHENTICATION_PREFIX + "admin.groups";

// The ServletContext attribute where the daemon Configuration
// gets stored.
Expand Down Expand Up @@ -220,7 +224,7 @@ public static class Builder {
private String bindAddress;
/**
* @see #addEndpoint(URI)
* @deprecated Since 0.99.0. Use builder pattern vai {@link #addEndpoint(URI)} instead.
* @deprecated Since 0.99.0. Use builder pattern via {@link #addEndpoint(URI)} instead.
*/
@Deprecated
private int port = -1;
Expand Down Expand Up @@ -593,6 +597,8 @@ private void initializeWebServer(String name, String hostName,

webServer.setHandler(handlerCollection);

webAppContext.setAttribute(ADMINS_ACL, adminsAcl);

addDefaultApps(contexts, appDir, conf);

addGlobalFilter("safety", QuotingInputFilter.class.getName(), null);
Expand Down Expand Up @@ -712,23 +718,24 @@ private void setContextAttributes(ServletContextHandler context, Configuration c
* Add default servlets.
*/
protected void addDefaultServlets(ContextHandlerCollection contexts) throws IOException {

// set up default servlets
addServlet("stacks", "/stacks", StackServlet.class);
addServlet("logLevel", "/logLevel", LogLevel.Servlet.class);
addPrivilegedServlet("stacks", "/stacks", StackServlet.class);
addPrivilegedServlet("logLevel", "/logLevel", LogLevel.Servlet.class);
// Hadoop3 has moved completely to metrics2, and dropped support for Metrics v1's
// MetricsServlet (see HADOOP-12504). We'll using reflection to load if against hadoop2.
// Remove when we drop support for hbase on hadoop2.x.
try {
Class clz = Class.forName("org.apache.hadoop.metrics.MetricsServlet");
addServlet("metrics", "/metrics", clz);
Class<?> clz = Class.forName("org.apache.hadoop.metrics.MetricsServlet");
addUnprivilegedServlet("metrics", "/metrics", clz.asSubclass(HttpServlet.class));
} catch (Exception e) {
// do nothing
}
addServlet("jmx", "/jmx", JMXJsonServlet.class);
addServlet("conf", "/conf", ConfServlet.class);
addUnprivilegedServlet("jmx", "/jmx", JMXJsonServlet.class);
addUnprivilegedServlet("conf", "/conf", ConfServlet.class);
final String asyncProfilerHome = ProfileServlet.getAsyncProfilerHome();
if (asyncProfilerHome != null && !asyncProfilerHome.trim().isEmpty()) {
addServlet("prof", "/prof", ProfileServlet.class);
addPrivilegedServlet("prof", "/prof", ProfileServlet.class);
Path tmpDir = Paths.get(ProfileServlet.OUTPUT_DIR);
if (Files.notExists(tmpDir)) {
Files.createDirectories(tmpDir);
Expand All @@ -738,7 +745,7 @@ protected void addDefaultServlets(ContextHandlerCollection contexts) throws IOEx
genCtx.setResourceBase(tmpDir.toAbsolutePath().toString());
genCtx.setDisplayName("prof-output");
} else {
addServlet("prof", "/prof", ProfileServlet.DisabledServlet.class);
addUnprivilegedServlet("prof", "/prof", ProfileServlet.DisabledServlet.class);
LOG.info("ASYNC_PROFILER_HOME environment variable and async.profiler.home system property " +
"not specified. Disabling /prof endpoint.");
}
Expand Down Expand Up @@ -770,30 +777,28 @@ public void addJerseyResourcePackage(final String packageName,
}

/**
* Add a servlet in the server.
* Adds a servlet in the server that any user can access.
* @param name The name of the servlet (can be passed as null)
* @param pathSpec The path spec for the servlet
* @param clazz The servlet class
*/
public void addServlet(String name, String pathSpec,
public void addUnprivilegedServlet(String name, String pathSpec,
Class<? extends HttpServlet> clazz) {
addInternalServlet(name, pathSpec, clazz, false);
addFilterPathMapping(pathSpec, webAppContext);
addServletWithAuth(name, pathSpec, clazz, false);
}

/**
* Add an internal servlet in the server.
* Note: This method is to be used for adding servlets that facilitate
* internal communication and not for user facing functionality. For
* servlets added using this method, filters are not enabled.
*
* @param name The name of the servlet (can be passed as null)
* @param pathSpec The path spec for the servlet
* @param clazz The servlet class
* Adds a servlet in the server that only administrators can access.
*/
public void addInternalServlet(String name, String pathSpec,
public void addPrivilegedServlet(String name, String pathSpec,
Class<? extends HttpServlet> clazz) {
addInternalServlet(name, pathSpec, clazz, false);
addServletWithAuth(name, pathSpec, clazz, true);
}

void addServletWithAuth(String name, String pathSpec,
Class<? extends HttpServlet> clazz, boolean requireAuth) {
addInternalServlet(name, pathSpec, clazz, requireAuth);
addFilterPathMapping(pathSpec, webAppContext);
}

/**
Expand All @@ -809,23 +814,13 @@ public void addInternalServlet(String name, String pathSpec,
* @param clazz The servlet class
* @param requireAuth Require Kerberos authenticate to access servlet
*/
public void addInternalServlet(String name, String pathSpec,
void addInternalServlet(String name, String pathSpec,
Class<? extends HttpServlet> clazz, boolean requireAuth) {
ServletHolder holder = new ServletHolder(clazz);
if (name != null) {
holder.setName(name);
}
webAppContext.addServlet(holder, pathSpec);

if(requireAuth && UserGroupInformation.isSecurityEnabled()) {
LOG.info("Adding Kerberos (SPNEGO) filter to " + name);
ServletHandler handler = webAppContext.getServletHandler();
FilterMapping fmap = new FilterMapping();
fmap.setPathSpec(pathSpec);
fmap.setFilterName(SPNEGO_FILTER);
fmap.setDispatches(FilterMapping.ALL);
handler.addFilterMapping(fmap);
}
}

@Override
Expand Down Expand Up @@ -1255,7 +1250,7 @@ public static boolean hasAdministratorAccess(

if (servletContext.getAttribute(ADMINS_ACL) != null &&
!userHasAdministratorAccess(servletContext, remoteUser)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User "
response.sendError(HttpServletResponse.SC_FORBIDDEN, "User "
+ remoteUser + " is unauthorized to access this page.");
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@
import java.io.IOException;
import java.net.URI;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.CommonConfigurationKeys;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.security.authorize.AccessControlList;
import org.apache.yetus.audience.InterfaceAudience;

/**
Expand Down Expand Up @@ -81,13 +85,31 @@ public InfoServer(String name, String bindAddress, int port, boolean findPort,
.setSignatureSecretFileKey(
HttpServer.HTTP_AUTHENTICATION_SIGNATURE_SECRET_FILE_KEY)
.setSecurityEnabled(true);

// Set an admin ACL on sensitive webUI endpoints
AccessControlList acl = buildAdminAcl(c);
builder.setACL(acl);
}
this.httpServer = builder.build();
}

/**
* Builds an ACL that will restrict the users who can issue commands to endpoints on the UI
* which are meant only for administrators.
*/
AccessControlList buildAdminAcl(Configuration conf) {
final String userGroups = conf.get(HttpServer.HTTP_SPNEGO_AUTHENTICATION_ADMIN_USERS_KEY, null);
final String adminGroups = conf.get(HttpServer.HTTP_SPNEGO_AUTHENTICATION_ADMIN_GROUPS_KEY, null);
if (userGroups == null && adminGroups == null) {
// Backwards compatibility - if the user doesn't have anything set, allow all users in.
return new AccessControlList("*", null);
}
return new AccessControlList(userGroups, adminGroups);
}

public void addServlet(String name, String pathSpec,
Class<? extends HttpServlet> clazz) {
this.httpServer.addServlet(name, pathSpec, clazz);
this.httpServer.addUnprivilegedServlet(name, pathSpec, clazz);
}

public void setAttribute(String name, Object value) {
Expand All @@ -110,4 +132,24 @@ public int getPort() {
public void stop() throws Exception {
this.httpServer.stop();
}


/**
* Returns true if and only if UI authentication (spnego) is enabled, UI authorization is enabled,
* and the requesting user is defined as an administrator. If the UI is set to readonly, this
* method always returns false.
*/
public static boolean canUserModifyUI(
HttpServletRequest req, ServletContext ctx, Configuration conf) {
if (conf.getBoolean("hbase.master.ui.readonly", false)) {
return false;
}
String remoteUser = req.getRemoteUser();
if ("kerberos".equals(conf.get(HttpServer.HTTP_UI_AUTHENTICATION)) &&
conf.getBoolean(CommonConfigurationKeys.HADOOP_SECURITY_AUTHORIZATION, false) &&
remoteUser != null) {
return HttpServer.userHasAdministratorAccess(ctx, remoteUser);
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,14 @@ public void doGet(HttpServletRequest request, HttpServletResponse response)
response)) {
return;
}
// Disallow modification of the LogLevel if explicitly set to readonly
Configuration conf = (Configuration) getServletContext().getAttribute(
HttpServer.CONF_CONTEXT_ATTRIBUTE);
if (conf.getBoolean("hbase.master.ui.readonly", false)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Modification of HBase via"
+ " the UI is disallowed in configuration.");
return;
}
response.setContentType("text/html");
PrintWriter out;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,10 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) thro
Configuration conf = new Configuration();
conf.setInt(HttpServer.HTTP_MAX_THREADS, MAX_THREADS);
server = createTestServer(conf);
server.addServlet("echo", "/echo", EchoServlet.class);
server.addServlet("echomap", "/echomap", EchoMapServlet.class);
server.addServlet("htmlcontent", "/htmlcontent", HtmlContentServlet.class);
server.addServlet("longheader", "/longheader", LongHeaderServlet.class);
server.addUnprivilegedServlet("echo", "/echo", EchoServlet.class);
server.addUnprivilegedServlet("echomap", "/echomap", EchoMapServlet.class);
server.addUnprivilegedServlet("htmlcontent", "/htmlcontent", HtmlContentServlet.class);
server.addUnprivilegedServlet("longheader", "/longheader", LongHeaderServlet.class);
server.addJerseyResourcePackage(
JerseyResource.class.getPackage().getName(), "/jersey/*");
server.start();
Expand Down Expand Up @@ -582,7 +582,7 @@ public void testXFrameHeaderSameOrigin() throws Exception {
.addEndpoint(new URI("http://localhost:0"))
.setFindPort(true).setConf(conf).build();
myServer.setAttribute(HttpServer.CONF_CONTEXT_ATTRIBUTE, conf);
myServer.addServlet("echo", "/echo", EchoServlet.class);
myServer.addUnprivilegedServlet("echo", "/echo", EchoServlet.class);
myServer.start();

String serverURL = "http://"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public static void setup() throws Exception {
.trustStore(sslConf.get("ssl.server.truststore.location"),
HBaseConfiguration.getPassword(sslConf, "ssl.server.truststore.password", null),
sslConf.get("ssl.server.truststore.type", "jks")).build();
server.addServlet("echo", "/echo", TestHttpServer.EchoServlet.class);
server.addUnprivilegedServlet("echo", "/echo", TestHttpServer.EchoServlet.class);
server.start();
baseUrl = new URL("https://"
+ NetUtils.getHostPortString(server.getConnectorAddress(0)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public static void setupServer() throws Exception {
Configuration conf = buildSpnegoConfiguration(serverPrincipal, infoServerKeytab);

server = createTestServerWithSecurity(conf);
server.addServlet("echo", "/echo", EchoServlet.class);
server.addUnprivilegedServlet("echo", "/echo", EchoServlet.class);
server.addJerseyResourcePackage(JerseyResource.class.getPackage().getName(), "/jersey/*");
server.start();
baseUrl = getServerURL(server);
Expand Down Expand Up @@ -252,7 +252,7 @@ public void testMissingConfigurationThrowsException() throws Exception {
// Intentionally skip keytab and principal

HttpServer customServer = createTestServerWithSecurity(conf);
customServer.addServlet("echo", "/echo", EchoServlet.class);
customServer.addUnprivilegedServlet("echo", "/echo", EchoServlet.class);
customServer.addJerseyResourcePackage(JerseyResource.class.getPackage().getName(), "/jersey/*");
customServer.start();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import="org.apache.hadoop.conf.Configuration"
import="org.apache.hadoop.hbase.client.Admin"
import="org.apache.hadoop.hbase.client.SnapshotDescription"
import="org.apache.hadoop.hbase.http.InfoServer"
import="org.apache.hadoop.hbase.master.HMaster"
import="org.apache.hadoop.hbase.snapshot.SnapshotInfo"
import="org.apache.hadoop.util.StringUtils"
Expand All @@ -30,7 +31,7 @@
<%
HMaster master = (HMaster)getServletContext().getAttribute(HMaster.MASTER);
Configuration conf = master.getConfiguration();
boolean readOnly = conf.getBoolean("hbase.master.ui.readonly", false);
boolean readOnly = !InfoServer.canUserModifyUI(request, getServletContext(), conf);
String snapshotName = request.getParameter("name");
SnapshotDescription snapshot = null;
SnapshotInfo.SnapshotStats stats = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import=" java.util.concurrent.TimeUnit"
import="org.apache.commons.lang3.StringEscapeUtils"
import="org.apache.hadoop.conf.Configuration"
import="org.apache.hadoop.fs.CommonConfigurationKeys"
import="org.apache.hadoop.hbase.HTableDescriptor"
import="org.apache.hadoop.hbase.HColumnDescriptor"
import="org.apache.hadoop.hbase.HConstants"
Expand All @@ -46,6 +47,7 @@
import="org.apache.hadoop.hbase.client.RegionLocator"
import="org.apache.hadoop.hbase.client.RegionReplicaUtil"
import="org.apache.hadoop.hbase.client.Table"
import="org.apache.hadoop.hbase.http.InfoServer"
import="org.apache.hadoop.hbase.master.HMaster"
import="org.apache.hadoop.hbase.master.assignment.RegionStates"
import="org.apache.hadoop.hbase.master.RegionState"
Expand Down Expand Up @@ -89,7 +91,7 @@
String tableHeader;
boolean withReplica = false;
boolean showFragmentation = conf.getBoolean("hbase.master.ui.fragmentation.enabled", false);
boolean readOnly = conf.getBoolean("hbase.master.ui.readonly", false);
boolean readOnly = !InfoServer.canUserModifyUI(request, getServletContext(), conf);
int numMetaReplicas = conf.getInt(HConstants.META_REPLICAS_NUM,
HConstants.DEFAULT_META_REPLICA_NUM);
Map<String, Integer> frags = null;
Expand Down
Loading

0 comments on commit e64d118

Please sign in to comment.