summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--source/net/yacy/cora/protocol/RequestHeader.java38
-rw-r--r--source/net/yacy/http/AdminSecurity.java139
-rw-r--r--source/net/yacy/http/YaCyDigestCredential.java140
-rw-r--r--source/net/yacy/http/YaCyLegacyCredential.java128
-rw-r--r--source/net/yacy/http/YaCyLoginService.java2
-rw-r--r--source/net/yacy/http/YaCySecurityHandler.java115
-rw-r--r--source/net/yacy/search/Switchboard.java5
-rw-r--r--source/net/yacy/server/serverAccessTracker.java7
-rw-r--r--test/java/net/yacy/cora/protocol/RequestHeaderTest.java58
-rw-r--r--test/java/net/yacy/http/AdminSecurityTest.java109
10 files changed, 560 insertions, 181 deletions
diff --git a/source/net/yacy/cora/protocol/RequestHeader.java b/source/net/yacy/cora/protocol/RequestHeader.java
index 202be51da..2a4140af8 100644
--- a/source/net/yacy/cora/protocol/RequestHeader.java
+++ b/source/net/yacy/cora/protocol/RequestHeader.java
@@ -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) {
diff --git a/source/net/yacy/http/AdminSecurity.java b/source/net/yacy/http/AdminSecurity.java
new file mode 100644
index 000000000..20cffa094
--- /dev/null
+++ b/source/net/yacy/http/AdminSecurity.java
@@ -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));
+ }
+}
diff --git a/source/net/yacy/http/YaCyDigestCredential.java b/source/net/yacy/http/YaCyDigestCredential.java
new file mode 100644
index 000000000..450c12f1f
--- /dev/null
+++ b/source/net/yacy/http/YaCyDigestCredential.java
@@ -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;
+ }
+
+}
diff --git a/source/net/yacy/http/YaCyLegacyCredential.java b/source/net/yacy/http/YaCyLegacyCredential.java
deleted file mode 100644
index 961e5c529..000000000
--- a/source/net/yacy/http/YaCyLegacyCredential.java
+++ /dev/null
@@ -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;
- }
-
-}
diff --git a/source/net/yacy/http/YaCyLoginService.java b/source/net/yacy/http/YaCyLoginService.java
index abc5ec2c7..ef9086cf3 100644
--- a/source/net/yacy/http/YaCyLoginService.java
+++ b/source/net/yacy/http/YaCyLoginService.java
@@ -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};
diff --git a/source/net/yacy/http/YaCySecurityHandler.java b/source/net/yacy/http/YaCySecurityHandler.java
index 675b02b35..436b167f1 100644
--- a/source/net/yacy/http/YaCySecurityHandler.java
+++ b/source/net/yacy/http/YaCySecurityHandler.java
@@ -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;
}
}
diff --git a/source/net/yacy/search/Switchboard.java b/source/net/yacy/search/Switchboard.java
index 13fee0653..c0d189b2e 100644
--- a/source/net/yacy/search/Switchboard.java
+++ b/source/net/yacy/search/Switchboard.java
@@ -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
}
diff --git a/source/net/yacy/server/serverAccessTracker.java b/source/net/yacy/server/serverAccessTracker.java
index d4f13817d..dce65b7f3 100644
--- a/source/net/yacy/server/serverAccessTracker.java
+++ b/source/net/yacy/server/serverAccessTracker.java
@@ -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;
- }
}
diff --git a/test/java/net/yacy/cora/protocol/RequestHeaderTest.java b/test/java/net/yacy/cora/protocol/RequestHeaderTest.java
index 3cdb23cb9..0931d8e0c 100644
--- a/test/java/net/yacy/cora/protocol/RequestHeaderTest.java
+++ b/test/java/net/yacy/cora/protocol/RequestHeaderTest.java
@@ -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.*;
@@ -30,6 +36,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.
*/
@Test
diff --git a/test/java/net/yacy/http/AdminSecurityTest.java b/test/java/net/yacy/http/AdminSecurityTest.java
new file mode 100644
index 000000000..4fd8abb58
--- /dev/null
+++ b/test/java/net/yacy/http/AdminSecurityTest.java
@@ -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"));
+ }
+}