SaslServerCallbackHandlerTest.java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.apache.zookeeper.server.auth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import ch.qos.logback.classic.Level;
import java.util.Collections;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.NameCallback;
import javax.security.sasl.AuthorizeCallback;
import org.apache.zookeeper.test.LoggerTestTool;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link SaslServerCallbackHandler} authorization handling.
*
* <p>These reproduce the client-port SASL authorization-ID (authzid) impersonation
* issue and lock in the fix. The handler runs the same code path a real DIGEST-MD5
* or GSSAPI negotiation drives via {@link AuthorizeCallback}; the SASL server
* mechanism later returns {@code AuthorizeCallback.getAuthorizedID()} as the
* connection identity, so asserting on it is equivalent to asserting on the
* identity ZooKeeperServer.processSasl would adopt.
*
* <p>No server, KDC, or network is required.
*/
public class SaslServerCallbackHandlerTest {
private static LoggerTestTool loggerTestTool;
@BeforeAll
public static void setupBeforeClass() {
loggerTestTool = new LoggerTestTool(SaslServerCallbackHandler.class, Level.INFO);
}
@AfterAll
public static void tearDownAfterClass() throws Exception {
loggerTestTool.close();
}
@BeforeEach
public void resetLog() {
// discard output from earlier tests so each test sees only its own log lines
loggerTestTool.getOutputStream().reset();
}
private static SaslServerCallbackHandler newHandler() {
return new SaslServerCallbackHandler(Collections.emptyMap());
}
private static AuthorizeCallback authorize(String authnId, String authzId) throws Exception {
SaslServerCallbackHandler handler = newHandler();
AuthorizeCallback ac = new AuthorizeCallback(authnId, authzId);
handler.handle(new Callback[]{ac});
return ac;
}
/**
* The core exploit: a validly authenticated but low-privilege client requests
* authzid "super". Before the fix, canonicalization of the cross-realm principal
* threw NoMatchingRule, the exception was swallowed, authorized stayed true, and
* getAuthorizedID() returned the client-chosen "super". The fix must deny it.
*/
@Test
public void authzidSuperMustBeRejectedWhenItDiffersFromAuthenticationId() throws Exception {
AuthorizeCallback ac = authorize("bob@CROSSREALM", "super");
assertFalse(ac.isAuthorized(),
"client must not be authorized to assume a different identity ('super')");
assertNull(ac.getAuthorizedID(),
"no authorized identity may be granted when authorization is denied");
}
/**
* Any authzid different from the authenticated identity is impersonation and
* must be denied, not just the literal "super".
*/
@Test
public void anyMismatchedAuthzidMustBeRejected() throws Exception {
AuthorizeCallback ac = authorize("mallory@ATTACK.REALM", "realadmin@PROD.REALM");
assertFalse(ac.isAuthorized());
assertNull(ac.getAuthorizedID());
}
/**
* Legitimate case: the client did not request a distinct authzid, so the SASL
* layer defaults authorizationID to authenticationID. A simple (realm-less) name
* canonicalizes to itself and must be authorized.
*/
@Test
public void matchingSimpleNameIsAuthorized() throws Exception {
AuthorizeCallback ac = authorize("alice", "alice");
assertTrue(ac.isAuthorized(), "a client authorizing as itself must be allowed");
assertEquals("alice", ac.getAuthorizedID());
}
/**
* Regression / compatibility guard for cross-realm users. When authzid equals the
* authenticated principal, the client is authorized as itself even though
* canonicalization fails under the default auth_to_local rules. The critical
* property is that the adopted identity is the client's OWN principal and can
* never be an attacker-chosen value.
*
* <p>Assumes the test host's default Kerberos realm is not literally "CROSSREALM"
* (true on any normal build/CI host), so getShortName() throws NoMatchingRule and
* the identity falls back to the authenticated principal.
*/
@Test
public void matchingCrossRealmPrincipalIsAuthorizedAsItselfNotSpoofable() throws Exception {
AuthorizeCallback ac = authorize("bob@CROSSREALM", "bob@CROSSREALM");
assertTrue(ac.isAuthorized());
assertEquals("bob@CROSSREALM", ac.getAuthorizedID(),
"cross-realm client must be authorized as its own principal");
assertNotEquals("super", ac.getAuthorizedID());
}
// ---------------------------------------------------------------------
// Log sanitization (CWE-117). authenticationID and authorizationID are
// client-controlled SASL token fields; control characters must be stripped
// before logging so a client cannot forge adjacent log lines.
// ---------------------------------------------------------------------
private static final String FORGED_MARKER = "FORGED";
/**
* Captures everything the handler logged during this test, then asserts that
* no physical log line begins with the injected marker, i.e. no line was forged.
*/
private static String capturedLogAssertingNoForgedLines() {
String output = loggerTestTool.getOutputStream().toString();
for (String line : output.split("\\R")) {
assertFalse(line.startsWith(FORGED_MARKER),
"client-controlled input started a new log line (log forgery): " + line);
}
return output;
}
private static String lineContaining(String output, String needle) {
for (String line : output.split("\\R")) {
if (line.contains(needle)) {
return line;
}
}
return null;
}
@Test
public void denialLogSanitizesControlCharactersAndLogsAtWarn() throws Exception {
String authnId = "mallory\n" + FORGED_MARKER + " AUTHN\r\t LINE";
String authzId = "super\n" + FORGED_MARKER + " AUTHZ\r\t LINE";
AuthorizeCallback ac = authorize(authnId, authzId);
assertFalse(ac.isAuthorized());
String output = capturedLogAssertingNoForgedLines();
String line = lineContaining(output, "Client attempted to authorize as a different identity");
assertNotNull(line, "denial was not logged");
// CR, LF and TAB are removed; the remaining text stays on the single log line
assertTrue(line.contains("authenticationID=mallory" + FORGED_MARKER + " AUTHN LINE"),
"authenticationID not logged sanitized on one line: " + line);
assertTrue(line.contains("requested authorizationID=super" + FORGED_MARKER + " AUTHZ LINE"),
"authorizationID not logged sanitized on one line: " + line);
// denial must be WARN, not ERROR
assertTrue(line.contains(" WARN "), "denial should be logged at WARN: " + line);
assertFalse(line.contains(" ERROR "), "denial must not be logged at ERROR: " + line);
}
@Test
public void successLogSanitizesControlCharacters() throws Exception {
// authcid == authzid, so this takes the success path; the realm-less name
// canonicalizes to itself without throwing
String id = "alice\n" + FORGED_MARKER + " SUCCESS\r\t LINE";
AuthorizeCallback ac = authorize(id, id);
assertTrue(ac.isAuthorized());
String output = capturedLogAssertingNoForgedLines();
String line = lineContaining(output, "Successfully authenticated client");
assertNotNull(line, "successful authorization was not logged");
assertTrue(line.contains("authorizationID=alice" + FORGED_MARKER + " SUCCESS LINE"),
"authorizationID not logged sanitized on one line: " + line);
// the canonicalized identity is logged separately; for a realm-less name it
// is the client-supplied string verbatim, so it must be sanitized as well
String setLine = lineContaining(output, "Setting authorizedID");
assertNotNull(setLine, "canonicalized authorizedID was not logged");
assertTrue(setLine.contains("Setting authorizedID: alice" + FORGED_MARKER + " SUCCESS LINE"),
"canonicalized authorizedID not logged sanitized on one line: " + setLine);
// only the log output is sanitized; the identity itself is not altered
assertEquals(id, ac.getAuthorizedID());
}
@Test
public void unknownDigestUserLogSanitizesControlCharacters() throws Exception {
// this runs before authentication, so any client can reach it
String userName = "ghost\n" + FORGED_MARKER + " UNKNOWN\r\t USER";
NameCallback nc = new NameCallback("username: ", userName);
newHandler().handle(new Callback[]{nc});
assertNull(nc.getName(), "unknown user must not be accepted");
String output = capturedLogAssertingNoForgedLines();
String line = lineContaining(output, "not found in list of DIGEST-MD5 authenticateable users");
assertNotNull(line, "unknown user was not logged");
assertTrue(line.contains("User 'ghost" + FORGED_MARKER + " UNKNOWN USER'"),
"username not logged sanitized on one line: " + line);
}
@Test
public void denialWithNullAuthenticationIdIsLoggedWithoutError() throws Exception {
AuthorizeCallback ac = authorize(null, "super");
assertFalse(ac.isAuthorized());
assertNull(ac.getAuthorizedID());
String line = lineContaining(loggerTestTool.getOutputStream().toString(),
"Client attempted to authorize as a different identity");
assertNotNull(line, "denial with null authenticationID was not logged");
assertTrue(line.contains("authenticationID=null"), line);
}
}