跳到内容

HTML5 安全备忘单

简介

以下备忘单旨在指导如何安全地实现 HTML 5。

通信 API

Web 消息传递

Web 消息传递(也称为跨域消息传递)提供了一种在不同源文档之间进行消息传递的方法,这种方法通常比过去为完成此任务而使用的多种技巧更安全。但是,仍有一些建议需要牢记:

  • 发送消息时,应将预期的源显式声明为 postMessage 的第二个参数,而不是使用 *,以防止在重定向或目标窗口源发生其他方式改变后将消息发送到未知源。
  • 接收页面应**始终**
    • 检查发送者的 origin 属性,以验证数据是否来自预期位置。
    • 对事件的 data 属性执行输入验证,以确保其格式符合要求。
  • 不要假设您对 data 属性有控制权。发送页面中的一个跨站脚本漏洞可能允许攻击者发送任何指定格式的消息。
  • 两个页面都应仅将交换的消息解释为**数据**。切勿将传递的消息评估为代码(例如通过 eval())或将其插入页面 DOM(例如通过 innerHTML),因为这会创建基于 DOM 的 XSS 漏洞。欲了解更多信息,请参阅基于 DOM 的 XSS 防御备忘单
  • 要将数据值分配给元素,请使用更安全的方法 element.textContent=data;,而不是不安全的方法,例如 element.innerHTML=data;
  • 正确检查源以精确匹配您预期的完全限定域名 (FQDN)。请注意,以下代码:if(message.origin.indexOf(".owasp.org")!=-1) { /* ... */ } 非常不安全,并且不会产生预期行为,因为 owasp.org.attacker.com 将会匹配。
  • 如果您需要嵌入外部内容/不受信任的小工具并允许用户控制的脚本(强烈不建议这样做),请查看有关沙盒框架的信息。

跨源资源共享

  • 验证传递给 XMLHttpRequest.open 的 URL。当前浏览器允许这些 URL 跨域;这种行为可能导致远程攻击者的代码注入。请特别注意绝对 URL。
  • 确保响应 Access-Control-Allow-Origin: * 的 URL 不包含任何敏感内容或可能帮助攻击者进行进一步攻击的信息。仅在需要跨域访问的特定 URL 上使用 Access-Control-Allow-Origin 头。不要将此头用于整个域。
  • Access-Control-Allow-Origin 头中仅允许选定的、受信任的域。优先允许特定域,而不是阻止或允许任何域(不要使用 * 通配符,也不要不加任何检查地盲目返回 Origin 头内容)。
  • 请记住,CORS 并不能阻止请求的数据发送到未经授权的位置。服务器仍需执行常规的CSRF 防御措施。
  • 尽管 Fetch 标准建议使用 OPTIONS 动词进行预检请求,但当前的实现可能不会执行此请求,因此重要的是“普通” (GETPOST) 请求也要执行任何必要的访问控制。
  • 丢弃通过普通 HTTP 接收但源自 HTTPS 的请求,以防止混合内容漏洞。
  • 不要仅仅依赖 Origin 头进行访问控制检查。浏览器总是在 CORS 请求中发送此头,但在浏览器外部可能会被伪造。应使用应用层协议来保护敏感数据。

WebSockets

  • 在已实现的客户端/服务器中放弃向后兼容性,并且只使用 hybi-00 以上的协议版本。流行的 Hixie-76 版本 (hybi-00) 和更早的版本已经过时且不安全。
  • 所有当前浏览器最新版本中推荐支持的版本是 RFC 6455(受 Firefox 11+、Chrome 16+、Safari 6、Opera 12.50 和 IE10 支持)。
  • 虽然通过 WebSockets 隧道传输 TCP 服务(例如 VNC、FTP)相对容易,但在发生跨站脚本攻击时,这样做会使浏览器内的攻击者能够访问这些隧道服务。这些服务也可能直接从恶意页面或程序中调用。
  • 该协议不处理授权和/或认证。如果正在传输敏感数据,应用层协议应单独处理这些问题。
  • 将 WebSocket 接收到的消息作为数据处理。不要尝试将其直接分配给 DOM,也不要将其评估为代码。如果响应是 JSON,切勿使用不安全的 eval() 函数;请改用安全的 JSON.parse() 选项。
  • 通过 ws:// 协议公开的端点很容易还原为纯文本。只有 wss://(基于 SSL/TLS 的 WebSockets)才应用于防范中间人攻击。
  • 在浏览器外部伪造客户端是可能的,因此 WebSockets 服务器应能够处理不正确/恶意的输入。始终验证来自远程站点的输入,因为它可能已被篡改。
  • 实现服务器时,检查 WebSocket 握手中的 Origin: 头。尽管在浏览器外部可能被伪造,但浏览器总是会添加发起 WebSocket 连接的页面的源。
  • 由于浏览器中的 WebSockets 客户端可以通过 JavaScript 调用访问,所有 WebSocket 通信都可能通过跨站脚本被伪造或劫持。始终验证通过 WebSocket 连接传入的数据。

