标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

密码哈希需要慢一点的代价

你选择的哈希不是给数据库看的,而是给偷走数据库的那方看的。专用的密码哈希增加泄露后离线猜测的成本,但不能承诺弱口令不会被恢复。 快不是正确的目标 通用哈希如 SHA-256 刻意追求速度,使离线攻击者能够高效测试猜测;实际速度取决于算法与硬件,不能套用固定的显卡吞吐。专用密码算法引入可调成本,并使用独立随机盐,使每个猜测付出工作,同一口令在不同盐下产生不同存值。优先考虑适用的内存困难方案,并在真正承担登录验证的硬件上选择可接受成本。慢是防御的一部分,却不是越慢越好,也不能替代登录限流和可用性预算。 为被拷贝的那天设计 应把数据库泄露作为需要提前规划的威胁,而不是为它编造确定破解期限。弱口令即使用强方案仍可能很快被猜中;强口令也不因为使用快哈希就必然立即恢复。独立随机盐可以阻止预计算表复用,并要求攻击者按不同盐分别付出工作。盐通常与校验值一起公开存储,不是加密密钥,也不增加口令本身的熵。哈希提高猜测成本,却不会让泄露自动无害,更不能代替事件响应和受影响账号的保护。 成本可调带来灵活 把算法与成本的选择放进一个可切换的封装,并预见到成本会随硬件与算法演进。当你把 work factor 调高、或迁移到更强的方案时,已入库的旧哈希仍带着旧的成本;稳妥的做法是在登录成功那一刻顺手把该用户的重哈希做掉并写回。这样可以逐步更新回来登录的账号,而不必立即要求全体用户重置密码。必须先使用旧记录保存的算法、盐和参数验证。成功以后,再按当前策略判断是否需要用新参数重新派生。直接用最新参数验证旧值,会拒绝合法口令。长期不登录的账号不会自动迁移,也需要明确的迁移或重置计划,不能把渐进更新说成所有账号已经完成升级。 坏掉的样子 只保存一遍快哈希,会让每次离线猜测变得便宜;最终恢复比例仍取决于口令强度与攻击资源,不能承诺整张表必然在几小时内破解。没有盐会让情况更糟:相同明文得到完全相同的摘要,一次猜中就能撞开所有用了同一口令的账号,彩虹表可以在不同泄露之间复用,光是摘要相等本身,就已经泄露了谁和谁用了同一个口令。全局固定盐只挡住现成彩虹表,一次猜测仍可同时检验文件里所有账号,摊到每个账号的成本并没有真正涨上去。 第二类失败是成本因子被冻住。很多年前够用的 bcrypt 代价 04,或者只迭代几千次的 PBKDF2,放到今天的硬件上早就变薄,而入库的老哈希还带着当年参数,攻击者拿着旧备份用新机器重跑,当年余量就消失了。比较方式和可用性是接下来会断的地方。应使用密码库提供的验证器,或对派生值使用合适的抗时序比较;普通相等判断不提供密码学时序保证。反过来,若把无上限的慢哈希放在未鉴权登录入口,每次伪造请求都会变成由你买单的 CPU 开销,大量错误登录就能把服务打满,因此必须给最大工作量设上限,对登录做限流和计量,还要警惕静默截断,例如 bcrypt 超长截断的旧坑,或编码不一致让不同口令验出同一结果。 快速验证 把下面代码存为临时目录里的 hash_cost_demo.py,运行 python3 hash_cost_demo.py。它只用标准库(hashlib、hmac、os、time),不联网,不写磁盘。脚本把单次快哈希和拉伸哈希放在一起对比,证明同一口令在两个随机盐下得到不同摘要,并用恒定时间比较完成校验。 """Fast digest vs stretched password hash (stdlib only).""" import hashlib import hmac import os import time def check(ok, message): if not ok: raise AssertionError(message) def main(): password = b"correct-horse-123" wrong = b"correct-horse-124" # 1. Fast hashes are cheap: time thousands of single SHA-256 digests. rounds = 5000 start = time.perf_counter() for _ in range(rounds): hashlib.sha256(password).digest() fast_secs = time.perf_counter() - start print(f"fast: {rounds}x SHA-256 in {fast_secs*1000:.2f} ms") # 2. Stretching adds a tunable cost: one PBKDF2-HMAC-SHA256 derivation. # Demo uses a reduced 100k iterations for a short exercise; production # guidance asks for much more (see OWASP Password Storage). salt1 = os.urandom(16) salt2 = os.urandom(16) check(salt1 != salt2, "salts must differ") start = time.perf_counter() stored = hashlib.pbkdf2_hmac("sha256", password, salt1, 100_000) slow_secs = time.perf_counter() - start print(f"slow: 1x PBKDF2-HMAC-SHA256(100k) in {slow_secs*1000:.2f} ms") # 3. Random per-user salt: same password, different salts, different digests. other = hashlib.pbkdf2_hmac("sha256", password, salt2, 100_000) check(stored != other, "random salts must separate identical passwords") print(f"salt separates: {stored.hex()[:16]}... != {other.hex()[:16]}...") # 4. Negative control: a global salt keeps identical passwords identical. global_salt = b"site-wide-salt-01!" alice = hashlib.pbkdf2_hmac("sha256", password, global_salt, 100_000) bob = hashlib.pbkdf2_hmac("sha256", password, global_salt, 100_000) check(alice == bob, "global salt must link identical passwords") print("global salt links identical passwords (do not do this)") # 5. Verify by re-deriving with the stored salt, using constant-time compare. again = hashlib.pbkdf2_hmac("sha256", password, salt1, 100_000) check(hmac.compare_digest(stored, again), "same password+salt must verify") bad = hashlib.pbkdf2_hmac("sha256", wrong, salt1, 100_000) check(not hmac.compare_digest(stored, bad), "wrong password must not verify") check(hmac.compare_digest(stored, stored), "compare_digest self-check") check(not hmac.compare_digest(stored, other), "compare_digest mismatch-check") print("verify: correct password passes, wrong password fails") print(f"ratio: one stretched hash costs ~{(slow_secs / (fast_secs / rounds)):.0f}x one fast digest") print("PASS: per-salt derivation, global-salt control, and password verification checks") if __name__ == "__main__": main() 输出先给出数千次快哈希的总耗时,再给出一次拉伸哈希的耗时,具体比值由本次运行决定;随后证明同盐重算能复现存值,换盐则结果不同,错误口令无法通过校验,恒定时间比较只在输入完全相等时返回真。早先 macOS 运行属于历史记录,本轮两份完整示例在 Python 3.12.14 中通过,毫秒数随机器而变。演示为了保持简短而调低迭代次数,不能证明生产参数、显卡集群的真实破解成本,也未覆盖线上限流与过载预算,调高工作因子前,请用文档中的成本参数和登录洪水预算在真实校验器上再测一遍。 ...

