Repository navigation
v1.0.3
ONRouter 1.0.2 shipped a lot of capability. 1.0.3 is small and pointed: one correctness bug at the
TLS edge, six real security advisories closed, one silent data-loss path in the cache governor, and
the test/CI infrastructure that had been hiding all three from us.
The fallback certificate follows default_server, not config order
On a TLS port carrying several server blocks, a ClientHello whose SNI matches none of them
(a typo, a scanner, a client connecting by IP, a leftover pinned cert) presented the certificate of
whichever block happened to be written first — nginx presents the address's default_server. The
vhost half was already right (routing did use default_server), so this is invisible from HTTP: the
only symptom is a client asking for a domain you don't serve getting someone else's certificate, and
a config reorder silently changing which certificate answers. ssl_certificate_by_lua* had the same
skew (tls::select_script also takes the first entry as its fallback), so both are fixed together.
Why no existing suite caught it: https_matrix's multi-domain port 18443 writes default_server
first, where "follows config order" and "follows the default server" are literally the same thing.
Port 18449 now writes it last — the only arrangement that separates the two implementations —
and it is the assertion that goes red without the fix.
The framed transports are covered too, and that needed new plumbing. orr-h3check could report
status and body digests but not which certificate the server presented; it now prints
PEER_SHA256, and a new ORRH3_AUTHORITY lets SNI and :authority be supplied separately (same on
the h2 side via Node's servername). Port 18156 in tools/quic_business_e2e.py hangs three
certificates on one TCP+UDP port number, default_server deliberately last, and asserts per case:
| over h2 and over h3, separately | expected |
|---|---|
SNI=a, authority=a / SNI=b, authority=b |
a's cert + a's vhost, b's cert + b's vhost |
SNI=a, authority=b |
a's certificate, b's vhost — the two accounts stay independent |
SNI=<matches nothing>, authority=b |
the default_server certificate (written last), b's vhost |
SNI=<nothing>, authority=<nothing> |
that certificate and that vhost — the full fallback |
| same case compared across transports | identical PEER_SHA256 and identical bytes |
A mutation check makes the value concrete: with the fix temporarily reverted, the h3 cases do not
fail as a fingerprint mismatch — the handshake is refused outright,
invalid peer certificate: certificate is only valid for DnsName("a.qbiz.test"). That is exactly what
a browser shows a real visitor. router::tests::fallback_cert_follows_default_server_not_config_order
pins both the declared and the undeclared default_server shapes.
Six TLS-stack vulnerabilities closed (cargo audit)
None of them were in the WASM stack; all six were patch-level inside the already-declared semver
range, so the fix was three cargo update --precise bumps — no Cargo.toml change, no API churn.
| Crate | Was → Now | Advisory | Why it matters at a TLS edge |
|---|---|---|---|
rustls |
0.23.45 → 0.23.46 | RUSTSEC-2026-0346 | Debug on client session values leaks TLS secrets (we are a client to https upstreams) |
rustls |
〃 | RUSTSEC-2026-0347 | buffered application data sent to the outer server after an ECH rejection |
rustls |
〃 | RUSTSEC-2026-0348 | a Brotli-compressed certificate can allocate ~1 GiB before authentication — attacker-shaped input, no credentials needed |
rustls |
〃 | RUSTSEC-2026-0349 | an unauthenticated ServerHello can downgrade later connections to classical key exchange |
rustls-webpki |
0.103.15 → 0.103.16 | RUSTSEC-2026-0382 | verification may accept distrusted intermediates (this is what we verify upstream certificates with) |
webpki-root-certs |
1.0.9 → 1.0.10 | RUSTSEC-2026-0383 | bundled roots trusted distrusted emSign intermediates |
Re-running cargo audit reports zero vulnerabilities. Two warnings remain — paste
(RUSTSEC-2024-0436) and rustls-pemfile (RUSTSEC-2025-0134) — both unmaintained, not vulnerable,
accepted knowingly: rustls-pemfile only reads PEM files an operator named in config, never peer
input. cargo audit is now part of the release checklist so the next batch arrives as a five-minute
check rather than a question.
The cold tier no longer eats what the governor told it to demote
lua_shared_dict … disk= plus cache_governor deep demotes entries to disk during idle windows.
The demotion removed the value from memory and then ignored DiskStore::spill()'s refusal — disk
full, pending byte/job caps reached, writer channel closed — while still returning a "demoted" record,
so onrouter_governor_demoted_total counted entries that were actually destroyed. Under cold-tier
pressure, "demote" silently meant "delete"; capacity eviction is allowed to do that (cache semantics),
a demotion of application state is not. It now restores the entry, records the refusal
(spill_skipped), and stops the pass so the next one retries
(shared_dict::tests::governor_demote_pass_keeps_values_the_cold_tier_refuses, a cold tier sized so
small that it cannot accept a single record).
WASM: why the Cranelift advisories do not reach this binary
The Wasmtime/Cranelift batch (e.g. GHSA-jhxm-h53p-jm7w, a miscompiled guest heap access on aarch64
escaping the sandbox; RUSTSEC-2026-0094 Winch typing table.grow wrongly; RUSTSEC-2026-0022 a
panic when an async Func::call future is dropped early) targets code we do not ship: Cargo.lock
contains zero cranelift* entries and no wasmtime, and the plugin host is wasmi — a bytecode
interpreter with no JIT/AOT, so there is no codegen step that could miscompile guest memory. Of the
four crates we actually use for WASM (wasmi, wat, wast, wasmparser) RustSec records no
advisory at all, and the parser stack is current. Stated plainly rather than smoothed over: upstream
wasmi is at 2.0.0 and we are on 0.31.2. Nothing known-exploitable, but the cost of being
behind is responsiveness — the day a wasmi advisory lands, taking it is an API migration (fuel
accounting, TrapCode paths, resource-limiter naming), not a version bump. That migration is its own
change with its own evidence pass; it is not something to slip into a release. Full analysis in
docs/SECURITY_AUDIT.md.
Test and CI infrastructure — every item here was "green on this host, red in CI"
- Each TLS suite signs its own fixture (
tools/selfsigned.py, one directory per suite).
production_shapewas pointing atcerts_random/fallback.pem, a directory another suite generates
and.gitignoreexcludes: fine on any machine that had run that suite, dead on a clean checkout.
Two constraints are recorded in the module —openssl req -x509stampsCA:TRUEby default and
rustls refuses such a certificate as an end-entity (CaUsedAsEndEntity), which is exactly what
happens when a client uses the self-signed cert as its own trust anchor (the h3 probe does); and the
SAN must contain the name being connected to, because QUIC verifies it. - Readiness probes survive a gateway that is still binding. The first
/pingcould raise
Connection refusedon Linux, and one unguarded probe aborted a whole suite. - Suites that write into
logs/create it (logs/is gitignored; CI only had it because an
earlier suite happened to make it first). tokens_refill_over_timeis no longer scheduling-sensitive. At 1000 r/s it required two
back-to-back checks to land inside one millisecond — true here, false on a loaded runner. The
judgement now sits two orders of magnitude away from the jitter (2 r/s).random_scenarios_e2eis back in the roll. It had been held out because its TLS oracle
conflated certificate by SNI with vhost by Host and its probe never sent the SNI it claimed
(HTTPSConnection("127.0.0.1")used the IP), so certificate assertions failed in blocks and looked
like gateway bugs. The oracle now computes certificates from SNI alone (exact > wildcard > the
address's default server), L4 expectations followsni_match's first-match-in-order, and 20 random
topologies pass 4122/4122.- CI widened to 15 suites in three steps, adding
https_matrix(the only suite that can
discriminate the fallback-certificate question), the business-shape suites, and a step that builds
the h3 probe and runs the cross-transport suite.
Upgrading to 1.0.3
No configuration change is required and no directive was added or removed. Two behaviour differences
worth knowing:
- Which certificate answers an unmatched SNI. If a multi-vhost listener relied on the
first-written block being the implicit default, declaredefault_serveron the block whose
certificate should be presented in that case — which is also what nginx requires. - Governor demotions that the cold tier cannot accept now stay in memory and show up as
spill_skippedrather than as keys that vanish. If a zone'smax_diskis too small for its
working set, that becomes visible instead of destructive.
Verification
cargo test --workspace— 502/502 (1.0.2 shipped 500; this release adds the fallback-cert
ordering test and the refused-spill test). Clippy: no errors.- TLS suite family on the rebuilt 1.0.3 binary:
https_matrix76/76,tls_session45/45,
tls_protocols13/13,tls_legacy12/12,mtls_upstream11/11,https_backend12/12,
protocol_matrix37/37,quic_business58/58 (was 41 — the 17 added assertions are the
three-certificate port). random_scenarios_e2e --iters 20— 4122/4122 across 20 generated topologies.- Governor and its cold-tier neighbours:
governor32/32,disk_cache53/53,cache_advanced32/32,
proxy_cache21/21,proxy_disk39/39,still_online25/25. - Full regression on the release binary:
python run_regression.py reg_103_gate2.txt→
TOTAL 95 scripts, nonzero=0. The first attempt had two reds and both are in the release
notes rather than hidden:governorsnapshotting the cold index before the writer thread had
landed it (harness: wait for the settled state), andgame_udpasserting losslessness during a
33k-pps self-flood where the countedqueue_fulldrops were exactly the shortfall
(judgement rewritten to liveliness + conservation; idle re-run is 3000/3000 at ≈46,338 pps,
0 dropped). A third red was mine, not the suite's: cleaning up a probe with
taskkill //F //IM onrouter.exekilled the in-flight regression's gateway —schedule_e2eis
50/50 when re-run quietly. Kill by PID, never by image name, while a roll runs. - 21-minute soak on the release binary: PASS — 2,161,682 requests, 0 failures, private
memory 18.71 → 20.02 MiB with a post-warmup slope of +0.043 MiB/min against the 1.0 MiB/min
gate (peak 21.15 MiB), cold tier churning throughout (79,254 spills / 78,520 cold hits / 78,520
promotions) withcorrupt0,never_read0,pending_jobs0 andspill_skipped0 — that last
counter only exists as of this release, which is why it can be asserted to be zero now. Recorded
indocs/OPERATIONS.md§13.1 (target/soak_report_v103.json). - Linux CI on
mainfor every commit in this batch: unit + clippy + 15 E2E suites across three
steps, all green. tools/release_verify.pyrun against the unpackedonrouter-v1.0.3-*.zip(the artifact a user
downloads, not the build tree's binary):-Vand theServer:header agree withCargo.toml,
every shipped config-ts clean, and the vendor-name grep carries a live control count so a wrong
pattern cannot pass by matching nothing.
中文摘要
1.0.2 加能力,1.0.3 收口子。三件事:
一、兜底证书跟着 default_server,不跟着配置顺序。 一个 TLS 端口上挂多个站点时,SNI 匹配不上
(打错域名、扫描器、按 IP 直连)出示的是"恰好第一个写的块"的证书,而 nginx 用的是该地址
default_server 的证书。站点选择那半边一直是对的,所以从 HTTP 侧完全看不出异常——症状是"你没服务的
域名拿到了别人的证书",以及挪一行配置顺序就换一张证书。ssl_certificate_by_lua* 同样的偏也一起修了。
为什么以前没抓到:https_matrix 的 18443 恰好把 default_server 写在最前面,两种实现给出同一张证书,
根本分不出来;新增 18449 写在最后,才是判别形状。h2 与 h3 单独建了一个端口:18156 一个
TCP+UDP 端口号上三张证书,探针先补上可观测性(orr-h3check 现在打 PEER_SHA256,
ORRH3_AUTHORITY 让 SNI 与 :authority 分开给),逐条断言"SNI=a 而 authority=b ⇒ a 的证书 + b 的站点"
以及"SNI 谁都不匹配 ⇒ 必须落到写在最后的那个 default_server"。变异校验的结论更硬:把修复退回,
h3 不是指纹不符而是握手直接拒绝(certificate is only valid for DnsName("a.qbiz.test"))——
浏览器用户看到的就是这个。
二、cargo audit 查出六条 TLS 栈漏洞,全部关闭。 rustls 0.23.45 四条(其中
RUSTSEC-2026-0348 是"Brotli 压缩的证书在认证之前可分配约 1 GiB",打在 443 边缘上就是未认证先吃内存;
0349 是可把后续连接降级到经典密钥交换)、rustls-webpki(可能接受被不信任的中间证书)、
webpki-root-certs(内置根信任了被撤销的 emSign 中间证书)。六条都在声明范围内,三次
cargo update --precise 解决,复查为 0 漏洞;paste 与 rustls-pemfile 两条 unmaintained 警告是
"不再维护"不是"有漏洞",已知接受。audit 从此进发布检查表。
三、governor 的降级不再静默丢数据。 冷层拒收(盘满、pending 上限、写通道关闭)时,旧代码已经把
内存值摘掉、却照样返回"已降级"记录——冷层一饱和,"降级"就变成"删除"。现在放回内存、记一笔
spill_skipped、结束本轮下次再试。
另外要说清 cranelift 那批公告打不到这里:锁文件里 cranelift* 命中 0 次、没有 wasmtime,插件
宿主是无 JIT 的字节码解释器,"编译器把 guest 访存生成错 ⇒ 越狱"这条路径在本项目里不存在;我们真正
用的四个 WASM crate RustSec 均无公告、解析器栈在当前版本线。诚实写下的缺口是 wasmi 0.31.2 vs
上游 2.0.0——目前没有已知可利用问题,但差距的代价是将来出公告时要做的是 API 迁移而不是改版本号,
那需要自己的一批与自己的证据。
测试与 CI 基础设施这一块,每一条都是"本机绿、CI 红":TLS 套件各自现造证书夹具(openssl req -x509
默认打 CA:TRUE 会被 rustls 当末端证书拒掉这条也记进了模块注释)、就绪探测兜住还在 bind 的网关、
往 logs/ 写的套件自己建目录、令牌补位单测不再要求两次调用落在同一毫秒、random_scenarios_e2e
带着"证书只按 SNI 算"的预言机回到回归(20 轮随机拓扑 4122/4122),CI 扩到三步 15 条并把
https_matrix 纳入每次 push。升级不需要改任何配置;两点行为差别:兜底证书从此看 default_server,
以及冷层装不下时条目留在内存里而不是消失。
Full Changelog: v1.0.2...v1.0.3