mirror of
https://github.com/yacy/yacy_search_server.git
synced 2026-10-25 05:54:15 +00:00
de-coupling of security functions from jetty
This commit is contained in:
1 parent
dd0d4ee962
commit
edeb6162c4
10 files changed
+558
-179
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,9 +45,51 @@ 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,
|
||||
@@ -58,54 +103,40 @@ public class YaCySecurityHandler extends ConstraintSecurityHandler {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user