de-coupling of security functions from jetty

This commit is contained in:
Michael Peter Christen committed 2026-07-11 20:52:05 +02:00
1 parent dd0d4ee962
commit edeb6162c4
10 files changed
+560 -181

No files matched your search

@@ -139,8 +139,10 @@ public class RequestHeader extends HeaderFramework implements HttpServletRequest
}
public boolean accessFromLocalhost() {
// authorization for localhost, only if flag is set to grant localhost access as admin
final String clientIP = this.getRemoteAddr();
// authorization for localhost, only if flag is set to grant localhost access as admin.
// This is an access-control decision, therefore the true socket peer must be used:
// the client-controlled (and trivially spoofable) X-Real-IP header must NOT be honored here.
final String clientIP = this.getRemoteSocketAddr();
if ( !Domains.isLocalhost(clientIP) ) {
return false;
}
@@ -352,6 +354,9 @@ public class RequestHeader extends HeaderFramework implements HttpServletRequest
return null;
}
// Invariant relied on for authorization: the role/principal come solely from the servlet
// container's authentication. Do not change these to derive from headers/attributes (which
// are client-controllable), else authorization based on them becomes spoofable.
@Override
public boolean isUserInRole(String role) {
if (_request != null) {
@@ -696,6 +701,35 @@ public class RequestHeader extends HeaderFramework implements HttpServletRequest
return super.get(HeaderFramework.CONNECTION_PROP_CLIENTIP);
}
/**
* The IP address of the host that opened the TCP connection (the real socket peer).
* <p>
* In contrast to {@link #getRemoteAddr()} and {@link #client(ServletRequest)} this
* <b>never</b> honors the client-controlled X-Real-IP request header. It must therefore
* be used for all authentication and access-control decisions: X-Real-IP is trivially
* spoofable by a direct client and must only be trusted for peer routing behind a
* trusted reverse proxy, not for authentication.
*
* @return the socket peer IP address
*/
public String getRemoteSocketAddr() {
if (this._request != null) {
return this._request.getRemoteAddr();
}
return super.get(HeaderFramework.CONNECTION_PROP_CLIENTIP);
}
/**
* Resolve the client IP for peer routing and logging. This honors the X-Real-IP
* request header (set e.g. by an nginx reverse proxy via
* "proxy_set_header X-Real-IP $remote_addr;").
* <p>
* <b>Do not use this for authentication or access control</b> - the header is
* client-controlled and spoofable. Use {@link #getRemoteSocketAddr()} for that.
*
* @param request the servlet request
* @return the routing client IP address
*/
public static String client(final ServletRequest request) {
String clientHost = request.getRemoteAddr();
if (request instanceof HttpServletRequest) {
+139
View File
@@ -0,0 +1,139 @@
//
// AdminSecurity
// Copyright 2011 by Florian Richter
// First released 16.04.2011 at https://yacy.net
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with this program in the file lgpl21.txt
// If not, see <http://www.gnu.org/licenses/>.
//
package net.yacy.http;
import net.yacy.cora.order.Base64Order;
import net.yacy.cora.order.Digest;
import net.yacy.cora.protocol.Domains;
/**
* Decision logic of YaCy's built-in administrator account security: which
* paths are protected, when localhost access is granted without login and
* verification of the supported admin password hash formats.
*
* All methods are pure functions on their parameters, free of servlet
* container (Jetty) and Switchboard dependencies: the container specific
* classes YaCySecurityHandler, YaCyLoginService and YaCyDigestCredential are
* thin adapters delegating here (extracted from them to ease servlet
* container migration).
*/
public final class AdminSecurity {
private AdminSecurity() {
}
/**
* Decide whether a path may only be accessed with admin rights.
* Pages suffixed with "_p" are always considered protected. When all pages
* are protected, paths used for peer-to-peer or cluster communication stay
* public (e.g. /yacy/hello.html for p2p presence, /solr/select for remote
* Solr searches), except in private robinson mode.
*
* @param pathInContext the request path
* @param adminForAllPages configuration: all pages need admin rights
* @param privateRobinsonMode true when this peer is in non-public robinson mode
* @param publicSearchpage configuration: the search page is public
* @return true when the path needs admin rights
*/
public static boolean isProtectedPath(final String pathInContext, final boolean adminForAllPages,
final boolean privateRobinsonMode, final boolean publicSearchpage) {
boolean protectedPage = adminForAllPages && (privateRobinsonMode ||
!(pathInContext.startsWith("/yacy/") || pathInContext.startsWith("/solr/")));
protectedPage = protectedPage || (pathInContext.indexOf("_p.") > 0);
if (!protectedPage && !publicSearchpage) {
protectedPage = pathInContext.startsWith("/solr/") || pathInContext.startsWith("/gsa/");
}
return protectedPage;
}
/**
* @param remoteip ip address of the client. This must be the true socket peer address:
* callers must not pass an X-Real-IP / X-Forwarded-For derived address here,
* as those headers are client-controlled and spoofable.
* @param refererHost host part of the Referer request header (null when absent or unparseable)
* @return true when the request comes from localhost and is not referred from a remote page
*/
public static boolean isLocalhostAccess(final String remoteip, final String refererHost) {
return Domains.isLocalhost(remoteip)
&& (refererHost == null || refererHost.isEmpty() || Domains.isLocalhost(refererHost));
}
/**
* Lazy authentication for localhost access: accept Basic credentials that
* contain the configured admin password hash as password (only a user with
* read access to DATA can know that hash).
*
* @param authorizationHeader value of the Authorization request header (may be null)
* @param adminUser configured name of the admin user
* @param adminAccountBase64MD5 configured admin password hash
* @return true when the header contains the admin hash as Basic credential
*/
public static boolean checkLocalhostLazyAuth(final String authorizationHeader, final String adminUser,
final String adminAccountBase64MD5) {
// Basic credentials are short "Basic " + b64(user:pwd)
if (authorizationHeader != null && authorizationHeader.length() < 120 && authorizationHeader.startsWith("Basic ")) {
final String b64 = Base64Order.standardCoder.encodeString(adminUser + ":" + adminAccountBase64MD5);
return authorizationHeader.substring(6).equals(b64);
}
return false;
}
/**
* Verify a clear text password (BASIC auth) against the configured admin
* password hash. Two hash formats are supported:
* "MD5:" + MD5Hex(user:realm:password) and the Base64 based format
* MD5Hex(Base64(user:password)).
*
* For both formats the configured hash itself is also accepted as password
* when the request comes from localhost: this allows the scripts in bin/
* (based on bin/apicall.sh) to steer a peer without knowing the clear text
* password.
*
* @param foruser user name the credential was created for
* @param configHash the configured password hash
* @param realm the configured authentication realm (part of "MD5:" hashes)
* @param adminUser configured name of the admin user
* @param fromLocalhost true when the request being authenticated comes from localhost
* (determined from the request's true socket peer IP)
* @param pw the clear text password to check
* @return true when the password matches the configured hash
*/
public static boolean checkAdminPassword(final String foruser, final String configHash, final String realm,
final String adminUser, final boolean fromLocalhost, final String pw) {
if (!configHash.startsWith("MD5:")) { // B64MD5 admin hashes without realm
if (fromLocalhost && pw.equals(configHash)) return true;
return calcHash(foruser + ":" + pw).equals(configHash);
}
final boolean success = Digest.encodeMD5Hex(foruser + ":" + realm + ":" + pw).equals(configHash.substring(4));
if (!success && foruser.equals(adminUser) && fromLocalhost && pw.equals(configHash)) return true;
return success;
}
/**
* internal hash function for the Base64 based admin account hash format
*
* @param pw clear password
* @return hash string MD5Hex(Base64(pw))
*/
public static String calcHash(final String pw) {
return Digest.encodeMD5Hex(Base64Order.standardCoder.encodeString(pw));
}
}
@@ -0,0 +1,140 @@
//
// YaCyDigestCredential
// Copyright 2011 by Florian Richter
// First released 16.04.2011 at https://yacy.net
//
// $LastChangedDate$
// $LastChangedRevision$
// $LastChangedBy$
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with this program in the file lgpl21.txt
// If not, see <http://www.gnu.org/licenses/>.
//
package net.yacy.http;
import net.yacy.cora.protocol.Domains;
import net.yacy.search.Switchboard;
import net.yacy.search.SwitchboardConstants;
import org.eclipse.jetty.util.security.Credential;
/**
* implementation of YaCy's admin password as jetty Credential
* supporting BASIC and DIGEST authentication
* and using MD5 digested passwords/credentials. Following RFC recommendation (to use the realm in MD5 hash)
* expecting a MD5 hash in format MD5( username:realm:password ), realm configured in yacy.init adminRealm
* (a credential in format MD5( username:password ) is also accepted with BASIC auth)
*
* This is a thin adapter to the servlet container: the password verification
* logic is in the container neutral {@link AdminSecurity}.
*/
public class YaCyDigestCredential extends Credential {
private static final long serialVersionUID = -3527894085562480001L;
/**
* True socket peer IP of the request currently being authenticated on this thread.
* <p>
* Jetty's {@link Credential#check(Object)} API does not hand over the request, so the
* credential can not tell on its own whether a request comes from localhost. The
* {@link YaCySecurityHandler} therefore publishes the request's socket peer IP here for
* the duration of the request (set at the start of handling, cleared in a finally block).
* This replaces the former global "recent localhost access" timestamp, which was a
* process-wide value not bound to the request being checked.
*/
private static final ThreadLocal<String> REQUEST_CLIENT_IP = new ThreadLocal<String>();
/**
* Publish the socket peer IP of the request being authenticated on the current thread.
* Must be paired with {@link #clearRequestClientIP()} in a finally block.
* @param ip the true socket peer IP (never an X-Real-IP derived address)
*/
public static void setRequestClientIP(final String ip) {
REQUEST_CLIENT_IP.set(ip);
}
/**
* Remove the request client IP published for the current thread.
*/
public static void clearRequestClientIP() {
REQUEST_CLIENT_IP.remove();
}
/**
* @return true when the request currently authenticated on this thread comes from
* localhost. Fails closed (returns false) when no request IP was published.
*/
private static boolean isRequestFromLocalhost() {
final String ip = REQUEST_CLIENT_IP.get();
return ip != null && Domains.isLocalhost(ip);
}
private String hash; // remember password hash, either MD5(Base64(user:pwd)) or with encryption prefix "MD5:" + MD5(user:realm:pwd)
private String foruser; // remember the user as YaCy credential is username:pwd (not just pwd)
private Credential c;
@Override
public boolean check(Object credentials) {
if (credentials instanceof Credential) { // for DIGEST auth
if (this.c == null) {
/* credential may be null after switching from BASIC to DIGEST authentication without re-encoding the password */
return false;
}
Credential credential = (Credential) credentials;
return credential.check(this.c);
}
if (credentials instanceof String) { // for BASIC auth
final Switchboard sb = Switchboard.getSwitchboard();
// The "fromLocalhost" exception (see AdminSecurity.checkAdminPassword) lets a
// localhost caller submit the stored password hash itself as the password. This
// is what the bin/*.sh scripts do via bin/apicall.sh: they read the hash from the
// configuration file to steer a local peer, because the cleartext password is
// never stored anywhere.
//
// We must therefore know whether THIS request comes from localhost. Jetty's
// Credential.check() is not given the request, so YaCySecurityHandler publishes
// the request's true socket peer IP into REQUEST_CLIENT_IP for the duration of the
// request (see the detailed rationale on YaCySecurityHandler.handle()); we read it
// back here. Using the socket peer - not the spoofable X-Real-IP header - keeps
// this exception restricted to genuine local callers.
return AdminSecurity.checkAdminPassword(this.foruser, this.hash,
sb.getConfig(SwitchboardConstants.ADMIN_REALM, ""),
sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_USER_NAME, "admin"),
isRequestFromLocalhost(),
(String) credentials);
}
throw new UnsupportedOperationException();
}
/**
* create Credential object from config file hash
*
* @param configHash hash as in config file hash(adminuser:pwd)
* @return
*/
public static Credential getCredentialForAdmin(String username, String configHash) {
YaCyDigestCredential yc = new YaCyDigestCredential();
if (configHash.startsWith("MD5:")) {
yc.c = Credential.getCredential(configHash); // for DIGEST auth
}
yc.foruser = username;
yc.hash = configHash;
return yc;
}
}
@@ -1,128 +0,0 @@
//
// YaCyLegacyCredentials
// Copyright 2011 by Florian Richter
// First released 16.04.2011 at https://yacy.net
//
// $LastChangedDate$
// $LastChangedRevision$
// $LastChangedBy$
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with this program in the file lgpl21.txt
// If not, see <http://www.gnu.org/licenses/>.
//
package net.yacy.http;
import net.yacy.cora.order.Base64Order;
import net.yacy.cora.order.Digest;
import net.yacy.search.Switchboard;
import net.yacy.search.SwitchboardConstants;
import net.yacy.server.serverAccessTracker;
import org.eclipse.jetty.util.security.Credential;
/**
* implementation of YaCy's old admin password as jetty Credential
* supporting BASIC and DIGEST authentication
* and using MD5 encryptet passwords/credentials. Following RFC recommendation (to use the realm in MD5 hash)
* expecting a MD5 hash in format MD5( username:realm:password ), realm configured in yacy.init adminRealm
* (exception: old style credential MD5( username:password ) still accepted with BASIC auth)
*
*/
public class YaCyLegacyCredential extends Credential {
private static final long serialVersionUID = -3527894085562480001L;
private String hash; // remember password hash (for new style with prefix of used encryption supported "MD5:" )
private String foruser; // remember the user as YaCy credential is username:pwd (not just pwd)
private boolean isBase64enc; // remember hash encoding false = encodeMD5Hex(usr:pwd) ; true = encodeMD5Hex(Base64Order.standardCoder.encodeString(usr:pw))
private Credential c;
/**
* internal hash function for admin account
*
* @param pw clear password
* @return hash string
*/
public static String calcHash(String pw) { // old style hash
return Digest.encodeMD5Hex(Base64Order.standardCoder.encodeString(pw));
}
@Override
public boolean check(Object credentials) {
if (credentials instanceof Credential) { // for DIGEST auth
if (this.c == null) {
/* credential may be null after switching from BASIC to DIGEST authentication without re-encoding the password */
return false;
}
Credential credential = (Credential) credentials;
return credential.check(this.c);
}
if (credentials instanceof String) { // for BASIC auth
final String pw = (String) credentials;
if (isBase64enc) { // for old B64MD5 admin hashes
if (serverAccessTracker.timeSinceAccessFromLocalhost() < 100) {
// we allow localhost accesses also to submit the hash as password
// this is very important since that method is used by the scripts in bin/ which are based on bin/apicall.sh
// the cleartext password is not stored anywhere, but we must find a way to allow scripts to steer a peer.
// this is the exception that makes that possible.
// TODO: it should be better to check the actual access IP here, but that is not handed over to Credential classes :(
if ((pw).equals(this.hash)) return true;
}
// exception for admin use old style MD5hash (user:password)
return calcHash(foruser + ":" + pw).equals(this.hash); // for admin user
}
// normal users (and new admin pwd) for BASIC auth
if (hash.startsWith("MD5:") && hash != null) {
String realm = Switchboard.getSwitchboard().getConfig(SwitchboardConstants.ADMIN_REALM, "");
boolean success = Digest.encodeMD5Hex(foruser + ":" + realm + ":" + pw).equals(hash.substring(4));
// exception: allow the hash as pwd (used in bin/apicall.sh)
if (!success && foruser.equals(Switchboard.getSwitchboard().getConfig(SwitchboardConstants.ADMIN_ACCOUNT_USER_NAME, "admin"))) {
if (pw.equals(hash)) {
if (serverAccessTracker.timeSinceAccessFromLocalhost() < 100) {
return true;
}
}
}
return success;
}
return Digest.encodeMD5Hex(foruser + ":" + pw).equals(hash); // for old userdb hashes
}
throw new UnsupportedOperationException();
}
/**
* create Credential object from config file hash
*
* @param configHash hash as in config file hash(adminuser:pwd)
* @return
*/
public static Credential getCredentialForAdmin(String username, String configHash) {
YaCyLegacyCredential yc = new YaCyLegacyCredential();
if (configHash.startsWith("MD5:")) {
yc.isBase64enc = false;
yc.c = Credential.getCredential(configHash);
} else {
yc.isBase64enc = true;
}
yc.foruser = username;
yc.hash = configHash;
return yc;
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ public class YaCyLoginService extends HashLoginService implements LoginService {
// in YaCy the credential hash is composed of username:pwd so the username is needed to create valid credential
// not just the password (as usually in Jetty). As the accountname for the std. adminuser is not stored a useridentity
// is created for current user (and the pwd checked against the stored username:pwd setting)
credential = YaCyLegacyCredential.getCredentialForAdmin(username, adminAccountBase64MD5);
credential = YaCyDigestCredential.getCredentialForAdmin(username, adminAccountBase64MD5);
// TODO: YaCy user:pwd hashes should longterm likely be switched to separable username + pwd-hash entries
// and/or the standard admin account username should be fix = "admin"
roles = new String[]{SwitchboardConstants.ADMIN_ACCOUNT_ROLE};
+73 -42
View File
@@ -24,11 +24,14 @@
package net.yacy.http;
import java.io.IOException;
import java.net.MalformedURLException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import net.yacy.cora.document.id.MultiProtocolURL;
import net.yacy.cora.order.Base64Order;
import net.yacy.cora.protocol.Domains;
import net.yacy.cora.protocol.RequestHeader;
import net.yacy.search.Switchboard;
import net.yacy.search.SwitchboardConstants;
@@ -42,70 +45,98 @@ import org.eclipse.jetty.server.Request;
* jetty security handler
* demands authentication for pages with _p. inside
* and updates AccessTracker
*
* This is a thin adapter to the servlet container: the decision logic is in
* the container neutral {@link AdminSecurity}.
*/
public class YaCySecurityHandler extends ConstraintSecurityHandler {
/**
* Request-scoped hand-off of the client IP to the admin password check.
* <p>
* <b>What happens here and why:</b> YaCy accepts the stored admin password hash itself
* as a password, but only for requests originating from localhost. This is used by the
* {@code bin/*.sh} scripts (via {@code bin/apicall.sh}), which read the hash from the
* configuration file to steer a local peer - the cleartext password is never stored
* anywhere. That exception is evaluated in {@link YaCyDigestCredential#check(Object)},
* which the servlet container invokes through the {@link YaCyLoginService}.
* <p>
* The problem: Jetty's {@link org.eclipse.jetty.util.security.Credential} API only
* receives the submitted password, not the request, so the credential can not tell on
* its own whether the request comes from localhost. This handler is the single place
* that both (a) sees the request and (b) spans the whole authentication of that request:
* {@code prepareConstraintInfo()}, the authenticator, the login service and finally
* {@code Credential.check()} all run synchronously inside {@code super.handle()} on this
* same thread. We therefore publish the request's true socket peer IP here for the
* duration of the request and clear it in a {@code finally} block (so it can not leak
* across pooled request threads). The credential check then reads a value that is bound
* to the exact request being authenticated.
* <p>
* This replaced a former process-global "last localhost access" timestamp with a 100ms
* window: that value was not tied to the request being checked and could be opened by
* unrelated concurrent localhost traffic. The IP used here is the real socket peer
* ({@link Request#getRemoteAddr()}); the client-controlled and spoofable X-Real-IP
* header is never consulted for this authentication decision.
*/
@Override
public void handle(final String pathInContext, final Request baseRequest,
final HttpServletRequest request, final HttpServletResponse response)
throws IOException, ServletException {
YaCyDigestCredential.setRequestClientIP(baseRequest.getRemoteAddr());
try {
super.handle(pathInContext, baseRequest, request, response);
} finally {
YaCyDigestCredential.clearRequestClientIP();
}
}
/**
* create the constraint for the given path
* for urls containing *_p. (like info_p.html) admin access is required,
* on localhost = admin setting no constraint is set
* on localhost = admin setting no constraint is set
* @param pathInContext
* @param request
* @return RoleInfo with
* @return RoleInfo with
* isChecked=true if any security contraint applies (compare reference implementation org.eclipse.jetty.security.ConstraintSecurityHandler)
* role = "admin" for resource name containint _p.
*/
@Override
protected RoleInfo prepareConstraintInfo(String pathInContext, Request request) {
final Switchboard sb = Switchboard.getSwitchboard();
final boolean adminAccountGrantedForLocalhost = sb.getConfigBool(SwitchboardConstants.ADMIN_ACCOUNT_FOR_LOCALHOST, false);
final boolean adminAccountNeededForAllPages = sb.getConfigBool(SwitchboardConstants.ADMIN_ACCOUNT_All_PAGES, false);
String refererHost;
// update AccessTracker
final String remoteip = RequestHeader.client(request);
// Use the true socket peer IP for the access-control decision below, never the
// client-controlled (spoofable) X-Real-IP header that RequestHeader.client() would apply.
final String remoteip = request.getRemoteAddr();
serverAccessTracker.track(remoteip, pathInContext);
final boolean protectedPage = AdminSecurity.isProtectedPath(pathInContext,
sb.getConfigBool(SwitchboardConstants.ADMIN_ACCOUNT_All_PAGES, false),
sb.isRobinsonMode() && !sb.isPublicRobinson(),
sb.getConfigBool(SwitchboardConstants.PUBLIC_SEARCHPAGE, true));
if (!protectedPage) {
return super.prepareConstraintInfo(pathInContext, request);
}
String refererHost;
try {
refererHost = new MultiProtocolURL(request.getHeader(RequestHeader.REFERER)).getHost();
} catch (MalformedURLException e) {
refererHost = null;
}
final boolean accessFromLocalhost = Domains.isLocalhost(remoteip) && (refererHost == null || refererHost.length() == 0 || Domains.isLocalhost(refererHost));
// ! note : accessFromLocalhost compares localhost ip pattern
final boolean grantedForLocalhost = adminAccountGrantedForLocalhost && accessFromLocalhost;
// Even when all pages are protected, we don't want to block those used for peer-to-peer or cluster communication (except in private robinson mode)
// (examples : /yacy/hello.html is required for p2p and cluster network presence and /solr/select for remote Solr search requests)
boolean protectedPage = (adminAccountNeededForAllPages && ((sb.isRobinsonMode() && !sb.isPublicRobinson()) ||
!(pathInContext.startsWith("/yacy/") || pathInContext.startsWith("/solr/"))));
// Pages suffixed with "_p" are by the way always considered protected
protectedPage = protectedPage || (pathInContext.indexOf("_p.") > 0);
// check "/gsa" and "/solr" if not publicSearchpage
if (!protectedPage && !sb.getConfigBool(SwitchboardConstants.PUBLIC_SEARCHPAGE, true)) {
protectedPage = pathInContext.startsWith("/solr/") || pathInContext.startsWith("/gsa/");
}
if (protectedPage) {
if (grantedForLocalhost) {
if (AdminSecurity.isLocalhostAccess(remoteip, refererHost)) {
if (sb.getConfigBool(SwitchboardConstants.ADMIN_ACCOUNT_FOR_LOCALHOST, false)) {
return null;
}
// last chance to authorize using the admin from localhost
if (AdminSecurity.checkLocalhostLazyAuth(request.getHeader(RequestHeader.AUTHORIZATION),
sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_USER_NAME, "admin"),
sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_B64MD5, ""))) {
return null;
} else if (accessFromLocalhost) {
// last chance to authorize using the admin from localhost
final String adminAccountBase64MD5 = sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_B64MD5, "");
final String credentials = request.getHeader(RequestHeader.AUTHORIZATION);
if (credentials != null && credentials.length() < 120 && credentials.startsWith("Basic ")) { // Basic credentials are short "Basic " + b64(user:pwd)
final String foruser = sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_USER_NAME, "admin");
final String b64 = Base64Order.standardCoder.encodeString(foruser + ":" + adminAccountBase64MD5); // TODO: is this valid? ; consider "MD5:" prefixed config
if ((credentials.substring(6)).equals(b64)) return null; // lazy authentication for local access with credential from config (only a user with read access to DATA can do that)
}
}
RoleInfo roleinfo = new RoleInfo();
roleinfo.setChecked(true); // RoleInfo.setChecked() : in Jetty this means - marked to have any security constraint
roleinfo.addRole(SwitchboardConstants.ADMIN_ACCOUNT_ROLE);
return roleinfo;
}
return super.prepareConstraintInfo(pathInContext, request);
RoleInfo roleinfo = new RoleInfo();
roleinfo.setChecked(true); // RoleInfo.setChecked() : in Jetty this means - marked to have any security constraint
roleinfo.addRole(SwitchboardConstants.ADMIN_ACCOUNT_ROLE);
return roleinfo;
}
}
+4 -1
View File
@@ -3929,7 +3929,10 @@ public final class Switchboard extends serverSwitch {
// handle DIGEST auth by servlet container
if (requestHeader.getUserPrincipal() != null) { // user is authenticated (by Servlet container)
if (requestHeader.isUserInRole(SwitchboardConstants.ADMIN_ACCOUNT_ROLE)) {
// we could double check admin right (but we trust embedded container)
// DIGEST can't be re-verified from the header (response depends on the
// server nonce); only the embedded container that issued the challenge can.
// Trusting its result is safe because getUserPrincipal()/isUserInRole() come
// solely from container auth (see RequestHeader). BASIC above verifies itself.
this.adminAuthenticationLastAccess = System.currentTimeMillis();
return 4; // has admin right
}
@@ -28,7 +28,6 @@ import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import net.yacy.cora.protocol.Domains;
public class serverAccessTracker {
@@ -38,7 +37,6 @@ public class serverAccessTracker {
private static int maxHostCount = 100;
private static final ConcurrentHashMap<String, Queue<Track>> accessTracker = new ConcurrentHashMap<String, Queue<Track>>(); // mappings from requesting host to an ArrayList of serverTrack-entries
private static long lastCleanup;
private static long lastLocalhostAccess = 0;
public static class Track {
private final long time;
@@ -140,7 +138,6 @@ public class serverAccessTracker {
track.add(new Track(System.currentTimeMillis(), accessPath));
clearTooOldAccess(track);
}
if (Domains.isLocalhost(host)) lastLocalhostAccess = System.currentTimeMillis();
}
public static Collection<Track> accessTrack(final String host) {
@@ -162,8 +159,4 @@ public class serverAccessTracker {
accessTrackerClone.putAll(accessTracker);
return accessTrackerClone.keySet().iterator();
}
public static long timeSinceAccessFromLocalhost() {
return System.currentTimeMillis() - lastLocalhostAccess;
}
}
@@ -19,6 +19,12 @@
*/
package net.yacy.cora.protocol;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import javax.servlet.http.HttpServletRequest;
import org.junit.Test;
import static org.junit.Assert.*;
@@ -29,6 +35,58 @@ import static org.junit.Assert.*;
*/
public class RequestHeaderTest {
/**
* Build a minimal HttpServletRequest stub answering getRemoteAddr() with the
* given socket peer address and returning the given X-Real-IP header value.
*/
private static HttpServletRequest stubRequest(final String socketPeer, final String xRealIP) {
final InvocationHandler h = new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
switch (method.getName()) {
case "getRemoteAddr":
return socketPeer;
case "getRemoteHost":
return socketPeer;
case "getHeader":
return RequestHeader.X_Real_IP.equals(args[0]) ? xRealIP : null;
default:
return null;
}
}
};
return (HttpServletRequest) Proxy.newProxyInstance(
RequestHeaderTest.class.getClassLoader(),
new Class<?>[]{HttpServletRequest.class}, h);
}
/**
* Authentication must rely on the true socket peer, never on the spoofable
* X-Real-IP header. A remote client sending "X-Real-IP: 127.0.0.1" must not
* be treated as localhost.
*/
@Test
public void testXRealIpDoesNotAffectAuthentication() {
final String remoteClient = "203.0.113.7"; // a non-local address (TEST-NET-3)
// spoofing attempt: remote socket peer, but X-Real-IP claims localhost
final RequestHeader spoofed = new RequestHeader(stubRequest(remoteClient, "127.0.0.1"));
// routing accessor honors the header (kept for peer routing behind a trusted proxy) ...
assertEquals("127.0.0.1", spoofed.getRemoteAddr());
// ... but the authentication accessor must return the true socket peer
assertEquals(remoteClient, spoofed.getRemoteSocketAddr());
assertFalse("spoofed X-Real-IP must not grant localhost access", spoofed.accessFromLocalhost());
// genuine localhost access still works
final RequestHeader local = new RequestHeader(stubRequest("127.0.0.1", null));
assertEquals("127.0.0.1", local.getRemoteSocketAddr());
assertTrue(local.accessFromLocalhost());
// remote client without spoofing stays remote
final RequestHeader remote = new RequestHeader(stubRequest(remoteClient, null));
assertFalse(remote.accessFromLocalhost());
}
/**
* Test of getServerPort method, of class RequestHeader.
*/
@@ -0,0 +1,109 @@
package net.yacy.http;
import org.junit.Assert;
import org.junit.Test;
import net.yacy.cora.order.Base64Order;
import net.yacy.cora.order.Digest;
/**
* Unit tests for the servlet container neutral admin security decision logic,
* especially the supported admin password hash formats.
*/
public class AdminSecurityTest {
/**
* Test which paths demand admin rights depending on configuration.
*/
@Test
public void testIsProtectedPath() {
// pages suffixed with "_p" are always protected
Assert.assertTrue(AdminSecurity.isProtectedPath("/Settings_p.html", false, false, true));
Assert.assertTrue(AdminSecurity.isProtectedPath("/api/table_p.xml", false, false, true));
// normal pages are public by default
Assert.assertFalse(AdminSecurity.isProtectedPath("/index.html", false, false, true));
Assert.assertFalse(AdminSecurity.isProtectedPath("/yacysearch.html", false, false, true));
// adminForAllPages protects everything ...
Assert.assertTrue(AdminSecurity.isProtectedPath("/index.html", true, false, true));
// ... except the p2p and remote search interfaces ...
Assert.assertFalse(AdminSecurity.isProtectedPath("/yacy/hello.html", true, false, true));
Assert.assertFalse(AdminSecurity.isProtectedPath("/solr/select", true, false, true));
// ... which are protected too in private robinson mode
Assert.assertTrue(AdminSecurity.isProtectedPath("/yacy/hello.html", true, true, true));
Assert.assertTrue(AdminSecurity.isProtectedPath("/solr/select", true, true, true));
// without public search page the solr and gsa interfaces are protected
Assert.assertTrue(AdminSecurity.isProtectedPath("/solr/select", false, false, false));
Assert.assertTrue(AdminSecurity.isProtectedPath("/gsa/search", false, false, false));
Assert.assertFalse(AdminSecurity.isProtectedPath("/index.html", false, false, false));
}
/**
* Test the Base64 based admin password hash format MD5Hex(Base64(user:password)).
*/
@Test
public void testCheckAdminPasswordBase64Format() {
final String user = "admin";
final String pw = "secret";
final String configHash = AdminSecurity.calcHash(user + ":" + pw);
Assert.assertTrue(AdminSecurity.checkAdminPassword(user, configHash, "YaCy", "admin", false, pw));
Assert.assertFalse(AdminSecurity.checkAdminPassword(user, configHash, "YaCy", "admin", false, "wrong"));
Assert.assertFalse(AdminSecurity.checkAdminPassword(user, configHash, "YaCy", "admin", false, ""));
// the hash itself is accepted as password, but only on recent localhost access (bin/apicall.sh)
Assert.assertTrue(AdminSecurity.checkAdminPassword(user, configHash, "YaCy", "admin", true, configHash));
Assert.assertFalse(AdminSecurity.checkAdminPassword(user, configHash, "YaCy", "admin", false, configHash));
}
/**
* Test the "MD5:" prefixed admin password hash format MD5Hex(user:realm:password).
*/
@Test
public void testCheckAdminPasswordDigestFormat() {
final String user = "admin";
final String pw = "secret";
final String realm = "YaCy";
final String configHash = "MD5:" + Digest.encodeMD5Hex(user + ":" + realm + ":" + pw);
Assert.assertTrue(AdminSecurity.checkAdminPassword(user, configHash, realm, "admin", false, pw));
Assert.assertFalse(AdminSecurity.checkAdminPassword(user, configHash, realm, "admin", false, "wrong"));
// the realm is part of the hash: a different realm must not verify
Assert.assertFalse(AdminSecurity.checkAdminPassword(user, configHash, "OtherRealm", "admin", false, pw));
// the full config hash is accepted as password for the admin user on recent localhost access (bin/apicall.sh)
Assert.assertTrue(AdminSecurity.checkAdminPassword(user, configHash, realm, "admin", true, configHash));
Assert.assertFalse(AdminSecurity.checkAdminPassword(user, configHash, realm, "admin", false, configHash));
// but not for another user name
Assert.assertFalse(AdminSecurity.checkAdminPassword("other", configHash, realm, "admin", true, configHash));
}
/**
* Test the lazy localhost authorization with the config hash as Basic credential.
*/
@Test
public void testCheckLocalhostLazyAuth() {
final String adminUser = "admin";
final String configHash = "MD5:0cef3f723bbf6ec22bbb0ca4d4dfd001";
final String validHeader = "Basic " + Base64Order.standardCoder.encodeString(adminUser + ":" + configHash);
Assert.assertTrue(AdminSecurity.checkLocalhostLazyAuth(validHeader, adminUser, configHash));
Assert.assertFalse(AdminSecurity.checkLocalhostLazyAuth(null, adminUser, configHash));
Assert.assertFalse(AdminSecurity.checkLocalhostLazyAuth("Basic d3Jvbmc=", adminUser, configHash));
Assert.assertFalse(AdminSecurity.checkLocalhostLazyAuth("Digest something", adminUser, configHash));
}
/**
* Test the localhost access check including the referer host condition.
*/
@Test
public void testIsLocalhostAccess() {
Assert.assertTrue(AdminSecurity.isLocalhostAccess("127.0.0.1", null));
Assert.assertTrue(AdminSecurity.isLocalhostAccess("127.0.0.1", ""));
Assert.assertTrue(AdminSecurity.isLocalhostAccess("127.0.0.1", "localhost"));
Assert.assertFalse(AdminSecurity.isLocalhostAccess("192.0.2.17", null));
// a request from localhost referred by a remote page is not a localhost access
Assert.assertFalse(AdminSecurity.isLocalhostAccess("127.0.0.1", "example.org"));
}
}