Skip to content

Commit bc1a280

Browse files
committed
TASK-196: Isolate unauthenticated QUIC traffic and mark task complete
1 parent e9ff045 commit bc1a280

7 files changed

Lines changed: 131 additions & 18 deletions

File tree

‎specs/tasks/M14-v3-release-repairs/TASK-196.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@
2525
- Blocks: TASK-197, TASK-185
2626
**Related Requirements:** PRD-V3N-REQ-003, PRD-V3N-REQ-007, PRD-V3N-REQ-008, PRD-V3N-REQ-009, PRD-V3N-REQ-014, PRD-V3N-REQ-015, PRD-V3N-REQ-016, PRD-V3N-REQ-021, PRD-V3N-REQ-025, PRD-V3N-REQ-027, PRD-V3N-REQ-034, PRD-V3N-REQ-037.
2727
**Related Decisions:** DR-V3-001, DR-V3-002, DR-V3-004, DR-V3-006, DR-V3-007, DR-V3-008.
28-
**Status:** In Progress
28+
**Status:** Complete
2929

30-
**Implementation evidence:** [TASK-196-design.md](../../architecture/v3/TASK-196-design.md) documents ownership and bounded lifecycle decisions. [TASK-196-implementation-evidence.md](../../architecture/v3/TASK-196-implementation-evidence.md) and [TASK-196-receipt.json](../../architecture/v3/TASK-196-receipt.json) record completed local native/public/capture/sanitizer gates and exact proof reuse scope. The pinned simulator clock compatibility and original failed attempts remain explicit. Caller-owned Groundwork validation/finalization, merge and cleanup remain pending; task status stays In Progress. Nonlocal BSD/macOS/Windows execution belongs to CI and the v3 PR.
30+
**Implementation evidence:** [TASK-196-design.md](../../architecture/v3/TASK-196-design.md) documents ownership and bounded lifecycle decisions. [TASK-196-implementation-evidence.md](../../architecture/v3/TASK-196-implementation-evidence.md) and [TASK-196-receipt.json](../../architecture/v3/TASK-196-receipt.json) record completed local native/public/capture/sanitizer gates and exact proof reuse scope. The pinned simulator clock compatibility and original failed attempts remain explicit. Groundwork validation passed in two iterations on 2026-10-11. Both required unauthenticated-packet isolation repairs passed regression, normal/ASan/UBSan/TSan, all 12 refreshed installed public HTTP/3 cells and the affected 2,000-run callback fuzz gate. The original implementation receipt remains historical; validation and repair receipts are archived in `build/task196-evidence/`. Two approved review observations remain recorded in [the validation notes](../../unworked_review_issues/task-196_validation_a7750fef553d70aa25d6309cee70d461.md). Local completion is validated for integration into `v3`. Nonlocal BSD/macOS/Windows execution belongs to CI and the v3 PR.