服务器发送事件

  • 验证传递给 EventSource 构造函数的 URL,即使只允许同源 URL。
  • 如前所述,将消息 (event.data) 作为数据处理,切勿将内容评估为 HTML 或脚本代码。
  • 始终检查消息的源属性 (event.origin) 以确保消息来自受信任的域。使用允许列表方法。

存储 API

本地存储

  • 也称为离线存储、Web 存储。底层存储机制可能因用户代理而异。换句话说,如果用户拥有存储数据机器的本地权限,则可以绕过您的应用程序所需的任何身份验证。因此,建议避免在假定需要身份验证的本地存储中存储任何敏感信息。
  • 鉴于浏览器的安全保障,在不假设需要认证或授权的情况下访问数据时,使用本地存储是合适的。
  • 如果不需要持久化存储,请使用 sessionStorage 对象而不是 localStorage。sessionStorage 对象仅在该窗口/标签页关闭前可用。
  • 单个跨站脚本可用于窃取这些对象中的所有数据,因此再次建议不要在本地存储中存储敏感信息。
  • 单个跨站脚本也可以用于将恶意数据加载到这些对象中,因此不要认为这些对象中的数据是可信的。
  • 特别注意 HTML5 页面中实现的 "localStorage.getItem" 和 "setItem" 调用。这有助于检测开发者何时构建将敏感信息放入本地存储的解决方案,如果错误地假定了对该数据的认证或授权,这可能是一个严重的风险。
  • 不要将会话标识符存储在本地存储中,因为数据总是可以通过 JavaScript 访问。Cookie 可以使用 httpOnly 标志来降低此风险。
  • 无法像 HTTP Cookie 的 path 属性那样将对象的可见性限制在特定路径。每个对象都在同一源中共享,并受同源策略保护。避免在同一源上托管多个应用程序,因为它们都会共享相同的 localStorage 对象,请改用不同的子域。

客户端数据库

  • 2010 年 11 月,W3C 宣布 Web SQL Database(关系型 SQL 数据库)为已弃用规范。目前正在积极开发新的标准 Indexed Database API 或 IndexedDB(以前称为 WebSimpleDB),它提供键值数据库存储和执行高级查询的方法。
  • 底层存储机制可能因用户代理而异。换句话说,如果用户拥有存储数据机器的本地权限,则可以绕过您的应用程序所需的任何身份验证。因此,建议不要在本地存储中存储任何敏感信息。
  • 如果使用,客户端的 WebDatabase 内容可能容易受到 SQL 注入攻击,需要进行适当的验证和参数化。
  • 与本地存储一样,单个跨站脚本也可以用于将恶意数据加载到 Web 数据库中。不要认为这些数据库中的数据是可信的。

地理定位

  • 地理定位 API 要求用户代理在计算位置之前征求用户的许可。此决定是否以及如何被记住因浏览器而异。某些用户代理要求用户再次访问页面才能关闭在不询问的情况下获取用户位置的能力,因此出于隐私原因,建议在调用 getCurrentPositionwatchPosition 之前要求用户输入。

Web Worker

  • Web Worker 允许使用 XMLHttpRequest 对象执行域内和跨源资源共享请求。请参阅此备忘单的相关部分以确保 CORS 安全。
  • 虽然 Web Worker 无法访问调用页面的 DOM,但恶意的 Web Worker 可能会过度占用 CPU 进行计算,导致拒绝服务条件,或滥用跨源资源共享进行进一步利用。确保所有 Web Worker 脚本中的代码不是恶意的。不允许从用户提供的输入创建 Web Worker 脚本。
  • 验证与 Web Worker 交换的消息。不要尝试交换 JavaScript 片段进行评估(例如通过 eval()),因为这可能会引入基于 DOM 的 XSS 漏洞。

Tabnabbing (标签劫持)

攻击详情请参阅这篇文章

总而言之,它是指通过 opener JavaScript 对象实例暴露的回溯链接,从新打开的页面对父页面的内容或位置进行操作的能力。

它适用于使用属性/指令 target 来指定一个不替换当前位置且使当前窗口/标签页可用的目标加载位置的 HTML 链接或 JavaScript window.open 函数。

为防止此问题,可采取以下措施:

切断父页面和子页面之间的回溯链接

  • 对于 HTML 链接
    • 要切断此回溯链接,请在用于从父页面创建到子页面链接的标签上添加 rel="noopener" 属性。此属性值会切断链接,但根据浏览器不同,可能会在发送到子页面的请求中保留引用信息。
    • 要同时删除引用信息,请使用此属性值:rel="noopener noreferrer"
  • 对于 JavaScript window.open 函数,在 window.open 函数的 windowFeatures 参数中添加值 noopener,noreferrer

由于上述元素在不同浏览器中的行为有所不同,因此可以使用 HTML 链接或 JavaScript 打开窗口(或标签页),然后使用此配置以最大化跨浏览器支持:

  • 对于HTML 链接,为每个链接添加属性 rel="noopener noreferrer"
  • 对于 JavaScript,使用此函数打开窗口(或标签页):