六月 10, 2027 · 2 分钟 · XB Field Notes
观察、决策与行动三块面板,旁边是关于 SQL 值与查询结构的标题。

查询参数守住取值与 SQL 的边界

参数化查询通过不同的接口参数传递数据库指令与实际取值。数据库把指令解释为 SQL,再把各个参数用于需要值的位置。这种边界既适用于 SQLite 这样的嵌入式引擎,也适用于客户端连接服务端的数据库;它不依赖一次网络交互,更不要求应用显式管理命名的服务端语句。重点在真实调用如何传值,而不是有没有某个名为“预编译”的包装函数。 坏掉的样子 字符串拼接会拆掉这条边界。姓名中的一个引号可能提前结束原本的字面量,后面的文本就有机会变成条件或注释。仅仅禁用分号无法修复问题,因为攻击者可以在不追加第二条语句的情况下,改变同一条查询的含义。同样,一个接口即使拒绝一次执行多条语句,也仍然需要正确绑定参数,不能把这个限制当作注入防护已经完成的证据。 绑定可以防止值变成 SQL 语法,但所选运算符仍会按自身规则解释这个值。对于 LIKE,百分号匹配一段字符,下划线匹配一个字符。因此,一个经过绑定的搜索串,仍可能比用户期望的普通文本搜索匹配更多记录。这属于模式语义的问题,不说明绑定失效,却说明“输入不会执行成 SQL”还不等于“搜索符合产品要求”。 另外一些错误也不会造成注入,却会给出错误结果。把逗号分隔的文本交给一个 IN 占位符,它仍是一个标量,不会自动展开成列表。把列名当作值绑定,得到的仍是文本,不是列引用。因此,类型检查、允许的查询结构与业务权限,都需要在绑定之外独立成立。 快速验证 直接运行下面的完整 Python 脚本,只需要标准库 sqlite3。它创建内存数据库作为样本,完成断言后关闭连接。记录中的运行环境为 Python 3.12.14 与 SQLite 3.53.1。故意保留的脆弱查询仅用于比较三个虚构用户的结果,不连接任何真实业务数据: import sqlite3 with sqlite3.connect(':memory:') as db: db.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)') db.executemany('INSERT INTO users VALUES (?, ?)', [(1, 'Alice'), (2, 'A_1'), (3, 'Ab1')]) payload = "' OR 1=1 --" # Deliberately vulnerable comparison, only against this in-memory fixture. unsafe = "SELECT id FROM users WHERE name='" + payload + "' ORDER BY id" assert sorted(db.execute(unsafe).fetchall()) == [(1,), (2,), (3,)] assert db.execute('SELECT id FROM users WHERE name=?', (payload,)).fetchall() == [] print('concatenated: 3 rows; bound: 0 rows') for value in ("O'Reilly", r'a\b', 'x; DROP TABLE users; --'): assert db.execute('SELECT ?', (value,)).fetchone() == (value,) assert db.execute('SELECT count(*) FROM users').fetchone() == (3,) print('quote, backslash, semicolon: literal round trips') assert db.execute('SELECT id FROM users WHERE name LIKE ? ORDER BY id', ('A_1',)).fetchall() == [(2,), (3,)] literal = 'A_1' escaped = literal.replace('!', '!!').replace('%', '!%').replace('_', '!_') assert db.execute("SELECT id FROM users WHERE name LIKE ? ESCAPE '!' ORDER BY id", (escaped,)).fetchall() == [(2,)] assert db.execute('SELECT id FROM users WHERE name=?', (literal,)).fetchall() == [(2,)] print('bound LIKE: 2 rows; escaped LIKE and equality: 1 row') assert db.execute('SELECT id FROM users WHERE id IN (?)', ('1,3',)).fetchall() == [] assert db.execute('SELECT id FROM users WHERE id IN (?,?) ORDER BY id', (1, 3)).fetchall() == [(1,), (3,)] assert db.execute('SELECT ? FROM users LIMIT 1', ('name',)).fetchone() == ('name',) assert db.execute('SELECT id FROM users ORDER BY id LIMIT ? OFFSET ?', (1, 1)).fetchall() == [(2,)] assert db.execute('SELECT id FROM users ORDER BY id LIMIT ?', (-1,)).fetchall() == [(1,), (2,), (3,)] assert db.execute('SELECT id FROM users ORDER BY id LIMIT ?', (0,)).fetchall() == [] print('IN needs separate values; bound column is a literal; LIMIT/OFFSET bind') print('negative LIMIT removes the cap; zero LIMIT returns no rows') db.close() 拼接查询返回三个编号,绑定查询面对相同输入则没有任何匹配。恶意输入中的注释还会删掉原先追加的排序部分,因此断言先对脆弱查询的结果排序,再比较编号集合;实验没有依赖未声明顺序的查询恰巧按插入顺序返回。这也展示了注入会改变原指令的多个部分,不只是多匹配一行那么简单。 ...