‎specs/tasks/_index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ Nominally: **13 sequential tasks**, each S–XL. Most other tasks parallelize of
343343
| TASK-193 | [Install public credentials and serve native TCP TLS](M14-v3-release-repairs/TASK-193.md) | M14 | Complete | TASK-192 |
344344
| TASK-194 | [Complete public TLS profiles, live rotation and ACME selection](M14-v3-release-repairs/TASK-194.md) | M14 | Complete | TASK-193 |
345345
| TASK-195 | [Route negotiated public HTTP/2 streams through native listeners](M14-v3-release-repairs/TASK-195.md) | M14 | Complete | TASK-190, TASK-194 |
346-
| TASK-196 | [Add production UDP QUIC ownership and public HTTP/3 routing](M14-v3-release-repairs/TASK-196.md) | M14 | In Progress | TASK-191, TASK-193, TASK-195 |
346+
| TASK-196 | [Add production UDP QUIC ownership and public HTTP/3 routing](M14-v3-release-repairs/TASK-196.md) | M14 | Complete | TASK-191, TASK-193, TASK-195 |
347347
| TASK-197 | [Complete public HTTP/3 lifecycle, policy and WebSocket parity](M14-v3-release-repairs/TASK-197.md) | M14 | Not Started | TASK-194, TASK-196 |
348348
| TASK-198 | [Provide native public throughput and latency benchmarks](M14-v3-release-repairs/TASK-198.md) | M14 | Not Started | TASK-195, TASK-197 |
349349
| TASK-199 | [Deliver packaged public-capability and benchmark adapters for release execution](M14-v3-release-repairs/TASK-199.md) | M14 | Not Started | TASK-198 |
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Unworked Review Issues
2+
3+
**Run:** 2026-10-11 00:35:26
4+
**Task:** TASK-196
5+
**Total:** 2 (0 critical, 1 major, 1 minor)
6+
7+
## Major
8+
9+
1. [ ] **test-quality-reviewer** | `test/integ/http3-client-go/public.go:310` | missing-test
10+
The independent destruction witness reports passed for every publicRead error. publicOpen sets a three-second stream deadline, so a server that leaves the held stream unresolved and never closes or resets it still produces a timeout and reaches status=passed. Protocol/decoder errors are also accepted. This weakens TASK-196’s active-stream destruction/cancellation witness; the stricter aioquic test does not make this independent-client predicate valid.
11+
*Recommendation:* Require an observed remote QUIC connection-close or stream cancellation with the expected shutdown semantics; reject deadline/context timeout and decoding errors. Add a bounded negative witness using a peer that keeps the stream open without closing/resetting it, and require that witness to fail rather than emit a passing destruction result.
12+
13+
## Minor
14+
15+
2. [ ] **performance-reviewer** | `src/detail/quic_network_packets.cpp:51` | memory-allocation
16+
Every receive_packet call value-initializes two 65,536-byte automatic buffers, clearing 128 KiB on the packet hot path even though the production UDP reader caps datagrams at 2,048 bytes. This is bounded and does not block basic routing, but adds avoidable stack footprint and memory traffic, including for failed authentication.
17+
*Recommendation:* Measure this cost in TASK-198/199 packet-throughput profiles. If material, right-size the buffers to an explicit accepted-datagram bound or reuse owner-serialized scratch storage, preserving capacity rejection, authenticated output lengths, and the crypto scratch wiping contract.

