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

对于 OAuth 持有者令牌请求,应采用 Authorization 请求头;RFC 9700 现已禁止客户端按照 RFC 6750 的查询方式,在 URI 查询参数中传递访问令牌。HTTPS 仍然不可缺少,因为把值从查询移动到请求头并不会加密它;TLS 保护的是到终止位置为止的传输数据。传输结束后,应用和中间组件仍然能够检查字段。请求头的直接收益,是让令牌离开请求地址,而不是保证它永远不会被日志或诊断工具捕获。不能把两种不同的保护作用混为一谈。

分清减少暴露与绝对保密

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

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

坏掉的样子

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

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

OAuth 查询禁令之后对比查询目标与请求头目标,再分别展示禁发 Referer 仍存在的 URL 记录、独立签名资源设计及保留的技术和浏览器阻塞。
请求头位置减少 URL 暴露,但请求头仍须保护。签名资源 URL 是独立限定的设计,不是 OAuth 例外。运行与技术检查仍受阻。

快速验证

保存代码为 token-placement.py,执行 python3 token-placement.py --serve,打开打印出的页面。原文报告使用 Python 3.14.4 和 Chrome 152,本轮源码复核没有复现。显式 Python 检查在优化模式下仍会执行;全部演示值都是明确无效的固定标记,不能替换为真实凭据。回环服务只是观察实验,不是认证服务;它记录请求目标以及示例授权头是否存在,不记录该请求头的值。Python 已禁用代理发现并拒绝重定向,浏览器代理策略仍须单独控制。服务模式默认保留 120 秒,--serve-seconds 接受 1–300;控制键加 C 也会请求清理。只有单独的清理结果确认服务线程停止且监听关闭;有时限的 join 未能确认结束就会报错。临时事件只保存在进程内存,前面的检查通过不证明清理已经完成。

import argparse
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener

parser = argparse.ArgumentParser()
parser.add_argument("--serve", action="store_true")
parser.add_argument("--serve-seconds", type=int, default=120)
args = parser.parse_args()
if not 1 <= args.serve_seconds <= 300:
    parser.error("--serve-seconds must be between 1 and 300")

def require(condition, message):
    if not condition:
        raise AssertionError(message)

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


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise RuntimeError("Unexpected redirect in local fixture")


opener = build_opener(ProxyHandler({}), NoRedirect())


class LocalServer(HTTPServer):
    def get_request(self):
        connection, address = super().get_request()
        connection.settimeout(2)
        return connection, address


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"
        elif path == "/":
            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) + """;
function require(condition, message) {
  if (!condition) throw new Error(message);
}
function verifyObservation(result, origin) {
  const queryTarget='/echo?access_token='+demo;
  require(new URL(result.page).origin===origin &&
          new URL(result.page).pathname==='/' &&
          new URL(result.page).search==='?access_token='+demo, 'page URL mismatch');
  require(result.query.request_target===queryTarget &&
          result.query.authorization_present===false, 'query placement mismatch');
  require(result.header.request_target==='/echo' &&
          result.header.authorization_present===true, 'header placement mismatch');
  require(result.query.referer===null && result.header.referer===null,
          'Referer was not suppressed');
  require(result.resourceUrls.includes(origin+queryTarget) &&
          result.resourceUrls.includes(origin+'/echo'), 'resource URLs missing');
  require(result.events.some(e=>e.document===true &&
          e.request_target==='/?access_token='+demo), 'document event missing');
  for (const expected of [result.query,result.header]) {
    require(result.events.some(e=>e.request_target===expected.request_target &&
            e.authorization_present===expected.authorization_present &&
            e.referer===null), 'matching server event missing');
  }
}
async function readJson(path, options={}) {
  const response=await fetch(path,{...options,redirect:'error',
                                  signal:AbortSignal.timeout(5000)});
  require(response.ok, 'HTTP status '+response.status);
  return response.json();
}
async function run() {
  require(location.protocol==='http:' && location.hostname==='127.0.0.1',
          'Use the printed loopback URL');
  performance.clearResourceTimings();
  const query=await readJson('/echo?access_token='+demo);
  const header=await readJson('/echo',{headers:{Authorization:'Bearer '+demo}});
  const deadline=performance.now()+5000;
  let resourceUrls=[];
  do {
    resourceUrls=performance.getEntriesByType('resource').map(e=>e.name)
      .filter(name=>new URL(name).pathname==='/echo');
    if (resourceUrls.length>=2) break;
    await new Promise(resolve=>setTimeout(resolve,25));
  } while (performance.now()<deadline);
  const result={page:location.href,query,header,resourceUrls,
                events:await readJson('/events')};
  verifyObservation(result,location.origin);
  window.labResult={status:'PASS',...result};
  document.getElementById('result').textContent=JSON.stringify(window.labResult,null,2);
}
run().catch(error=>{
  window.labError=String(error);
  window.labResult={status:'FAIL',error:window.labError};
  document.getElementById('result').textContent=JSON.stringify(window.labResult,null,2);
});
</script>""").encode()
            kind = "text/html; charset=utf-8"
        else:
            self.send_error(404)
            return
        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)


