我该如何在Java Servlet过滤器中安全地处理密码?

5
我有一个过滤器可以处理HTTPS上的BASIC身份认证。这意味着会收到一个名为“Authorization”的标头,其值类似于“Basic aGVsbG86c3RhY2tvdmVyZmxvdw==”。
我不关心如何处理认证、401加WWW-Authenticate响应头、JDBC查询之类的事情。我的过滤器工作得很好。
我的担忧是我们永远不应该在java.lang.String中存储用户密码,因为它们是不可变的。我不能一完成认证就将该String清零。那个对象会一直留在内存中,直到垃圾回收器运行。这为坏人获取核心转储或以某种方式观察堆留下了更大的时间窗口。
问题在于,我看到的唯一读取Authorization标头的方法是通过javax.servlet.http.HttpServletRequest.getHeader(String)方法,但它返回一个字符串。我需要一个返回字节数组或字符数组的getHeader方法。理想情况下,请求从Socket到HttpServletRequest和所有中间点在任何时候都不应该是一个字符串。
如果我切换到某种形式的基于表单的安全性,问题仍然存在。javax.servlet.ServletRequest.getParameter(String)也返回一个字符串。
这是否只是Java EE的局限性?
3个回答

4

实际上,只有字符串字面量被保留在Permgen的字符串池区域中。创建的字符串是可丢弃的。

所以... 基本认证可能存在的问题不止内存转储,还包括:

  • 密码明文传输。
  • 为每个请求重复发送密码。(攻击窗口更大)
  • Web浏览器会缓存密码,至少在窗口/进程的长度内。(可以被任何其他请求静默地重新使用,例如CSRF)。
  • 如果用户请求,密码可能会永久存储在浏览器中。(与前一个点相同,此外可能会被共享计算机上的另一个用户窃取)。
  • 即使使用SSL,内部服务器(在SSL协议后面)也将访问明文可缓存的密码。

同时,Java容器已经解析了HTTP请求并填充了对象。因此,您从请求头中获取字符串。您可能应该重写Web容器以安全地解析HTTP请求。

更新

我错了。至少对于Apache Tomcat。

http://alvinalexander.com/java/jwarehouse/apache-tomcat-6.0.16/java/org/apache/catalina/authenticator/BasicAuthenticator.java.shtml

正如您所看到的,Tomcat项目中的BasicAuthenticator使用MessageBytes(即避免使用String)来执行身份验证。
/**
 * Authenticate the user making this request, based on the specified
 * login configuration.  Return <code>true if any specified
 * constraint has been satisfied, or <code>false if we have
 * created a response challenge already.
 *
 * @param request Request we are processing
 * @param response Response we are creating
 * @param config    Login configuration describing how authentication
 *              should be performed
 *
 * @exception IOException if an input/output error occurs
 */
public boolean authenticate(Request request,
                            Response response,
                            LoginConfig config)
    throws IOException {

    // Have we already authenticated someone?
    Principal principal = request.getUserPrincipal();
    String ssoId = (String) request.getNote(Constants.REQ_SSOID_NOTE);
    if (principal != null) {
        if (log.isDebugEnabled())
            log.debug("Already authenticated '" + principal.getName() + "'");
        // Associate the session with any existing SSO session
        if (ssoId != null)
            associate(ssoId, request.getSessionInternal(true));
        return (true);
    }

    // Is there an SSO session against which we can try to reauthenticate?
    if (ssoId != null) {
        if (log.isDebugEnabled())
            log.debug("SSO Id " + ssoId + " set; attempting " +
                      "reauthentication");
        /* Try to reauthenticate using data cached by SSO.  If this fails,
           either the original SSO logon was of DIGEST or SSL (which
           we can't reauthenticate ourselves because there is no
           cached username and password), or the realm denied
           the user's reauthentication for some reason.
           In either case we have to prompt the user for a logon */
        if (reauthenticateFromSSO(ssoId, request))
            return true;
    }

    // Validate any credentials already included with this request
    String username = null;
    String password = null;

    MessageBytes authorization = 
        request.getCoyoteRequest().getMimeHeaders()
        .getValue("authorization");

    if (authorization != null) {
        authorization.toBytes();
        ByteChunk authorizationBC = authorization.getByteChunk();
        if (authorizationBC.startsWithIgnoreCase("basic ", 0)) {
            authorizationBC.setOffset(authorizationBC.getOffset() + 6);
            // FIXME: Add trimming
            // authorizationBC.trim();

            CharChunk authorizationCC = authorization.getCharChunk();
            Base64.decode(authorizationBC, authorizationCC);

            // Get username and password
            int colon = authorizationCC.indexOf(':');
            if (colon < 0) {
                username = authorizationCC.toString();
            } else {
                char[] buf = authorizationCC.getBuffer();
                username = new String(buf, 0, colon);
                password = new String(buf, colon + 1, 
                        authorizationCC.getEnd() - colon - 1);
            }

            authorizationBC.setOffset(authorizationBC.getOffset() - 6);
        }

        principal = context.getRealm().authenticate(username, password);
        if (principal != null) {
            register(request, response, principal, Constants.BASIC_METHOD,
                     username, password);
            return (true);
        }
    }


    // Send an "unauthorized" response and an appropriate challenge
    MessageBytes authenticate = 
        response.getCoyoteResponse().getMimeHeaders()
        .addValue(AUTHENTICATE_BYTES, 0, AUTHENTICATE_BYTES.length);
    CharChunk authenticateCC = authenticate.getCharChunk();
    authenticateCC.append("Basic realm=\"");
    if (config.getRealmName() == null) {
        authenticateCC.append(request.getServerName());
        authenticateCC.append(':');
        authenticateCC.append(Integer.toString(request.getServerPort()));
    } else {
        authenticateCC.append(config.getRealmName());
    }
    authenticateCC.append('\"');        
    authenticate.toChars();
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    //response.flushBuffer();
    return (false);

}