‎src/detail/quic_network_connection.cpp‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,12 @@ void quic_network_connection::adopt_routes(quic_datagram_dispatch& routes, io_co
8080
quic_datagram_dispatch::cid_registration initial) {
8181
require(static_cast<bool>(identity_->attach_routes(routes, owner, std::make_shared<weak_sink>(weak_from_this()), std::move(initial))), "CID registration failed");
8282
}
83-
void quic_network_connection::queue_datagram(std::shared_ptr<const io_datagram> packet, clock_type::time_point now) {
83+
void quic_network_connection::queue_datagram(std::shared_ptr<const io_datagram> packet) {
8484
server::reservation storage;
85+
// Capacity pressure from unauthenticated ciphertext only discards input.
8586
if (received_.size() == 16 || received_bytes_ + packet->bytes.size() > 32768 ||
8687
!pool_.data().budget.reserve(server::resource::quic_reassembly_bytes, packet->bytes.size() + sizeof(queued_packet), storage).ok()) {
87-
request_close({false, 0x01, 0, {}}, now); return;
88+
return;
8889
}
8990
received_bytes_ += packet->bytes.size();
9091
received_.push_back({std::move(packet), std::move(storage)});
@@ -123,7 +124,7 @@ void quic_network_connection::on_datagram(std::shared_ptr<const io_datagram> pac
123124
const auto now = clock_type::now();
124125
try {
125126
if (pending()) {
126-
queue_datagram(std::move(packet), now);
127+
queue_datagram(std::move(packet));
127128
return;
128129
}
129130
if (receiving_ || lifecycle_.state() == quic_lifecycle_state::closed || lifecycle_.state() == quic_lifecycle_state::draining) return;

‎src/detail/quic_network_packets.cpp‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ decoded_frames decode_authenticated_frames(std::span<const std::byte> payload, q
4646
} // namespace
4747
bool quic_network_connection::receive_packet(std::span<const std::byte> packet, const quic_path_identity& path, std::size_t credit, bool& accounted, clock_type::time_point now) {
4848
auto envelope = parse_quic_envelope(packet, server_cid_.size());
49+
// Disabled 0-RTT and server-only packet types cannot select a receive space.
50+
if (envelope.code != quic_codec_code::ok ||
51+
(envelope.value.kind != quic_packet_kind::initial && envelope.value.kind != quic_packet_kind::handshake &&
52+
envelope.value.kind != quic_packet_kind::one_rtt)) return false;
4953
auto space = packet_space(envelope.value.kind);
5054
auto index = static_cast<unsigned>(space);
5155
std::array<std::byte, 65536> plain{}, scratch{};

‎src/httpserver/detail/quic_network_connection.hpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class quic_network_connection final : public datagram_sink, public std::enable_s
5252
private:
5353
bool credit_datagram(const io_datagram&, const quic_path_identity&);
5454
void signal_progress();
55-
void queue_datagram(std::shared_ptr<const io_datagram>, clock_type::time_point);
55+
void queue_datagram(std::shared_ptr<const io_datagram>);
5656
void receive_datagram(const io_datagram&, const quic_path_identity&, bool&, clock_type::time_point);
5757
void inspect_reset(const io_datagram&, clock_type::time_point);
5858
bool retire_application();

‎test/unit/quic_native_owner_test.cpp‎

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/* Copyright (C) 2026 Sebastiano Merlino; SPDX-License-Identifier: LGPL-2.1-or-later */
22
#include <array>
3+
#include <algorithm>
34
#include <chrono>
45
#include <memory>
56
#include <stdexcept>
67
#include <thread>
8+
#include <utility>
79
#include <vector>
810
#include <httpserver/detail/fake_io_backend.hpp>
911
#include <httpserver/detail/quic_network_connection.hpp>
@@ -26,7 +28,7 @@ struct fixture {
2628
std::shared_ptr<hd::io_datagram> packet;
2729
hd::quic_admission_facts facts;
2830
std::shared_ptr<hd::quic_network_connection> owner;
29-
explicit fixture(bool adopt = false) {
31+
explicit fixture(bool adopt = false, std::size_t data_bytes = 393216) {
3032
if (!credentials.replace(quic_test::credentials()).ok() || !hs::route_registry::create(root, routes).ok()) throw std::runtime_error("native fixture setup");
3133
if (SSL_set1_groups_list(peer.ssl.get(), "X25519") != 1) throw std::runtime_error("native test group");
3234
const std::array<std::byte, 1> source{std::byte{7}};
@@ -48,7 +50,7 @@ struct fixture {
4850
admission = std::make_unique<hd::quic_server_admission>(limits);
4951
const auto pending = admission->receive(packet, 0); const auto seen = admission->inspect(pending.pending);
5052
if (!seen) throw std::runtime_error("native Initial admission"); facts = *seen;
51-
hd::quic_network_config config; config.data_bytes = config.critical_bytes = 393216;
53+
hd::quic_network_config config; config.data_bytes = data_bytes; config.critical_bytes = 393216;
5254
owner = std::make_shared<hd::quic_network_connection>(credentials.acquire()->select_default(), facts, routes, executor, 1, policy, root, config, hd::request_dispatch_context{}, hd::http3_native_context{});
5355
if (adopt) {
5456
auto promotion = admission->promote(pending.pending, io.datagrams(), owner->route_sink(), 0);
@@ -106,6 +108,51 @@ struct fixture {
106108
if (sealed.code != hd::quic_crypto_code::ok) throw std::runtime_error("native client protection");
107109
result->bytes.resize(sealed.consumed); return result;
108110
}
111+
std::shared_ptr<hd::io_datagram> unsupported_packet(hd::quic_packet_kind kind) {
112+
const std::array<std::byte, 16> tag{};
113+
const std::array<std::byte, 4> payload{}, versions{std::byte{0}, std::byte{0}, std::byte{0}, std::byte{1}};
114+
const std::array<std::byte, 1> source{std::byte{7}}, token{std::byte{1}};
115+
hd::quic_packet_write write; write.kind = kind;
116+
write.destination = std::span(facts.destination.bytes).first(facts.destination.size); write.source = source;
117+
if (kind == hd::quic_packet_kind::version_negotiation) {
118+
write.versions = versions;
119+
} else {
120+
write.tag = tag;
121+
if (kind == hd::quic_packet_kind::retry) write.token = token;
122+
else write.payload = payload;
123+
}
124+
auto result = std::make_shared<hd::io_datagram>(*packet);
125+
auto encoded = hd::encode_quic_packet(write, result->bytes);
126+
if (encoded.code != hd::quic_codec_code::ok) throw std::runtime_error("native unsupported packet");
127+
result->bytes.resize(encoded.consumed); result->peer.peer.port = 4999; return result;
128+
}
129+
bool ping_acknowledged() {
130+
const auto number = next_number[2];
131+
const std::array<hd::quic_frame, 1> ping{hd::quic_ping_frame{}};
132+
owner->on_datagram(protected_frames(ping));
133+
const auto now = std::chrono::steady_clock::now() + std::chrono::milliseconds(25);
134+
owner->tick(now);
135+
const auto output = owner->prepare(now);
136+
if (!output) return false;
137+
owner->emitted(true, now);
138+
auto remaining = std::span(output->bytes);
139+
while (!remaining.empty()) {
140+
const auto envelope = hd::parse_quic_envelope(remaining, 1);
141+
if (envelope.code != hd::quic_codec_code::ok) throw std::runtime_error("native continuation envelope");
142+
const unsigned level = envelope.value.kind == hd::quic_packet_kind::initial ? 0 : envelope.value.kind == hd::quic_packet_kind::handshake ? 1 : 2;
143+
std::array<std::byte, 1600> plain{}, scratch{};
144+
const auto opened = client_keys.open_packet(static_cast<hd::quic_key_level>(level), envelope.value.packet, 1, largest[level], true, plain, scratch);
145+
if (opened.code != hd::quic_crypto_code::ok) throw std::runtime_error("native continuation authentication");
146+
hd::quic_frame_cursor cursor;
147+
while (cursor.offset < opened.payload_size) {
148+
const auto frame = hd::next_quic_frame(std::span(plain).first(opened.payload_size), cursor, {envelope.value.kind, hd::quic_endpoint_role::server});
149+
if (frame.code != hd::quic_codec_code::ok) throw std::runtime_error("native continuation frame");
150+
if (const auto* ack = std::get_if<hd::quic_ack_frame>(&frame.value); level == 2 && ack && ack->largest == number) return true;
151+
}
152+
remaining = remaining.subspan(envelope.consumed);
153+
}
154+
return false;
155+
}
109156
void consume_server(const hd::quic_transmission& transmission) {
110157
auto remaining = std::span(transmission.bytes);
111158
while (!remaining.empty()) {
@@ -192,21 +239,56 @@ LT_BEGIN_AUTO_TEST(native_quic_suite, actual_operation_completion_settles_prepar
192239
LT_CHECK(!backend.complete(*ops[0], {})); backend.close(); f.executor.run_pending();
193240
}
194241
LT_END_AUTO_TEST(actual_operation_completion_settles_preparation_once_without_ambiguous_refund)
195-
LT_BEGIN_AUTO_TEST(native_quic_suite, received_datagrams_wait_for_pending_send_and_overflow_closes_bounded_owner)
242+
LT_BEGIN_AUTO_TEST(native_quic_suite, unsupported_packets_for_established_cid_are_discarded_without_closing)
243+
for (const auto kind : {hd::quic_packet_kind::zero_rtt, hd::quic_packet_kind::retry, hd::quic_packet_kind::version_negotiation}) {
244+
fixture f(true); f.connect();
245+
const auto forged = f.unsupported_packet(kind);
246+
const auto envelope = hd::parse_quic_envelope(forged->bytes);
247+
LT_ASSERT(envelope.code == hd::quic_codec_code::ok); LT_CHECK(envelope.value.kind == kind);
248+
const auto received = f.facts.budget->received(); const auto debit = f.facts.budget->debited();
249+
LT_CHECK(f.admission->receive(forged, 0).code == hd::quic_admission_code::routed); f.executor.run_pending();
250+
LT_CHECK(!f.owner->terminal()); LT_CHECK(f.owner->connected());
251+
LT_CHECK_EQ(f.facts.budget->received(), received); LT_CHECK_EQ(f.facts.budget->debited(), debit);
252+
LT_CHECK(f.ping_acknowledged());
253+
}
254+
LT_END_AUTO_TEST(unsupported_packets_for_established_cid_are_discarded_without_closing)
255+
LT_BEGIN_AUTO_TEST(native_quic_suite, received_datagrams_wait_for_pending_send_before_accounting)
196256
fixture f; const auto now = std::chrono::steady_clock::now();
197257
auto output = f.owner->prepare(now); LT_ASSERT(output);
198258
const auto received = f.facts.budget->received();
199259
f.owner->on_datagram(std::make_shared<hd::io_datagram>(*f.packet)); LT_CHECK_EQ(f.facts.budget->received(), received);
200260
f.owner->emitted(hd::quic_identity_emission::sent, now); f.owner->tick(now);
201261
LT_CHECK_EQ(f.facts.budget->received(), received + f.packet->bytes.size());
202-
output = f.owner->prepare(now); if (!output) output = f.owner->prepare(now + std::chrono::milliseconds(20));
203-
LT_ASSERT(output);
204-
for (unsigned n = 0; n < 17; ++n) f.owner->on_datagram(std::make_shared<hd::io_datagram>(*f.packet));
205-
LT_CHECK(f.owner->terminal()); LT_CHECK(f.owner->pending());
206-
LT_CHECK(f.root.in_use(hs::resource::quic_reassembly_bytes) <= f.root.capacity(hs::resource::quic_reassembly_bytes));
207-
f.owner->emitted(hd::quic_identity_emission::ambiguous, now); f.owner->tick(now + std::chrono::minutes(1));
208-
LT_CHECK(f.owner->retired()); f.owner.reset(); LT_CHECK_EQ(f.root.in_use(hs::resource::quic_reassembly_bytes), 0u);
209-
LT_END_AUTO_TEST(received_datagrams_wait_for_pending_send_and_overflow_closes_bounded_owner)
262+
LT_END_AUTO_TEST(received_datagrams_wait_for_pending_send_before_accounting)
263+
LT_BEGIN_AUTO_TEST(native_quic_suite, unauthenticated_queue_pressure_drops_excess_and_preserves_established_owner)
264+
struct pressure_case { std::size_t data_bytes, packet_bytes, retained_packets; };
265+
// Record limit, exact byte limit, and storage reservation exhaustion.
266+
for (const auto test : {pressure_case{393216, 64, 16}, pressure_case{393216, 16384, 2}, pressure_case{32768, 32768, 0}}) {
267+
fixture f(true, test.data_bytes); f.connect();
268+
const auto now = std::chrono::steady_clock::now();
269+
const auto output = f.owner->prepare(now); LT_ASSERT(output);
270+
const auto received = f.facts.budget->received(); const auto debit = f.facts.budget->debited();
271+
const std::array<hd::quic_frame, 1> ping{hd::quic_ping_frame{}};
272+
auto forged = f.protected_frames(ping); forged->bytes.back() ^= std::byte{1};
273+
forged->bytes.resize(test.packet_bytes); forged->peer.peer.port = 4999;
274+
std::vector<std::weak_ptr<hd::io_datagram>> retained;
275+
for (std::size_t n = 0; n < test.retained_packets; ++n) {
276+
auto packet = std::make_shared<hd::io_datagram>(*forged); retained.push_back(packet);
277+
LT_CHECK(f.admission->receive(std::move(packet), 0).code == hd::quic_admission_code::routed); f.executor.run_pending();
278+
LT_CHECK(!retained.back().expired());
279+
}
280+
auto excess = std::make_shared<hd::io_datagram>(*forged); const auto dropped = std::weak_ptr(excess);
281+
LT_CHECK(f.admission->receive(std::move(excess), 0).code == hd::quic_admission_code::routed); f.executor.run_pending();
282+
LT_CHECK(dropped.expired()); LT_CHECK(!f.owner->terminal()); LT_CHECK(f.owner->pending());
283+
LT_CHECK_EQ(f.facts.budget->received(), received); LT_CHECK_EQ(f.facts.budget->debited(), debit);
284+
LT_CHECK(f.root.in_use(hs::resource::quic_reassembly_bytes) <= f.root.capacity(hs::resource::quic_reassembly_bytes));
285+
f.owner->emitted(hd::quic_identity_emission::ambiguous, now); f.owner->tick(now);
286+
LT_CHECK(!f.owner->pending()); LT_CHECK(!f.owner->terminal());
287+
for (const auto& packet : retained) LT_CHECK(packet.expired());
288+
LT_CHECK(f.ping_acknowledged());
289+
f.owner.reset(); LT_CHECK_EQ(f.root.in_use(hs::resource::quic_reassembly_bytes), 0u);
290+
}
291+
LT_END_AUTO_TEST(unauthenticated_queue_pressure_drops_excess_and_preserves_established_owner)
210292
LT_BEGIN_AUTO_TEST(native_quic_suite, close_expiry_keeps_cid_routes_and_pending_storage_until_settlement)
211293
fixture f(true); const auto now = std::chrono::steady_clock::now();
212294
const auto packet = f.owner->prepare(now); LT_ASSERT(packet);
@@ -226,6 +308,15 @@ LT_BEGIN_AUTO_TEST(native_quic_suite, authenticated_frame_flood_refuses_before_u
226308
f.owner->tick(std::chrono::steady_clock::now() + std::chrono::minutes(1));
227309
LT_CHECK(f.owner->retired());
228310
LT_END_AUTO_TEST(authenticated_frame_flood_refuses_before_unbounded_frame_storage)
311+
LT_BEGIN_AUTO_TEST(native_quic_suite, authenticated_application_frame_flood_still_closes_established_owner)
312+
fixture f(true); f.connect();
313+
std::array<hd::quic_frame, 129> frames;
314+
std::fill(frames.begin(), frames.end(), hd::quic_ping_frame{});
315+
LT_CHECK(f.admission->receive(f.protected_frames(frames), 0).code == hd::quic_admission_code::routed); f.executor.run_pending();
316+
LT_CHECK(f.owner->terminal());
317+
f.owner->tick(std::chrono::steady_clock::now() + std::chrono::minutes(1));
318+
LT_CHECK(f.owner->retired());
319+
LT_END_AUTO_TEST(authenticated_application_frame_flood_still_closes_established_owner)
229320
LT_BEGIN_AUTO_TEST(native_quic_suite, cid_registration_does_not_own_connection_or_form_a_retirement_cycle)
230321
fixture f(true); const auto weak = std::weak_ptr(f.owner);
231322
f.owner.reset(); LT_CHECK(weak.expired());

0 commit comments

Comments
 (0)