def close_server(server, thread):
    stopper = None
    try:
        if thread.is_alive():
            stopper = Thread(target=server.shutdown, daemon=True)
            stopper.start()
            stopper.join(timeout=3)
        if thread.ident is not None:
            thread.join(timeout=3)
        require((stopper is None or not stopper.is_alive()) and not thread.is_alive(),
                "Server shutdown not verified")
    finally:
        server.server_close()
    require(server.socket.fileno() == -1, "Listener close not verified")
    print(json.dumps({"cleanup": "server thread stopped and listener closed"}), flush=True)


server = LocalServer(("127.0.0.1", 0), Handler)
thread = Thread(target=lambda: server.serve_forever(poll_interval=0.1), daemon=True)
origin = f"http://127.0.0.1:{server.server_port}"
try:
    thread.start()
    with opener.open(origin + "/echo?access_token=" + demo, timeout=5) as response:
        query = json.load(response)
    with opener.open(Request(origin + "/echo", headers={"Authorization": "Bearer " + demo}), timeout=5) as response:
        header = json.load(response)
    require(query["request_target"] == "/echo?access_token=" + demo and
            query["authorization_present"] is False, "query placement mismatch")
    require(header["request_target"] == "/echo" and
            header["authorization_present"] is True, "header placement mismatch")
    events.clear()
    print(json.dumps({"app_url": origin + "/?access_token=" + demo,
                      "wire_placement_checks": "PASS", "browser_checks": "NOT RUN BY PYTHON",
                      "serve_seconds": args.serve_seconds if args.serve else 0,
                      "real_credentials_used": False}), flush=True)
    if args.serve:
        Event().wait(args.serve_seconds)
except KeyboardInterrupt:
    pass
finally:
    close_server(server, thread)

页面先要求使用输出的回环地址,再分别发送查询令牌与授权请求头请求,每次获取数据都有五秒中止信号。浏览器断言要求收到的 Referer 为空、两个请求目标完全符合预期、资源计时包含两个完整地址、当前页面地址仍带演示标记,并且服务端事件能对应页面及两次请求。资源条目另有五秒轮询截止时间,浏览器挂起可能延迟计时器,全部满足后页面才显示 PASS;缺失或不匹配就显示 FAIL。当前页面地址只是当前导航状态的证据,不等于完整检查了持久浏览历史;禁止来源头也不会把已经发送的查询内容抹去。

原文报告曾对照浏览器状态与服务端事件列表,但本轮未恢复可绑定到当前源码的浏览器轨迹。Python 只检查请求目标位置和演示请求头匹配;新增浏览器断言在本轮未执行,Python 输出不能替它们背书。运行及端到端技术验收仍保留阻塞状态。实验没有验证生产认证、TLS 终止、日志脱敏或签名链接的单次消费。应用到实际客户端和日志系统时,应尽量让可复用凭据离开地址表面,对仍然承载凭据的字段明确进行保护,并验证请求结束后继续存在的存储和分享路径。这样才能知道传输位置改变究竟减少了哪些暴露,以及哪些责任仍然需要完成。

延伸阅读

来源