function openPopup(url, name, windowFeatures){
  //Open the popup and set the opener and referrer policy instruction
  var newWindow = window.open(url, name, 'noopener,noreferrer,' + windowFeatures);
  //Reset the opener link
  newWindow.opener = null;
}
  • 向应用程序发送的每个 HTTP 响应添加 HTTP 响应头 Referrer-Policy: no-referrerReferrer-Policy 头信息)。此配置将确保页面发出的请求不附带任何引用信息。

兼容性矩阵

沙盒框架

  • 对于不受信任的内容,使用 iframesandbox 属性。
  • iframesandbox 属性可对 iframe 中的内容施加限制。当设置了 sandbox 属性时,以下限制生效:
    1. 所有标记均被视为来自唯一源。
    2. 所有表单和脚本均被禁用。
    3. 所有链接均被阻止定位其他浏览上下文。
    4. 所有自动触发的功能均被阻止。
    5. 所有插件均被禁用。

可以使用 sandbox 属性的值对 iframe 功能进行细粒度控制

  • 在不支持此功能的用户代理旧版本中,此属性将被忽略。将此功能作为额外的保护层,或者检查浏览器是否支持沙盒框架,并且仅在支持时才显示不受信任的内容。
  • 除了此属性外,为防止点击劫持攻击和未经请求的框架,建议使用支持 denysame-origin 值的 X-Frame-Options 头。其他解决方案,如框架突破 if(window!==window.top) { window.top.location=location;},不推荐使用。

凭证和个人身份信息 (PII) 输入提示

  • 保护输入值不被浏览器缓存。

从公共计算机访问金融账户。即使已注销,下一位使用该机器的人也可能因为浏览器的自动完成功能而登录。为缓解此问题,我们应告知输入字段不提供任何帮助。

<input type="text" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></input>

应阻止浏览器存储个人身份信息 (PII)(姓名、电子邮件、地址、电话号码)和登录凭据(用户名、密码)的文本区域和输入字段。使用这些 HTML5 属性来阻止浏览器存储表单中的 PII:

  • spellcheck="false"
  • autocomplete="off"
  • autocorrect="off"
  • autocapitalize="off"

离线应用

  • 用户代理是否向用户请求存储数据以进行离线浏览的权限,以及此缓存何时被删除,因浏览器而异。如果用户通过不安全的网络连接,缓存投毒是一个问题,因此出于隐私原因,建议在发送任何 manifest 文件之前要求用户输入。
  • 用户应只缓存受信任的网站,并在浏览开放或不安全的网络后清除缓存。

渐进增强和优雅降级风险

  • 目前的最佳实践是确定浏览器支持的功能,并为不直接支持的功能添加某种替代方案。这可能意味着一个类似洋葱的元素,例如,如果不支持 <video> 标签,则回退到 Flash Player;或者,它可能意味着来自各种来源的额外脚本代码,这些代码应进行代码审查。

增强安全性的 HTTP 头

请查阅 OWASP 安全头项目,以获取应用程序应使用的 HTTP 安全头列表,从而在浏览器级别启用防御。

WebSocket 实现提示

除了上述要素,以下是实现过程中必须谨慎的领域列表。

  • 通过“Origin”HTTP 请求头进行访问过滤
  • 输入/输出验证
  • 认证
  • 授权
  • 访问令牌显式失效
  • 保密性和完整性

以下部分将为每个领域提供一些实现提示,并附带一个应用程序示例,展示所有描述的要点。

示例应用程序的完整源代码可在此处获取。

访问过滤

在 WebSocket 通道初始化期间,浏览器会发送包含请求握手源域的 Origin HTTP 请求头。即使此头在伪造的 HTTP 请求中(非基于浏览器)可以被伪造,但在浏览器上下文中它无法被覆盖或强制。因此,它是一个根据预期值应用过滤的良好候选。

使用此向量的攻击示例,名为跨站 WebSocket 劫持 (CSWSH),在此处进行了描述。

以下代码定义了一个基于源“允许列表”应用过滤的配置。这确保只有允许的源才能建立完整的握手。

import org.owasp.encoder.Encode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.websocket.server.ServerEndpointConfig;
import java.util.Arrays;
import java.util.List;

/**
 * Setup handshake rules applied to all WebSocket endpoints of the application.
 * Use to setup the Access Filtering using "Origin" HTTP header as input information.
 *
 * @see "http://docs.oracle.com/javaee/7/api/index.html?javax/websocket/server/
 * ServerEndpointConfig.Configurator.html"
 * @see "https://mdn.org.cn/en-US/docs/Web/HTTP/Headers/Origin"
 */
public class EndpointConfigurator extends ServerEndpointConfig.Configurator {

    /**
     * Logger
     */
    private static final Logger LOG = LoggerFactory.getLogger(EndpointConfigurator.class);