只要您可以访问org.apache.catalina.connector.Request,就没有问题。
那么,如何避免解析HTTP请求呢?
在stackoverflow上有一个很棒的答案详细介绍了{{link1:使用servlet过滤器从发布的数据中删除表单参数}}。
还有一个重要的解释:
方法
代码遵循正确的方法:
在wrapRequest()中,它实例化HttpServletRequestWrapper并覆盖触发请求解析的4个方法: public String getParameter(String name) public Map getParameterMap() public Enumeration getParameterNames() public String[] getParameterValues(String name)
doFilter()方法使用包装的请求调用过滤器链,这意味着后续的过滤器以及目标servlet(URL映射)将使用包装的请求。

我承认 Basic Auth 的局限性。但是,当使用 HTTPS 时,密码不会以明文形式通过网络发送。HTTPS 可以防止窃听,因此希望用户浏览器和我的服务器之间的连接是保密的。我的服务器正在运行 Servlet 过滤器。 - bmauter
是的,你说得对。密码不会以明文形式通过网络发送。但是,在Tomcat应用服务器中,默认情况下,每个标准Realm实现都会以明文形式存储用户的密码。有时,您还可以在Tomcat服务器前使用Apache服务器和ajp连接器。最后,如果直接使用Tomcat,则HTTPS将防止窃听。 - rdllopes
好的,我明白你为什么指出这一点。我们的应用程序中已经有一个用户表,其中包含一个哈希密码字段。我的Servlet过滤器会对其进行身份验证。 - bmauter
我认为我不需要担心包装请求。我可以使用request.getCoyoteRequest.getMimeHeaders().getValue(String)方法获取密码的char[],然后在进行身份验证后甚至可以将其清零。我不太喜欢将其与Tomcat绑定,因为我们有一些客户运行不同的Web容器。如果不是Tomcat,我可以使用默认的getHeader(String)方法,而在Tomcat中则使用这种疯狂的方式。哇。 - bmauter
感谢@rdllopes。最终,即使是Tomcat,我们也不会在我们的servlet中放置特定于供应商的内容。我认为我的问题已得到解答,因为您确认J2EE没有提供此机制,并且因为您提供了一种解决方法。也许有一天我们会回来看看它。 - bmauter

0

这是正确的,但不应该在数据库中存储实际密码进行检查,而是对密码本身进行哈希处理,然后运行哈希函数来确定两个哈希值是否相同,这样就不会使用原始用户的密码。


我不关心JDBC部分(原问题的第二段)。我正在使用bcrypt哈希。 - bmauter

0

如果您非常关心这个问题,那么在您的Filter中使用ServletRequest.getInputStream()而不是HttpServletRequest.getHeader(String)。您应该能够将HTTP请求作为流获取,跳过直到Authorization头,并在char []中获取密码。

但是所有这些努力可能都是徒劳的,因为底层对象仍然是HTTPServletRequest,并且可能包含所有标题作为键值对的映射,具体取决于Servlet的实现方式。


抱歉,getInputStream 只返回请求正文。头部在正文之前。不过你说得对,容器可能会使这个无用(这就是我在原问题中提到从 Socket 到 HttpServletRequest 的请求的评论)。 - bmauter
我的错,我在回答中只是把我的思维过程表达出来了,没有测试过。 - mzzzzb

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接