@@ -1176,6 +1176,30 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
11761176 n -- ;
11771177 continue ;
11781178 }
1179+
1180+ /*
1181+ * Fixed bit (RFC 9000 §17.2 & §17.3). This is the one bit in
1182+ * the first byte that is NOT covered by header protection (the
1183+ * HP mask only touches the low nibble of a long header and the
1184+ * low 5 bits of a short header), so we can check it here, before
1185+ * trusting any field derived from this byte and before AEAD.
1186+ *
1187+ * A compliant sender always sets it. A cleared Fixed bit means
1188+ * the byte is corrupt or not a QUIC packet; since every field we
1189+ * would parse next (form, type, version, CID lengths) is derived
1190+ * from this byte, we cannot trust the length field to find the
1191+ * next coalesced packet boundary either, so drop the rest of the
1192+ * datagram rather than advance blindly.
1193+ *
1194+ * The reserved bits (0x0c long / 0x18 short) are header-protected
1195+ * and so cannot be checked until after lws_quic_unmask_header();
1196+ * the existing post-AEAD check still handles those.
1197+ */
1198+ if (!(p [0 ] & 0x40 )) {
1199+ lwsl_wsi_notice (wsi , "QUIC RX: Fixed bit not set (0x%02x), "
1200+ "dropping corrupt datagram" , p [0 ]);
1201+ break ;
1202+ }
11791203 if (p [0 ] & 0x80 ) {
11801204 uint8_t type = (uint8_t )((p [0 ] & 0x30 ) >> 4 );
11811205 uint32_t parsed_pkt_version = (uint32_t )((p [1 ] << 24 ) | (p [2 ] << 16 ) | (p [3 ] << 8 ) | p [4 ]);
@@ -1318,8 +1342,18 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
13181342
13191343 if (!(p [0 ] & 0x80 )) { /* Short header */
13201344 uint8_t kp = (p [0 ] & 0x04 ) >> 2 ;
1321- if (nwsi -> quic .qn && nwsi -> quic .qn -> handshake_done && nwsi -> quic .qn -> rx_key_phase != kp ) {
1322- /* Provisional key update */
1345+ if (nwsi -> quic .qn && nwsi -> quic .qn -> handshake_done &&
1346+ nwsi -> quic .qn -> rx_key_phase != kp &&
1347+ nwsi -> quic .qn -> kp_probe_fail < LWS_QUIC_KP_PROBE_LIMIT ) {
1348+ /*
1349+ * Provisional key update. The key-phase bit is
1350+ * only 1 bit and is trivially flipped by wire
1351+ * corruption, so this probe can fire on junk.
1352+ * If it then fails AEAD we count it below and,
1353+ * once kp_probe_fail saturates, we stop trusting
1354+ * the bit entirely and treat mismatches as
1355+ * corruption rather than a peer rotation.
1356+ */
13231357 scratch_keys = * k ;
13241358 scratch_keys .aead_rx = NULL ;
13251359 scratch_keys .aead_tx = NULL ;
@@ -1336,9 +1370,45 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
13361370 if (dec_len < 0 ) {
13371371 lwsl_wsi_notice (wsi , "QUIC RX: AEAD Decryption failed (bad tag or truncated)" );
13381372 if (is_key_update ) {
1373+ /*
1374+ * The key-phase bit mismatched and the rotated
1375+ * keys still failed AEAD: that mismatch was
1376+ * corruption, not a peer key update. Account
1377+ * it so we stop chasing key rotations on junk
1378+ * once it becomes a pattern.
1379+ */
13391380 lws_quic_keys_release_aead_rx (& scratch_keys );
13401381 lws_quic_keys_release_aead_tx (& scratch_keys );
1382+ if (nwsi -> quic .qn )
1383+ nwsi -> quic .qn -> kp_probe_fail ++ ;
1384+ }
1385+
1386+ /*
1387+ * Corruption circuit-breaker. A single undecryptable
1388+ * packet is normal (loss, a stale PN, a probe for a key
1389+ * phase we discarded). A sustained run with no progress
1390+ * is wire/path corruption that the loss machinery cannot
1391+ * recover from at this rate: rather than spin key
1392+ * derivation and stall until PTO exhaustion, close the
1393+ * connection cleanly with NO_VIABLE_PATH so the peer and
1394+ * any test harness see a decisive transport error.
1395+ */
1396+ if (nwsi && nwsi -> quic .qn ) {
1397+ if (++ nwsi -> quic .qn -> consec_decrypt_fail [pn_space ] >=
1398+ LWS_QUIC_DECRYPT_FAIL_LIMIT ) {
1399+ lwsl_wsi_warn (wsi ,
1400+ "QUIC RX: %u consecutive decrypt "
1401+ "failures in pn_space %d with no "
1402+ "progress; path is corrupted, "
1403+ "closing" ,
1404+ nwsi -> quic .qn -> consec_decrypt_fail [pn_space ],
1405+ pn_space );
1406+ lws_quic_enter_closing_state (nwsi ,
1407+ LWS_QUIC_ERR_NO_VIABLE_PATH , 0 , 0 );
1408+ return LWS_HPI_RET_PLEASE_CLOSE_ME ;
1409+ }
13411410 }
1411+
13421412 /* F-130: Stateless Reset Token detection */
13431413 if (nwsi && nwsi -> quic .qn && (size_t )n >= 21 ) {
13441414 static const uint8_t zero_token [16 ] = {0 };
@@ -1427,6 +1497,15 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
14271497 /* Check for duplicate/replayed packet numbers (Security Fix) */
14281498 int is_out_of_order = 0 ;
14291499 if (nwsi -> quic .qn ) {
1500+ /*
1501+ * Reaching here means the packet authenticated cleanly,
1502+ * so any prior decrypt-failure run for this pn_space is
1503+ * over: reset the corruption circuit-breaker and the
1504+ * key-phase-probe accounting.
1505+ */
1506+ nwsi -> quic .qn -> consec_decrypt_fail [pn_space ] = 0 ;
1507+ nwsi -> quic .qn -> kp_probe_fail = 0 ;
1508+
14301509 uint64_t highest = nwsi -> quic .qn -> highest_rx_pn [pn_space ];
14311510 if ((nwsi -> quic .qn -> rx_pn_bitmask [pn_space ] != 0 || highest != 0 ) && full_pn <= highest ) {
14321511 uint64_t diff = highest - full_pn ;
@@ -1439,6 +1518,29 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
14391518 } else {
14401519 if (nwsi -> quic .qn -> rx_pn_bitmask [pn_space ] != 0 || highest != 0 ) {
14411520 uint64_t diff = full_pn - highest ;
1521+ /*
1522+ * Forward-PN plausibility guard. A
1523+ * corrupted truncated PN decodes to an
1524+ * arbitrary value, and a huge forward
1525+ * jump would rescale the receive
1526+ * bitmask and bump highest_rx_pn past
1527+ * thousands of imaginary gaps,
1528+ * poisoning ACK and loss accounting.
1529+ * QUIC senders never gap PNs by this
1530+ * much in practice, so drop the packet
1531+ * rather than trust the decode.
1532+ */
1533+ if (diff > LWS_QUIC_PN_FORWARD_JUMP_LIMIT ) {
1534+ lwsl_wsi_notice (wsi ,
1535+ "QUIC RX: decoded PN %llu "
1536+ "jumps %llu ahead of "
1537+ "highest %llu; treating "
1538+ "as corrupted truncated PN" ,
1539+ (unsigned long long )full_pn ,
1540+ (unsigned long long )diff ,
1541+ (unsigned long long )highest );
1542+ goto next_packet ;
1543+ }
14421544 if (diff >= 64 )
14431545 nwsi -> quic .qn -> rx_pn_bitmask [pn_space ] = 0 ;
14441546 else
0 commit comments