可复用访问令牌会把某种约定访问能力交给持有它的人。把令牌放进地址,会让这项能力进入平时就会复制地址的系统,例如访问日志、监控记录、浏览器导航历史、书签和分享链接。具体暴露范围取决于请求及策略,但查询参数的名称看起来像秘密,并不会自动让它受到保护。传输位置本身就是凭据披露边界的一部分,应当结合它会经过哪些记录和分享环节判断。

对于 OAuth 持有者令牌请求,通常应采用授权请求头机制。HTTPS 仍然不可缺少,因为把值从查询移动到请求头并不会加密它;TLS 保护的是到终止位置为止的传输数据。传输结束后,应用和中间组件仍然能够检查字段。请求头的直接收益,是让令牌离开请求地址,而不是保证它永远不会被日志或诊断工具捕获。不能把两种不同的保护作用混为一谈。

分清减少暴露与绝对保密

某些访问日志只记录方法、请求目标和状态。在这种格式里,请求头令牌不会出现在目标字段中,而查询令牌会出现。但其他系统可能采集请求头,因此授权字段仍需在日志、链路和错误报告配置中被明确处理。应验证真实管线究竟保存了什么,不能假定请求头可以绕过全部日志层。检查时使用无效的合成值即可,不应为了演示而把真正凭据再次写入更多记录。

引用来源策略能够减少源地址向后续目标披露,却不会删除已经发给目的地的查询内容,也不会抹去浏览器记录或改写已复制的链接。同样,禁止缓存响应并不是要求清除全部访问日志和导航历史的通用指令。把令牌移到片段,会改变 HTTP 请求发送哪些内容,但值仍可被页面脚本和浏览器地址状态访问,也不是适合所有场景的凭据存储方案。

坏掉的样子

团队可能把接口认证从查询参数改成请求头,却让旧的带令牌地址继续留在报告和共享书签中。传输方式改变并不会撤销此前暴露,需要调查受影响的副本,并在系统支持时使已经暴露的可复用凭据失效。另一种误判是把有效期很短的链接称为泄露也无所谓;在有效期间,任何取得这项能力的人仍然可能使用它。缩短时间窗口只能减少风险,不能取消披露的后果。

当实际客户端流程需要时,签名地址可以是有意设计的、范围很窄的资源访问方式,但允许访问的资源、操作、期限和处理规则都应明确审阅。到期失效与只能使用一次并不相同,要阻止到期之前重复使用,需要真正强制执行消费状态的机制。简单的回调接口或浏览器导航,也不会自动构成把无限制可复用接口凭据放进查询的理由,应根据真实客户端选择合适的认证流程。

快速验证

保存代码为 token-placement.py,执行 python3 token-placement.py –serve,打开打印出的页面。本次使用 Python 3.14.4 和 Chrome 152,全部演示值都是明确无效的固定标记,不能替换为真实凭据。回环服务只是观察实验,不是认证服务;它记录请求目标以及示例授权头是否存在,不记录该请求头的值。结束后按控制键加 C 关闭服务,临时事件仅留在本次进程的内存中。

import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.parse import urlsplit
from urllib.request import Request, urlopen

# Synthetic demonstration value only; this server is not an authentication service.
demo = "DEMO-NOT-A-SECRET"
events = []


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_GET(self):
        path = urlsplit(self.path).path
        if path == "/events":
            body = json.dumps(events).encode()
            kind = "application/json"
        elif path == "/echo":
            entry = {"request_target": self.path,
                     "authorization_present": self.headers.get("Authorization") == "Bearer " + demo,
                     "referer": self.headers.get("Referer")}
            events.append(entry)
            body = json.dumps(entry).encode()
            kind = "application/json"
        else:
            events.append({"request_target": self.path, "document": True})
            body = ("""<!doctype html><meta charset="utf-8"><title>Token placement lab</title>
<h1>A clean referrer does not erase the request URL</h1><pre id="result">Running...</pre><script>
const demo=""" + json.dumps(demo) + """;
async function run() {
  const query=await (await fetch('/echo?access_token='+demo)).json();
  const header=await (await fetch('/echo',{headers:{Authorization:'Bearer '+demo}})).json();
  await new Promise(resolve=>requestAnimationFrame(resolve));
  const resourceUrls=performance.getEntriesByType('resource').map(entry=>entry.name).filter(name=>name.includes('/echo'));
  window.labResult={page:location.href,query,header,resourceUrls};
  document.getElementById('result').textContent=JSON.stringify(window.labResult,null,2);
}
run().catch(error=>{window.labError=String(error);});
</script>""").encode()
            kind = "text/html; charset=utf-8"
        self.send_response(200)
        self.send_header("Content-Type", kind)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Referrer-Policy", "no-referrer")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)


server = HTTPServer(("127.0.0.1", 0), Handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
origin = f"http://127.0.0.1:{server.server_port}"
try:
    with urlopen(origin + "/echo?access_token=" + demo, timeout=5) as response:
        query = json.load(response)
    with urlopen(Request(origin + "/echo", headers={"Authorization": "Bearer " + demo}), timeout=5) as response:
        header = json.load(response)
    assert demo in query["request_target"] and query["authorization_present"] is False
    assert demo not in header["request_target"] and header["authorization_present"] is True
    events.clear()
    print(json.dumps({"app_url": origin + "/?access_token=" + demo,
                      "wire_placement_checks": "PASS", "real_credentials_used": False}), flush=True)
    if "--serve" in sys.argv:
        Event().wait()
except KeyboardInterrupt:
    pass
finally:
    server.shutdown()
    thread.join(timeout=5)
    server.server_close()

页面先通过 access_token 查询发出一次请求,再通过授权请求头发出另一次请求。因为页面设置了不发送引用来源,两次接收结果中的 Referer 都为空。然而查询请求的目标仍然包含演示标记,请求头方案的目标则只是 echo 路径。浏览器资源计时条目中的地址也体现同样差异。页面自身的地址同样带有演示标记,因此当前导航记录仍保留这条完整地址,不能因为后续请求没有来源头就认为它消失了。

这些观察分别与浏览器状态和服务端合成事件列表核对,单独执行的 Python 探针另外确认了两种请求目标的区别。实验没有验证生产认证、TLS 终止、日志脱敏或签名链接的单次消费。应用到实际客户端和日志系统时,应尽量让可复用凭据离开地址表面,对仍然承载凭据的字段明确进行保护,并验证请求结束后继续存在的存储和分享路径。这样才能知道传输位置改变究竟减少了哪些暴露,以及哪些责任仍然需要完成。

延伸阅读

来源