    /**
     * Get the expected source origins from a JVM property in order to allow external configuration
     */
    private static final List<String> EXPECTED_ORIGINS =  Arrays.asList(System.getProperty("source.origins")
                                                          .split(";"));

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean checkOrigin(String originHeaderValue) {
        boolean isAllowed = EXPECTED_ORIGINS.contains(originHeaderValue);
        String safeOriginValue = Encode.forHtmlContent(originHeaderValue);
        if (isAllowed) {
            LOG.info("[EndpointConfigurator] New handshake request received from {} and was accepted.",
                      safeOriginValue);
        } else {
            LOG.warn("[EndpointConfigurator] New handshake request received from {} and was rejected !",
                      safeOriginValue);
        }
        return isAllowed;
    }

}

认证和输入/输出验证

当使用 WebSocket 作为通信通道时,重要的是要使用一种认证方法,允许用户接收一个不会被浏览器自动发送的访问令牌,并且在每次交换期间必须由客户端代码显式发送该令牌。

HMAC 摘要是最简单的方法,而JSON Web Token 是一个功能丰富的良好替代方案,因为它允许以无状态且不可篡改的方式传输访问凭证信息。此外,它定义了有效时间范围。您可以在此备忘单上找到有关 JWT 强化的更多信息。

JSON 验证模式用于定义和验证输入和输出消息中的预期内容。

以下代码定义了完整的认证消息流处理:

认证 WebSocket 端点 - 提供一个支持认证交换的 WS 端点

import org.owasp.pocwebsocket.configurator.EndpointConfigurator;
import org.owasp.pocwebsocket.decoder.AuthenticationRequestDecoder;
import org.owasp.pocwebsocket.encoder.AuthenticationResponseEncoder;
import org.owasp.pocwebsocket.handler.AuthenticationMessageHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.websocket.CloseReason;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

/**
 * Class in charge of managing the client authentication.
 *
 * @see "http://docs.oracle.com/javaee/7/api/javax/websocket/server/ServerEndpointConfig.Configurator.html"
 * @see "http://svn.apache.org/viewvc/tomcat/trunk/webapps/examples/WEB-INF/classes/websocket/"
 */
@ServerEndpoint(value = "/auth", configurator = EndpointConfigurator.class,
subprotocols = {"authentication"}, encoders = {AuthenticationResponseEncoder.class},
decoders = {AuthenticationRequestDecoder.class})
public class AuthenticationEndpoint {

    /**
     * Logger
     */
    private static final Logger LOG = LoggerFactory.getLogger(AuthenticationEndpoint.class);

    /**
     * Handle the beginning of an exchange
     *
     * @param session Exchange session information
     */
    @OnOpen
    public void start(Session session) {
        //Define connection idle timeout and message limits in order to mitigate as much as possible
        //DOS attacks using massive connection opening or massive big messages sending
        int msgMaxSize = 1024 * 1024;//1 MB
        session.setMaxIdleTimeout(60000);//1 minute
        session.setMaxTextMessageBufferSize(msgMaxSize);
        session.setMaxBinaryMessageBufferSize(msgMaxSize);
        //Log exchange start
        LOG.info("[AuthenticationEndpoint] Session {} started", session.getId());
        //Affect a new message handler instance in order to process the exchange
        session.addMessageHandler(new AuthenticationMessageHandler(session.getBasicRemote()));
        LOG.info("[AuthenticationEndpoint] Session {} message handler affected for processing",
                  session.getId());
    }

    /**
     * Handle error case
     *
     * @param session Exchange session information
     * @param thr     Error details
     */
    @OnError
    public void onError(Session session, Throwable thr) {
        LOG.error("[AuthenticationEndpoint] Error occur in session {}", session.getId(), thr);
    }

    /**
     * Handle close event
     *
     * @param session     Exchange session information
     * @param closeReason Exchange closing reason
     */
    @OnClose
    public void onClose(Session session, CloseReason closeReason) {
        LOG.info("[AuthenticationEndpoint] Session {} closed: {}", session.getId(),
                  closeReason.getReasonPhrase());
    }

}

认证消息处理程序 - 处理所有认证请求

import org.owasp.pocwebsocket.enumeration.AccessLevel;
import org.owasp.pocwebsocket.util.AuthenticationUtils;
import org.owasp.pocwebsocket.vo.AuthenticationRequest;
import org.owasp.pocwebsocket.vo.AuthenticationResponse;
import org.owasp.encoder.Encode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.websocket.EncodeException;
import javax.websocket.MessageHandler;
import javax.websocket.RemoteEndpoint;
import java.io.IOException;

/**
 * Handle authentication message flow
 */
public class AuthenticationMessageHandler implements MessageHandler.Whole<AuthenticationRequest> {

    private static final Logger LOG = LoggerFactory.getLogger(AuthenticationMessageHandler.class);

    /**
     * Reference to the communication channel with the client
     */
    private RemoteEndpoint.Basic clientConnection;

    /**
     * Constructor
     *
     * @param clientConnection Reference to the communication channel with the client
     */
    public AuthenticationMessageHandler(RemoteEndpoint.Basic clientConnection) {
        this.clientConnection = clientConnection;
    }


