可复用的持有者访问令牌把约定访问能力交给持有它的人;发送者约束令牌还要求提供与发送者绑定的证明。把令牌放进地址,会让这项能力进入平时就会复制地址的系统,例如访问日志、监控记录、浏览器导航历史、书签和分享链接。具体暴露范围取决于请求及策略,但查询参数的名称看起来像秘密,并不会自动让它受到保护。传输位置本身就是凭据披露边界的一部分,应当结合它会经过哪些记录和分享环节判断。
对于 OAuth 持有者令牌请求,应采用 Authorization 请求头;RFC 9700 现已禁止客户端按照 RFC 6750 的查询方式,在 URI 查询参数中传递访问令牌。HTTPS 仍然不可缺少,因为把值从查询移动到请求头并不会加密它;TLS 保护的是到终止位置为止的传输数据。传输结束后,应用和中间组件仍然能够检查字段。请求头的直接收益,是让令牌离开请求地址,而不是保证它永远不会被日志或诊断工具捕获。不能把两种不同的保护作用混为一谈。
分清减少暴露与绝对保密
某些访问日志只记录方法、请求目标和状态。在这种格式里,请求头令牌不会出现在目标字段中,而查询令牌会出现。但其他系统可能采集请求头,因此授权字段仍需在日志、链路和错误报告配置中被明确处理。应验证真实管线究竟保存了什么,不能假定请求头可以绕过全部日志层。检查时使用无效的合成值即可,不应为了演示而把真正凭据再次写入更多记录。
引用来源策略能够减少源地址向后续目标披露,却不会删除已经发给目的地的查询内容,也不会抹去浏览器记录或改写已复制的链接。同样,禁止缓存响应并不是要求清除全部访问日志和导航历史的通用指令。把令牌移到片段,会改变 HTTP 请求发送哪些内容,但值仍可被页面脚本和浏览器地址状态访问,也不是适合所有场景的凭据存储方案。
坏掉的样子
团队可能把接口认证从查询参数改成请求头,却让旧的带令牌地址继续留在报告和共享书签中。传输方式改变并不会撤销此前暴露,需要调查受影响的副本,并在系统支持时使已经暴露的可复用凭据失效。另一种误判是把有效期很短的链接称为泄露也无所谓;在有效期间,任何取得这项能力的人仍然可能使用它。缩短时间窗口只能减少风险,不能取消披露的后果。
当实际客户端流程需要时,签名地址可以是有意设计的、范围很窄的资源访问方式,但允许访问的资源、操作、期限和处理规则都应明确审阅。到期失效与只能使用一次并不相同,要阻止到期之前重复使用,需要真正强制执行消费状态的机制。简单的回调接口或浏览器导航,也不会自动构成把无限制可复用接口凭据放进查询的理由,应根据真实客户端选择合适的认证流程。
快速验证
保存代码为 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 终止、日志脱敏或签名链接的单次消费。应用到实际客户端和日志系统时,应尽量让可复用凭据离开地址表面,对仍然承载凭据的字段明确进行保护,并验证请求结束后继续存在的存储和分享路径。这样才能知道传输位置改变究竟减少了哪些暴露,以及哪些责任仍然需要完成。
延伸阅读
来源
RFC 6750 OAuth 2.0 Bearer Token Usage,授权请求头用法、查询传递的限制及 TLS 要求,已核验 2026-09-13。
OWASP information exposure through URL query strings,地址在日志和浏览器记录中的留存与暴露,已核验 2026-09-13。
MDN Referrer-Policy,禁止引用来源信息能够控制的范围,已核验 2026-09-13。
RFC 9700 OAuth 2.0 Security BCP,查询令牌禁令及发送者约束令牌,已核验 2026-10-18。
W3C Resource Timing,资源条目保存请求地址,与 Referer 是独立字段,已核验 2026-10-18。
RFC 9111 HTTP Caching,缓存指令与应用另行实现的历史记录边界,已核验 2026-10-18。
Python urllib.request,默认代理发现与显式请求打开器的范围,已核验 2026-10-18。
Python socketserver,shutdown 须从另一个线程调用,已核验 2026-10-19。
WHATWG Fetch,中止信号及重定向错误模式,已核验 2026-10-19。
WHATWG DOM,AbortSignal 超时及活动时间限制,已核验 2026-10-19。