五月 17, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调接收之前必须实际计数输入字节。

请求体限制划定边界

请求体限制约束的是实际接受的输入,而不只是 Content-Length 里写出的数字。应在消费正文时执行限制,并在验证完成之前避免业务提交。压缩输入还需要分别限制编码后的字节和解码后的字节。如果昂贵的内存分配或不可逆写入已经发生,最后再返回 413,并没有守住原本要保护的边界。 明确预算到底计算哪些字节 采用长度分帧时,Content-Length 描述消息正文的长度;HTTP/1.1 分块传输则可能没有预先确定的长度。已声明长度过大,可以提前拒绝,但缺少长度不等于允许无限读取。消息分帧交给 HTTP 实现处理,应用再针对它提供的正文读取器施加自己的预算,不能自行混淆报文边界。 正文读取器不包含 HTTP 头和分块编码的分帧开销,因此这里的限制不是整个网络请求的总字节配额。Content-Encoding: gzip 下,这些正文是压缩后的数据,解码表示可能大得多。应在解压之前约束编码输入,在解压之后再次约束输出;一个很小的压缩包,也可能超过合法的解码大小上限。 限制应来自具体端点的契约,包括单位和支持的内容编码。整个请求体的总量限制,与某个 multipart 文件或字段的限制也不同。流式处理避免一次缓存全部内容,却不会自动限制最终存储量、解析工作或慢速发送方占据请求的时间,不能把“使用流”直接等同于“资源已经受控”。 验证完成之后再暴露副作用 Go 的 MaxBytesReader 在读取超过限制时返回 MaxBytesError。普通 LimitReader 则会在边界处返回 EOF;如果没有额外判断,应用可能把被截断的前缀当成完整输入。最多读取“解码上限加一”个字节,才能区分恰好达到上限的合法正文与确实超过上限的正文。 对 gzip 而言,成功读到一个前缀并不代表校验和有效。在接受未超限的表示之前,需要读到结尾;只关闭解码器不会完成校验。文件发布、数据库修改等动作应放在完整验证成功之后,或者采用带明确中止路径的暂存方案。不能忽略读取错误,仅因为读取器同时返回了一些数据就执行提交。 坏掉的样子 只看 Content-Length 会遗漏未知长度输入;只在解压前使用一个大小上限,会遗漏解压膨胀;先解析、后拒绝,则可能已经花掉需要保护的资源。损坏 gzip 流可以在报错时同时返回正文前缀,错误处理若只检查正文非空,就可能把受损数据变成被接受的业务输入。 字节限制不能修复冲突的 HTTP 分帧,不能验证调用方或文件类型,也不能限制所有并发请求的内存总和。它还无法阻止发送方把很小的正文无限拖慢。因此,时间和并发量需要独立预算;不能把字节上限叫作空闲超时,也不能从一次拒绝成功推导出已经具备高负载防护能力。 快速验证 保存为 body_probe.go,使用 Go 1.23 或更新版本运行 go run body_probe.go。环回测试服务允许最多 64 个编码输入字节和八个解码字节,支持无内容编码或单层 gzip。这里刻意使用很小的常量,是为了验证边界,不是推荐生产参数。计数器代表应用提交点,不会真的写入数据库或上传文件。 ...