    /**
     * {@inheritDoc}
     */
    @Override
    public void onMessage(AuthenticationRequest message) {
        AuthenticationResponse response = null;
        try {
            //Authenticate
            String authenticationToken = "";
            String accessLevel = this.authenticate(message.getLogin(), message.getPassword());
            if (accessLevel != null) {
                //Create a simple JSON token representing the authentication profile
                authenticationToken = AuthenticationUtils.issueToken(message.getLogin(), accessLevel);
            }
            //Build the response object
            String safeLoginValue = Encode.forHtmlContent(message.getLogin());
            if (!authenticationToken.isEmpty()) {
                response = new AuthenticationResponse(true, authenticationToken, "Authentication succeed !");
                LOG.info("[AuthenticationMessageHandler] User {} authentication succeed.", safeLoginValue);
            } else {
                response = new AuthenticationResponse(false, authenticationToken, "Authentication failed !");
                LOG.warn("[AuthenticationMessageHandler] User {} authentication failed.", safeLoginValue);
            }
        } catch (Exception e) {
            LOG.error("[AuthenticationMessageHandler] Error occur in authentication process.", e);
            //Build the response object indicating that authentication fail
            response = new AuthenticationResponse(false, "", "Authentication failed !");
        } finally {
            //Send response
            try {
                this.clientConnection.sendObject(response);
            } catch (IOException | EncodeException e) {
                LOG.error("[AuthenticationMessageHandler] Error occur in response object sending.", e);
            }
        }
    }

    /**
     * Authenticate the user
     *
     * @param login    User login
     * @param password User password
     * @return The access level if the authentication succeed or NULL if the authentication failed
     */
    private String authenticate(String login, String password) {
      ....
    }
}

用于管理 JWT 的实用类 - 处理访问令牌的颁发和验证。示例中使用了简单的 JWT(此处侧重于全局 WS 端点实现),没有进行额外强化(请参阅此备忘单以对 JWT 应用额外强化)。

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Calendar;
import java.util.Locale;

/**
 * Utility class to manage the authentication JWT token
 */
public class AuthenticationUtils {

    /**
     * Build a JWT token for a user
     *
     * @param login       User login
     * @param accessLevel Access level of the user
     * @return The Base64 encoded JWT token
     * @throws Exception If any error occur during the issuing
     */
    public static String issueToken(String login, String accessLevel) throws Exception {
        //Issue a JWT token with validity of 30 minutes
        Algorithm algorithm = Algorithm.HMAC256(loadSecret());
        Calendar c = Calendar.getInstance();
        c.add(Calendar.MINUTE, 30);
        return JWT.create().withIssuer("WEBSOCKET-SERVER").withSubject(login).withExpiresAt(c.getTime())
                  .withClaim("access_level", accessLevel.trim().toUpperCase(Locale.US)).sign(algorithm);
    }

    /**
     * Verify the validity of the provided JWT token
     *
     * @param token JWT token encoded to verify
     * @return The verified and decoded token with user authentication and
     * authorization (access level) information
     * @throws Exception If any error occur during the token validation
     */
    public static DecodedJWT validateToken(String token) throws Exception {
        Algorithm algorithm = Algorithm.HMAC256(loadSecret());
        JWTVerifier verifier = JWT.require(algorithm).withIssuer("WEBSOCKET-SERVER").build();
        return verifier.verify(token);
    }

    /**
     * Load the JWT secret used to sign token using a byte array for secret storage in order
     * to avoid persistent string in memory
     *
     * @return The secret as byte array
     * @throws IOException If any error occur during the secret loading
     */
    private static byte[] loadSecret() throws IOException {
        return Files.readAllBytes(Paths.get("src", "main", "resources", "jwt-secret.txt"));
    }
}

输入和输出认证消息的 JSON 模式 - 从认证端点的角度定义输入和输出消息的预期结构

{
    "$schema": "https://json-schema.fullstack.org.cn/schema#",
    "title": "AuthenticationRequest",
    "type": "object",
    "properties": {
    "login": {
        "type": "string",
        "pattern": "^[a-zA-Z]{1,10}$"
    },
    "password": {
        "type": "string"
    }
    },
    "required": [
    "login",
    "password"
    ]
}

{
"$schema": "https://json-schema.fullstack.org.cn/schema#",
"title": "AuthenticationResponse",
"type": "object",
"properties": {
    "isSuccess;": {
    "type": "boolean"
    },
    "token": {
    "type": "string",
    "pattern": "^[a-zA-Z0-9+/=\\._-]{0,500}$"
    },
    "message": {
    "type": "string",
    "pattern": "^[a-zA-Z0-9!\\s]{0,100}$"
    }
},
"required": [
    "isSuccess",
    "token",
    "message"
]
}

认证消息解码器和编码器 - 使用专用的 JSON 模式执行 JSON 序列化/反序列化以及输入/输出验证。这使得系统地确保端点接收和发送的所有消息严格遵守预期的结构和内容成为可能。

import com.fasterxml.jackson.databind.JsonNode;
import com.github.fge.jackson.JsonLoader;
import com.github.fge.jsonschema.core.exceptions.ProcessingException;
import com.github.fge.jsonschema.core.report.ProcessingReport;
import com.github.fge.jsonschema.main.JsonSchema;
import com.github.fge.jsonschema.main.JsonSchemaFactory;
import com.google.gson.Gson;
import org.owasp.pocwebsocket.vo.AuthenticationRequest;

import javax.websocket.DecodeException;
import javax.websocket.Decoder;
import javax.websocket.EndpointConfig;
import java.io.File;
import java.io.IOException;

/**
 * Decode JSON text representation to an AuthenticationRequest object
 * <p>
 * As there's one instance of the decoder class by endpoint session so we can use the
 * JsonSchema as decoder instance variable.
 */
public class AuthenticationRequestDecoder implements Decoder.Text<AuthenticationRequest> {

    /**
     * JSON validation schema associated to this type of message
     */
    private JsonSchema validationSchema = null;

    /**
     * Initialize decoder and associated JSON validation schema
     *
     * @throws IOException If any error occur during the object creation
     * @throws ProcessingException If any error occur during the schema loading
     */
    public AuthenticationRequestDecoder() throws IOException, ProcessingException {
        JsonNode node = JsonLoader.fromFile(
                        new File("src/main/resources/authentication-request-schema.json"));
        this.validationSchema = JsonSchemaFactory.byDefault().getJsonSchema(node);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public AuthenticationRequest decode(String s) throws DecodeException {
        try {
            //Validate the provided representation against the dedicated schema
            //Use validation mode with report in order to enable further inspection/tracing
            //of the error details
            //Moreover the validation method "validInstance()" generate a NullPointerException
            //if the representation do not respect the expected schema
            //so it's more proper to use the validation method with report
            ProcessingReport validationReport = this.validationSchema.validate(JsonLoader.fromString(s),
                                                                               true);
            //Ensure there no error
            if (!validationReport.isSuccess()) {
                //Simply reject the message here: Don't care about error details...
                throw new DecodeException(s, "Validation of the provided representation failed !");
            }
        } catch (IOException | ProcessingException e) {
            throw new DecodeException(s, "Cannot validate the provided representation to a"
                                      + " JSON valid representation !", e);
        }

        return new Gson().fromJson(s, AuthenticationRequest.class);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean willDecode(String s) {
        boolean canDecode = false;

        //If the provided JSON representation is empty/null then we indicate that
        //representation cannot be decoded to our expected object
        if (s == null || s.trim().isEmpty()) {
            return canDecode;
        }

        //Try to cast the provided JSON representation to our object to validate at least
        //the structure (content validation is done during decoding)
        try {
            AuthenticationRequest test = new Gson().fromJson(s, AuthenticationRequest.class);
            canDecode = (test != null);
        } catch (Exception e) {
            //Ignore explicitly any casting error...
        }

        return canDecode;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void init(EndpointConfig config) {
        //Not used
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void destroy() {
        //Not used
    }
}
import com.fasterxml.jackson.databind.JsonNode;
import com.github.fge.jackson.JsonLoader;
import com.github.fge.jsonschema.core.exceptions.ProcessingException;
import com.github.fge.jsonschema.core.report.ProcessingReport;
import com.github.fge.jsonschema.main.JsonSchema;
import com.github.fge.jsonschema.main.JsonSchemaFactory;
import com.google.gson.Gson;
import org.owasp.pocwebsocket.vo.AuthenticationResponse;

import javax.websocket.EncodeException;
import javax.websocket.Encoder;
import javax.websocket.EndpointConfig;
import java.io.File;
import java.io.IOException;

/**
 * Encode AuthenticationResponse object to JSON text representation.
 * <p>
 * As there one instance of the encoder class by endpoint session so we can use
 * the JsonSchema as encoder instance variable.
 */
public class AuthenticationResponseEncoder implements Encoder.Text<AuthenticationResponse> {

    /**
     * JSON validation schema associated to this type of message
     */
    private JsonSchema validationSchema = null;

    /**
     * Initialize encoder and associated JSON validation schema
     *
     * @throws IOException If any error occur during the object creation
     * @throws ProcessingException If any error occur during the schema loading
     */
    public AuthenticationResponseEncoder() throws IOException, ProcessingException {
        JsonNode node = JsonLoader.fromFile(
                        new File("src/main/resources/authentication-response-schema.json"));
        this.validationSchema = JsonSchemaFactory.byDefault().getJsonSchema(node);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String encode(AuthenticationResponse object) throws EncodeException {
        //Generate the JSON representation
        String json = new Gson().toJson(object);
        try {
            //Validate the generated representation against the dedicated schema
            //Use validation mode with report in order to enable further inspection/tracing
            //of the error details
            //Moreover the validation method "validInstance()" generate a NullPointerException
            //if the representation do not respect the expected schema
            //so it's more proper to use the validation method with report
            ProcessingReport validationReport = this.validationSchema.validate(JsonLoader.fromString(json),
                                                                                true);
            //Ensure there no error
            if (!validationReport.isSuccess()) {
                //Simply reject the message here: Don't care about error details...
                throw new EncodeException(object, "Validation of the generated representation failed !");
            }
        } catch (IOException | ProcessingException e) {
            throw new EncodeException(object, "Cannot validate the generated representation to a"+
                                              " JSON valid representation !", e);
        }

        return json;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void init(EndpointConfig config) {
        //Not used
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void destroy() {
        //Not used
    }

}

请注意,在 POC 的消息处理部分也使用了相同的方法。客户端和服务器之间交换的所有消息都以相同的方式进行系统验证,使用链接到消息专用编码器/解码器(序列化/反序列化)的专用 JSON 模式。

授权和访问令牌显式失效

授权信息使用 JWT 的声明(Claim)特性存储在访问令牌中(在 POC 中,声明的名称是 access_level)。当接收到请求时,以及在使用用户输入信息执行任何其他操作之前,都会验证授权。

访问令牌随发送到消息端点的每条消息传递,并使用拒绝列表以允许用户请求显式令牌失效。

从用户的角度来看,显式令牌失效很有趣,因为通常在使用令牌时,令牌的有效期相对较长(通常超过1小时),因此重要的是允许用户有一种方式向系统表明“好的,我已完成与您的交互,您可以关闭我们的交互会话并清理相关链接。”

如果检测到使用相同令牌的恶意并发访问(令牌窃取的情况),它还有助于用户自行撤销当前访问权限。

令牌拒绝列表 - 使用内存和时间有限的缓存维护一个临时列表,其中包含不再允许使用的令牌哈希值。

import org.apache.commons.jcs.JCS;
import org.apache.commons.jcs.access.CacheAccess;
import org.apache.commons.jcs.access.exception.CacheException;

import javax.xml.bind.DatatypeConverter;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
 * Utility class to manage the access token that have been declared as no
 * more usable (explicit user logout)
 */
public class AccessTokenBlocklistUtils {
    /**
     * Message content send by user that indicate that the access token that
     * come along the message must be block-listed for further usage
     */
    public static final String MESSAGE_ACCESS_TOKEN_INVALIDATION_FLAG = "INVALIDATE_TOKEN";

    /**
     * Use cache to store block-listed token hash in order to avoid memory exhaustion and be consistent
     * because token are valid 30 minutes so the item live in cache 60 minutes
     */
    private static final CacheAccess<String, String> TOKEN_CACHE;

    static {
        try {
            TOKEN_CACHE = JCS.getInstance("default");
        } catch (CacheException e) {
            throw new RuntimeException("Cannot init token cache !", e);
        }
    }

    /**
     * Add token into the denylist
     *
     * @param token Token for which the hash must be added
     * @throws NoSuchAlgorithmException If SHA256 is not available
     */
    public static void addToken(String token) throws NoSuchAlgorithmException {
        if (token != null && !token.trim().isEmpty()) {
            String hashHex = computeHash(token);
            if (TOKEN_CACHE.get(hashHex) == null) {
                TOKEN_CACHE.putSafe(hashHex, hashHex);
            }
        }
    }

    /**
     * Check if a token is present in the denylist
     *
     * @param token Token for which the presence of the hash must be verified
     * @return TRUE if token is block-listed
     * @throws NoSuchAlgorithmException If SHA256 is not available
     */
    public static boolean isBlocklisted(String token) throws NoSuchAlgorithmException {
        boolean exists = false;
        if (token != null && !token.trim().isEmpty()) {
            String hashHex = computeHash(token);
            exists = (TOKEN_CACHE.get(hashHex) != null);
        }
        return exists;
    }

    /**
     * Compute the SHA256 hash of a token
     *
     * @param token Token for which the hash must be computed
     * @return The hash encoded in HEX
     * @throws NoSuchAlgorithmException If SHA256 is not available
     */
    private static String computeHash(String token) throws NoSuchAlgorithmException {
        String hashHex = null;
        if (token != null && !token.trim().isEmpty()) {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] hash = md.digest(token.getBytes());
            hashHex = DatatypeConverter.printHexBinary(hash);
        }
        return hashHex;
    }

}

消息处理 - 处理用户将消息添加到列表的请求。展示一个授权验证方法的示例。

import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.owasp.pocwebsocket.enumeration.AccessLevel;
import org.owasp.pocwebsocket.util.AccessTokenBlocklistUtils;
import org.owasp.pocwebsocket.util.AuthenticationUtils;
import org.owasp.pocwebsocket.util.MessageUtils;
import org.owasp.pocwebsocket.vo.MessageRequest;
import org.owasp.pocwebsocket.vo.MessageResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.websocket.EncodeException;
import javax.websocket.RemoteEndpoint;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
 * Handle message flow
 */
public class MessageHandler implements javax.websocket.MessageHandler.Whole<MessageRequest> {

    private static final Logger LOG = LoggerFactory.getLogger(MessageHandler.class);

    /**
     * Reference to the communication channel with the client
     */
    private RemoteEndpoint.Basic clientConnection;

    /**
     * Constructor
     *
     * @param clientConnection Reference to the communication channel with the client
     */
    public MessageHandler(RemoteEndpoint.Basic clientConnection) {
        this.clientConnection = clientConnection;
    }


    /**
     * {@inheritDoc}
     */
    @Override
    public void onMessage(MessageRequest message) {
        MessageResponse response = null;
        try {
            /*Step 1: Verify the token*/
            String token = message.getToken();
            //Verify if is it in the block list
            if (AccessTokenBlocklistUtils.isBlocklisted(token)) {
                throw new IllegalAccessException("Token is in the block list !");
            }

            //Verify the signature of the token
            DecodedJWT decodedToken = AuthenticationUtils.validateToken(token);

            /*Step 2: Verify the authorization (access level)*/
            Claim accessLevel = decodedToken.getClaim("access_level");
            if (accessLevel == null || AccessLevel.valueOf(accessLevel.asString()) == null) {
                throw new IllegalAccessException("Token have an invalid access level claim !");
            }

            /*Step 3: Do the expected processing*/
            //Init the list of the messages for the current user
            if (!MessageUtils.MESSAGES_DB.containsKey(decodedToken.getSubject())) {
                MessageUtils.MESSAGES_DB.put(decodedToken.getSubject(), new ArrayList<>());
            }

            //Add message to the list of message of the user if the message is a not a token invalidation
            //order otherwise add the token to the block list
            if (AccessTokenBlocklistUtils.MESSAGE_ACCESS_TOKEN_INVALIDATION_FLAG
                .equalsIgnoreCase(message.getContent().trim())) {
                AccessTokenBlocklistUtils.addToken(message.getToken());
            } else {
                MessageUtils.MESSAGES_DB.get(decodedToken.getSubject()).add(message.getContent());
            }

            //According to the access level of user either return only is message or return all message
            List<String> messages = new ArrayList<>();
            if (accessLevel.asString().equals(AccessLevel.USER.name())) {
                MessageUtils.MESSAGES_DB.get(decodedToken.getSubject())
                .forEach(s -> messages.add(String.format("(%s): %s", decodedToken.getSubject(), s)));
            } else if (accessLevel.asString().equals(AccessLevel.ADMIN.name())) {
                MessageUtils.MESSAGES_DB.forEach((k, v) ->
                v.forEach(s -> messages.add(String.format("(%s): %s", k, s))));
            }

            //Build the response object indicating that exchange succeed
            if (AccessTokenBlocklistUtils.MESSAGE_ACCESS_TOKEN_INVALIDATION_FLAG
                .equalsIgnoreCase(message.getContent().trim())) {
                response = new MessageResponse(true, messages, "Token added to the block list");
            }else{
                response = new MessageResponse(true, messages, "");
            }

        } catch (Exception e) {
            LOG.error("[MessageHandler] Error occur in exchange process.", e);
            //Build the response object indicating that exchange fail
            //We send the error detail on client because ware are in POC (it will not the case in a real app)
            response = new MessageResponse(false, new ArrayList<>(), "Error occur during exchange: "
                       + e.getMessage());
        } finally {
            //Send response
            try {
                this.clientConnection.sendObject(response);
            } catch (IOException | EncodeException e) {
                LOG.error("[MessageHandler] Error occur in response object sending.", e);
            }
        }
    }
}

保密性和完整性

如果使用协议的原始版本(协议 ws://),则传输的数据容易遭受窃听和潜在的即时篡改。

使用 Wireshark 捕获并在存储的 PCAP 文件中搜索密码交换的示例,命令结果中已明确移除非可打印字符。

$ grep -aE '(password)' capture.pcap
{"login":"bob","password":"bob123"}

在 WebSocket 端点级别,可以通过调用 session 对象实例上的 isSecure() 方法来检查通道是否安全。

在负责会话设置并影响消息处理程序的端点方法中的实现示例:

/**
 * Handle the beginning of an exchange
 *
 * @param session Exchange session information
 */
@OnOpen
public void start(Session session) {
    ...
    //Affect a new message handler instance in order to process the exchange only if the channel is secured
    if(session.isSecure()) {
        session.addMessageHandler(new AuthenticationMessageHandler(session.getBasicRemote()));
    }else{
        LOG.info("[AuthenticationEndpoint] Session {} do not use a secure channel so no message handler " +
                 "was affected for processing and session was explicitly closed !", session.getId());
        try{
            session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT,"Insecure channel used !"));
        }catch(IOException e){
            LOG.error("[AuthenticationEndpoint] Session {} cannot be explicitly closed !", session.getId(),
                      e);
        }

    }
    LOG.info("[AuthenticationEndpoint] Session {} message handler affected for processing", session.getId());
}

仅通过 wss:// 协议(基于 SSL/TLS 的 WebSocket)公开 WebSocket 端点,以确保流量的保密性完整性,就像使用基于 SSL/TLS 的 HTTP 来保护 HTTP 交换一样。