四月 18, 2027 · 3 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边是关于绑定地址与本地目的地址的标题。

绑定地址选择本地目的地,可达性还取决于完整路径

服务器的绑定地址决定套接字可以接受哪些本地目的地址。IPv4 监听绑定到 127.0.0.1,与绑定到通配地址 0.0.0.0,具有不同匹配范围。但任何一个设置都不能独立描述完整访问策略。路由、网络命名空间、包过滤、端口发布、代理和应用认证,共同决定请求怎样到达监听,以及连接之后能够执行什么操作。 坏掉的样子 通配监听不等于互联网已经能够访问服务。进程可能位于没有外部接口的隔离命名空间,也可能受到防火墙限制。反过来,回环监听也可以被接受远程请求的反向代理或隧道间接访问。判断谁能使用服务前,需要检查这些转发路径。不能看到监听地址就把网络路径、调用者身份和业务权限合并成一个结论。 IPv6 通配地址 :: 还涉及地址族选择。在 Linux 中,IPV6_V6ONLY 控制 IPv6 套接字是否也接受映射形式的 IPv4 连接。默认值取决于环境与应用,所以监听列表里只有一条 IPv6 项,并不能证明 IPv4 不可用。服务契约要求明确地址族边界时,应显式设置这个选项,并分别建立连接验证,而不是从显示格式推测行为。 指定地址也可能让服务在启动阶段失败。地址与端口被冲突套接字占用,和地址根本没有在本地配置,是不同错误。重用选项和非本地绑定机制会改变部分场景,本例没有启用这些机制。如果把所有绑定失败都解释成端口被占用,就可能去停止无关进程,却没有修复真正缺失的地址配置。错误码应与启动参数一起保留。 快速验证 将脚本保存为 check-bind-address.py,准备 Docker 后运行 python3 check-bind-address.py。固定 Python 3.11.16 镜像在一次性容器内使用 Linux 套接字,没有发布宿主端口。实验先比较两个 IPv4 回环地址,再显式改变 IPV6_V6ONLY。它不连接公网目标,也不修改宿主防火墙;所有监听和客户端都位于同一个可丢弃网络命名空间: import json,subprocess,uuid image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84" program=r""" from contextlib import ExitStack import errno,json,socket def read_exact(stream,length): result=b'' while len(result)<length: part=stream.recv(length-len(result));assert part,'unexpected end of stream' result+=part return result def listener(stack,address,family=socket.AF_INET,v6only=None): stream=stack.enter_context(socket.socket(family,socket.SOCK_STREAM)) if v6only is not None: stream.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,v6only) stream.settimeout(1);stream.bind((address,0));stream.listen(4) return stream def exchange(server,address): with socket.create_connection((address,server.getsockname()[1]),timeout=1) as client: connection,peer=server.accept() with connection: connection.settimeout(1);client.sendall(b'ping');assert read_exact(connection,4)==b'ping' connection.sendall(b'pong');assert read_exact(client,4)==b'pong' return {'listener':server.getsockname(),'accepted_local':connection.getsockname(),'peer':peer} def refused(server,address): try: with socket.create_connection((address,server.getsockname()[1]),timeout=1):pass except ConnectionRefusedError:return 'ECONNREFUSED' raise AssertionError('unexpectedly connected') with ExitStack() as stack: narrow=listener(stack,'127.0.0.1') one=exchange(narrow,'127.0.0.1');other=refused(narrow,'127.0.0.2') duplicate=stack.enter_context(socket.socket()) try:duplicate.bind(narrow.getsockname());raise AssertionError('duplicate bind succeeded') except OSError as error:assert error.errno==errno.EADDRINUSE unassigned=stack.enter_context(socket.socket()) try:unassigned.bind(('192.0.2.99',0));raise AssertionError('unassigned bind succeeded') except OSError as error:assert error.errno==errno.EADDRNOTAVAIL wildcard=listener(stack,'0.0.0.0') both=[exchange(wildcard,a) for a in ['127.0.0.1','127.0.0.2']] narrow.close();wildcard.close() only6=listener(stack,'::',socket.AF_INET6,1) six=exchange(only6,'::1');four_rejected=refused(only6,'127.0.0.1') dual=listener(stack,'::',socket.AF_INET6,0) mapped=exchange(dual,'127.0.0.1');assert mapped['peer'][0].startswith('::ffff:') print(json.dumps({'narrow':one,'other_loopback':other,'duplicate':'EADDRINUSE','unassigned':'EADDRNOTAVAIL', 'wildcard':both,'ipv6_only':six,'ipv6_only_v4':four_rejected,'dual_stack':mapped})) """ name='xbcatm-socket-'+uuid.uuid4().hex[:10] try: result=subprocess.run(['docker','run','--rm','-i','--name',name,'--network','none', '--memory','128m','--pids-limit','64',image,'python3','-'],input=program, capture_output=True,text=True,timeout=60) assert result.returncode==0,result.stderr print(json.dumps(json.loads(result.stdout),ensure_ascii=False,sort_keys=True)) finally: subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10) 指定地址监听接受发往 127.0.0.1 的请求并返回 pong,同端口发往 127.0.0.2 的连接被拒绝。两个目的地都属于回环,因此这个比较只隔离地址匹配行为,没有声称验证外部网络过滤。各监听使用端口零,让内核选择可用端口;程序随后读取实际端口再连接,避免硬编码端口恰好被其他测试占用。 ...

四月 2, 2027 · 1 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

Referrer-Policy 决定发送多少来源地址信息

HTTP 的 Referer 请求头可以告诉接收方,这次请求由哪个页面发起。它保留了历史拼写,与控制来源信息范围的 Referrer-Policy 并不完全同名。根据策略和目标地址,值可能包含源页面的路径与查询参数,也可能只剩来源部分,或者完全省略。现代浏览器的默认行为并不是向每一个外部目标发送完整地址,不能沿用这种错误前提解释所有来源信息泄露。 当前通常采用的默认策略是严格的跨源仅来源策略:同源请求可以发送路径和查询信息,安全级别合适的跨源请求只发送来源,遇到从 HTTPS 到 HTTP 等安全降级则不发送引用来源。主动设置策略,仍有助于让团队审阅明确的预期行为。只暴露来源虽然少于完整地址,但主机名称本身仍然可能带有业务含义,不能未经判断就把来源级信息一概称为无敏感性的统计数据。 区分几种策略的具体选择 不发送引用来源的策略会省略整个请求头,同源策略只对同源目标保留较完整的值,而不安全地址策略会更广泛地披露来源、路径和查询参数,包括一些严格策略本来会抑制的场景。地址片段会从引用来源计算中移除,因此地址栏里的片段不应出现在接收方的 Referer 中。但这并不表示片段对页面脚本或浏览器状态保密,它只是没有通过这个请求头传送。 文档策略可以通过响应头设置,在合适场景下也可以使用名称为 referrer 的 HTML meta 元素。不能把自造的 http-equiv=“Referrer-Policy” 标签当成同等语法。部分元素以及 fetch 请求还支持单次请求自己的引用来源策略,因此单看文档响应头不一定能解释每一个实际请求。观察到不同结果时,应检查请求由什么上下文发起,以及它是否明确指定了自己的策略。 坏掉的样子 应用如果依赖收到完整 Referer 路径,可能在策略收紧或浏览器隐私行为减少信息后突然失效。引用来源并不是可靠的认证凭据,也不能代替明确的应用参数。同样,修改这项策略不会授予读取跨源响应的 CORS 权限,也不会删除已经写入目标地址的访问令牌。它只控制请求中的一种元数据,不能被当成同时处理全部信息流动的开关。 另一个错误是把跨源与跨站混为一谈。同一主机上的不同端口已经属于不同来源,足以改变本例的策略结果。同源请求也仍然可能把敏感路径或查询信息交给该来源上的另一个接口。需要核对每个接收者实际需要什么;当观察到的值与文档看起来采用的默认值不一致时,还要检查重定向链及资源自己的策略,不能只依据域名相似就认定浏览器表现异常。 快速验证 保存脚本为 referrer-policy.py,执行 python3 referrer-policy.py –serve。在打印出的页面地址后追加 ?policy=strict-origin-when-cross-origin&demo=visible#fragment-not-sent 并打开。本次使用 Python 3.14.4 与 Chrome 152,两个临时回环服务分别提供页面和跨源接收端。页面实际请求同源接口与跨源接口,由接口返回自己收到的 Referer 值,结束后按控制键加 C 关闭两个服务。 ...

三月 24, 2027 · 2 分钟 · XB Field Notes
通过受保护套接字访问的签名助手,可使用软件密钥或硬件支持的身份。

ssh-agent 在会话期间替你记住已解密的密钥

SSH 代理为公钥认证提供签名操作。对于普通软件密钥,加载加密私钥时会解锁相关材料,此后代理便可以使用它,不必每次连接都重新询问文件口令。正常代理协议向客户端返回公钥身份和签名,而不是导出私钥。硬件支持的身份有所不同:私钥可以一直保留在认证器里,并要求触摸或用户验证,不能把所有代理身份都描述成完整私钥必然存放在代理内存中。 套接字提供的是服务访问能力 OpenSSH 通常通过 SSH_AUTH_SOCK 查找代理,但客户端配置中的 IdentityAgent 可以选择另一个套接字,也可以关闭代理使用。知道路径并不等于有连接权限。套接字和父目录的权限都重要;root 以及与用户同身份运行的其他进程,则是需要考虑的威胁。环境变量本身不能代替操作系统隔离,隐藏路径也不是可靠的保护边界。 能够访问代理,并不保证每个签名请求都会成功。密钥约束、确认要求、硬件在场和协议限制都可能拒绝请求。即使签名成功,远端服务器也不必接受这把密钥或授予权限。因此,本地签名能力与服务端授权决定应分开观察。诊断“代理里有钥匙但登录失败”时,不能只停留在身份列表这一层。 同时限制身份与委托 ssh-add 加载身份,并在需要时询问文件口令。但 ssh 的 AddKeysToAgent 等配置路径也能加载身份,不能说只有 ssh-add 才可能解锁。代理避免反复读取加密私钥文件,却不保证交换空间、转储、备份或其他进程中绝不存在私钥材料。操作系统与密钥存储策略仍然需要独立审阅,代理不是整台机器的保密证明。 -t 为添加的身份设置存活期限。到期后身份会被移除,这个参数不只是提醒;没有期限时,普通已加载身份可能一直留到显式删除或代理退出。移走、删除或者替换磁盘文件,并不会自动替换代理里已经加载的副本。轮换时应检查实际使用的指纹,而不是只确认文件名已经改变。 -c 通过 ssh-askpass 要求确认。决定是否批准的是辅助程序是否成功退出,而不是远端主机有没有终端。需要验证本地确认机制确实能工作,并拒绝意外请求。确认可以减少无人值守的使用,但仍需理解提示所代表的主机和操作,不能看到弹窗就习惯性通过,也不能把没有远端终端当作必然拒绝的证据。 OpenSSH 8.9 引入的目的地约束使用主机密钥进一步限制允许的认证路径。经过转发时,路径上的客户端需要支持相关协议;已知主机信息也必须正确识别目标。这不是靠域名文字就能独立工作的防火墙。依赖约束前应验证版本和兼容性,保持 OpenSSH 更新,并理解主机密钥变化如何影响允许的路径。 坏掉的样子 代理转发允许远端在转发通道存在期间中继签名请求。那里的管理员或被入侵进程可能使用这份能力,而不需要提取私钥。没有必要时应避免转发,并查看客户端最终生效的配置;命令行没有 -A,不足以证明配置文件没有开启它。风险在于签名委托,不在于私钥文件一定已经复制到远端。 从代理删除身份,并不会撤销已经被复制的私钥、删除服务器 authorized_keys 中的条目,也不保证关闭已建立的 SSH 会话。反过来,服务端撤销授权可能阻止新登录,即使代理仍保存这把钥匙。恢复计划应分别处理受影响的副本、授权记录和活动会话,而不是把其中一个动作误当成全部撤销。 现代 OpenSSH 代理还限制远端请求加载提供方,以及部分 FIDO 签名用途,以减少把转发认证器用于 SSH 之外的特定风险。这些保护不是公开套接字或随意放宽选项的理由。不同版本和平台集成的默认值、套接字位置可能不同;排查时应阅读当前实现的文档,不能把某次输出的路径和权限数字当成永远成立的协议常量。 ...

二月 27, 2027 · 1 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现 HTTP Basic 认证机制。

Basic Auth 携带的是可重复使用的凭据

HTTP Basic 认证把编码后的用户名与密码放进 Authorization 请求头。由于编码可逆,取得该请求头的值,往往就相当于取得凭据本身。Basic 确实定义了认证挑战与响应流程,但不会用密码学方式把响应绑定到服务器刚生成的一次性随机数。只要服务器继续接受这个账号和凭据,一份曾被接受的值就可能被再次使用。不能把“存在挑战流程”误解成“重放一定失败”。 看清真正发生的交换 服务器可以返回四零一,并通过 WWW-Authenticate: Basic 挑战声明 realm,客户端再带 Authorization 重试。客户端也可以在合适的保护空间内提前发送凭据。因此,带认证的请求会携带凭据,不意味着每个最初请求一开始就必须有它。realm 用于标识保护空间,并不会自动授予同名路径上的所有权限,更不能替代具体的资源访问控制。 编码输入由用户标识、一个冒号和密码拼接。第一个冒号是分隔符,所以用户名不能包含冒号,密码则可以包含后续冒号。用户名和密码都不能包含控制字符;允许密码里出现冒号,并不意味着它可以包含任意字符。Base64 既不是加密,也不是密码哈希。涉及非 ASCII 凭据时还要考虑字符编码:RFC 7617 在兼容 ASCII 的条件下没有统一默认编码,并允许服务器挑战指明 UTF-8。仅验证一个 ASCII 示例,不等于已经覆盖所有国际化账号的编码与比较路径。 Basic 本身不定义会话令牌或通用退出登录协议。应用仍可以设置账号到期、禁用账号、改变授权或拒绝旧密码。因此,轮换密码并不是阻止泄露请求头继续生效的唯一办法。凭据的有效期与撤销方式,应依据服务器真实的账号策略定义,不能期待网络传输格式自动提供完整账号生命周期管理。客户端停止发送,与服务器已经拒绝旧值,也是不同事实。 坏掉的样子 在没有保护的网络链路上,能够读取请求头的人可以解码并再次使用。正确校验证书的 HTTPS 保护的是到 TLS 终点的传输,不会让终点本身、合法终止 TLS 的代理或疏忽的应用日志看不到凭据。还需要保护后续链路,并避免在日常追踪、报告和共享诊断文件中记录 Authorization 的真实值。传输加密不能替代服务端对凭据的妥善处理。 权限过大的凭据会扩大泄露后果。账号应匹配具体任务及其生命周期,身份验证之后仍要执行授权。凭据有效,不等于每个操作都允许;realm 标签也不是租户隔离机制。客户端需要限制凭据发送位置,尤其关注重定向和脚本复用全局请求头的情况。把同一个认证头传给无关目的地址,超出了原来的保护空间,也破坏了凭据使用边界。 重复的四零一可能来自凭据缺失、错误、到期或服务端不支持相应方式。在下面例子中,四零三表示已经识别的演示账号缺少目标权限,但不能据此认为所有服务返回四零三都证明认证已成功。应结合服务契约及响应中的认证挑战检查,不能仅凭一个数字推断完整的身份状态。认证结果和业务许可,应分别提供能够判断的证据。 快速验证 保存为 basic-auth.py,执行 python3 basic-auth.py。所有凭据都是刻意编造的演示值:用户名为 DEMO,密码为 NOT:A:SECRET。服务器只绑定回环地址,用明文 HTTP 展示协议机制;不要把这些值替换成真实凭据。实验没有测试 TLS、持久账号存储或生产认证服务,也不会读取当前环境的登录信息。 ...

二月 11, 2027 · 2 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

CORS 预检取决于请求形态

CORS 预检用于询问浏览器能否发送某种具体形态的跨源请求。它是一条 OPTIONS 请求,携带计划采用的方法及相关请求头名称,浏览器检查服务端答复后才决定是否发送实际请求。这与实际响应到达之后,检查应用脚本能否读取响应,是两个不同阶段。它们都可能失败,但对服务端是否执行过业务操作的影响不同,诊断时必须先确定错误发生在哪个边界。 触发预检的条件并不是简单的“带有凭据”或“会产生副作用”。在其他条件同时满足时,安全列表中的 GET、HEAD 或 POST 可以不预检。PUT、应用自己设置的 Authorization 等请求头通常需要预检,JSON 内容类型也不属于安全列表里的内容类型。因此,即使目标地址和业务操作没变,只改变请求形态,也可能新增一次 OPTIONS 交互。不能把这种变化误认为服务端突然多执行了一次业务请求。 把每项权限放到对应阶段理解 预检携带计划使用的方法,并在需要时携带计划设置的请求头名称。响应中的允许方法和允许请求头必须覆盖浏览器提出的内容。这个阶段成功之后,实际响应仍然要满足自己的 CORS 暴露条件。当获准的前端需要读取错误状态和正文时,错误响应也可能需要恰当的暴露头。后端处理函数可以被普通客户端成功调用,并不足以证明浏览器中的真实前端能够访问它。 凭据模式又增加了独立约束。使用 include 时,响应暴露需要匹配的明确来源,以及值为 true 的允许凭据头;即使本次测试根本没有 Cookie 可发送,允许来源通配符仍然不成立。浏览器对 Cookie 的其他限制也独立存在。允许凭据头不能替代 Cookie 配置、用户认证或来源允许列表,也不应为了回答浏览器的预检元数据查询,就要求预检先携带会话 Cookie。 坏掉的样子 应用路由可能允许 PUT,但预检响应遗漏了 PUT 或所需自定义请求头。浏览器此时只发送 OPTIONS,根本不会派发实际 PUT。重试业务处理器或修改数据库代码,并不能修复这个协议边界。反过来,一个采用凭据模式的简单 GET 可以在没有 OPTIONS 的情况下到达处理器,然后因为服务端返回通配符而无法向脚本暴露响应。仅凭前端的失败提示,无法确定哪一种情况发生了。 预检缓存还会让配置变更难以观察。最大缓存时长控制的是浏览器专门维护的许可缓存,并且受到浏览器自身限制;它不是业务接口正文缓存。判断修改是否生效之前,应查看真实请求交互及缓存影响。如果响应会根据请求来源选择不同的允许值,还要设置恰当的缓存变化规则,避免共享缓存把某个来源对应的许可响应直接复用于其他请求。不能靠反复修改接口正文来排查权限缓存。 ...

一月 22, 2027 · 2 分钟 · XB Field Notes