diff --git a/CHANGELOG.md b/CHANGELOG.md index d041771d..a2633266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Unreleased + * DTLS 1.2 DTLS 1.3, parser reject ApplicationData in epoch 0/plaintext #90 + * DTLS 1.3 reject plaintext records with non-zero epoch #90 + * Silently discard invalid records and process subsequent valid records #90 + # 0.4.2 * Downgrade rand to 0.9 to avoid double chacha20 dep #84 diff --git a/src/dtls12/incoming.rs b/src/dtls12/incoming.rs index c20942e8..28fd3308 100644 --- a/src/dtls12/incoming.rs +++ b/src/dtls12/incoming.rs @@ -136,7 +136,15 @@ impl Record { // ONLY COPY: UDP packet slice -> pooled buffer let mut buffer = Buf::new(); buffer.extend_from_slice(record_slice); - let parsed = ParsedRecord::parse(&buffer, cs, 0)?; + let parsed = match ParsedRecord::parse(&buffer, cs, 0) { + Ok(p) => p, + Err(e) => { + // RFC 6347 §4.1.2.7: Invalid records SHOULD be silently discarded. + // This includes epoch 0 records with invalid ContentType. + trace!("Discarding record: parse failed: {}", e); + return Ok(None); + } + }; let parsed = Box::new(parsed); let record = Record { buffer, parsed }; diff --git a/src/dtls12/message/record.rs b/src/dtls12/message/record.rs index e22ef784..896e0af3 100644 --- a/src/dtls12/message/record.rs +++ b/src/dtls12/message/record.rs @@ -64,6 +64,21 @@ impl DTLSRecord { } let (input, epoch) = be_u16(input)?; // u16 + + // Epoch 0 records are plaintext in DTLS 1.2. Reject plaintext + // ApplicationData before record protection is active, and only accept + // the epoch-0 content types this implementation supports. + if epoch == 0 { + match content_type { + ContentType::ChangeCipherSpec | ContentType::Alert | ContentType::Handshake => {} + _ => { + return Err(Err::Failure(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))); + } + } + } let (input, sequence_number) = be_u48(input)?; // u48 let (input, length) = be_u16(input)?; // u16 @@ -162,4 +177,82 @@ mod tests { parsed.serialize(RECORD, &mut serialized); assert_eq!(&*serialized, RECORD); } + + #[test] + fn epoch_0_content_type_whitelist() { + // Epoch 0 is plaintext (RFC 6347 §4.1: epoch starts at 0, incremented by each CCS). + // Only ChangeCipherSpec(20), Alert(21), and Handshake(22) can legitimately + // appear unencrypted. ApplicationData in epoch 0 is rejected at parse time. + + fn build_epoch_0_record(content_type: u8) -> Vec { + vec![ + content_type, // ContentType + 0xFE, + 0xFD, // version: DTLS 1.2 + 0x00, + 0x00, // epoch: 0 (plaintext) + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, // sequence_number + 0x00, + 0x02, // length: 2 + 0xAA, + 0xBB, // fragment payload + ] + } + + // ALLOWED: ChangeCipherSpec (20) + let ccs = build_epoch_0_record(0x14); + assert!( + DTLSRecord::parse(&ccs, 0, 0).is_ok(), + "ChangeCipherSpec should be allowed in epoch 0" + ); + + // ALLOWED: Alert (21) + let alert = build_epoch_0_record(0x15); + assert!( + DTLSRecord::parse(&alert, 0, 0).is_ok(), + "Alert should be allowed in epoch 0" + ); + + // ALLOWED: Handshake (22) + let handshake = build_epoch_0_record(0x16); + assert!( + DTLSRecord::parse(&handshake, 0, 0).is_ok(), + "Handshake should be allowed in epoch 0" + ); + + // REJECTED: ApplicationData (23) + let app_data = build_epoch_0_record(0x17); + assert!( + DTLSRecord::parse(&app_data, 0, 0).is_err(), + "ApplicationData must be rejected in epoch 0" + ); + + // REJECTED: Ack (26) - valid in DTLS 1.3 but not DTLS 1.2 + let ack = build_epoch_0_record(0x1A); + assert!( + DTLSRecord::parse(&ack, 0, 0).is_err(), + "Ack must be rejected in DTLS 1.2 epoch 0" + ); + + // REJECTED: Unknown ContentType (0x99) + let unknown = build_epoch_0_record(0x99); + assert!( + DTLSRecord::parse(&unknown, 0, 0).is_err(), + "Unknown ContentType must be rejected in epoch 0" + ); + + // Verify that epoch 1+ allows ApplicationData (no whitelist restriction) + let mut epoch_1_app_data = build_epoch_0_record(0x17); + epoch_1_app_data[3] = 0x00; // epoch high byte + epoch_1_app_data[4] = 0x01; // epoch low byte = 1 + assert!( + DTLSRecord::parse(&epoch_1_app_data, 0, 0).is_ok(), + "ApplicationData should be allowed in epoch 1+" + ); + } } diff --git a/src/dtls13/message/record.rs b/src/dtls13/message/record.rs index 8556304b..cbbaf734 100644 --- a/src/dtls13/message/record.rs +++ b/src/dtls13/message/record.rs @@ -72,6 +72,18 @@ impl Dtls13Record { let (input, content_type) = ContentType::parse(input)?; // u8 let (input, version) = ProtocolVersion::parse(input)?; // u16 + // RFC 9147 §4.1: Only alert(21), handshake(22), and ack(26) are valid + // plaintext content types in DTLS 1.3. Reject all others. + match content_type { + ContentType::Alert | ContentType::Handshake | ContentType::Ack => {} + _ => { + return Err(Err::Failure(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))); + } + } + // Accept DTLS 1.0 or 1.2 in record layer per RFC 9147 §5.1 // (same legacy version handling as DTLS 1.2) match version { @@ -85,6 +97,16 @@ impl Dtls13Record { } let (input, epoch) = be_u16(input)?; // u16 + + // RFC 9147 §4.1: DTLSPlaintext records must use epoch 0. + // Epoch values other than 0 in plaintext format are invalid. + if epoch != 0 { + return Err(Err::Failure(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))); + } + let (input, sequence_number) = be_u48(input)?; // u48 let (input, length) = be_u16(input)?; // u16 let (rest, fragment_slice) = take(length as usize)(input)?; @@ -222,3 +244,111 @@ impl fmt::Debug for Dtls13Record { .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plaintext_content_type_whitelist() { + // RFC 9147 §4.1: Only alert(21), handshake(22), and ack(26) are valid + // plaintext content types in DTLS 1.3. Plaintext records must use epoch 0. + // ApplicationData and other invalid types are rejected at parse time. + + fn build_plaintext_record(content_type: u8) -> Vec { + vec![ + content_type, // ContentType + 0xFE, + 0xFD, // version: DTLS 1.2 + 0x00, + 0x00, // epoch: 0 (plaintext) + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, // sequence_number + 0x00, + 0x02, // length: 2 + 0xAA, + 0xBB, // fragment payload + ] + } + + fn build_plaintext_record_with_epoch(content_type: u8, epoch: u16) -> Vec { + vec![ + content_type, // ContentType + 0xFE, + 0xFD, // version: DTLS 1.2 + (epoch >> 8) as u8, + (epoch & 0xFF) as u8, // epoch + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, // sequence_number + 0x00, + 0x02, // length: 2 + 0xAA, + 0xBB, // fragment payload + ] + } + + // ALLOWED: Alert (21) with epoch 0 + let alert = build_plaintext_record(0x15); + assert!( + Dtls13Record::parse(&alert, 0).is_ok(), + "Alert should be allowed in plaintext DTLS 1.3" + ); + + // ALLOWED: Handshake (22) with epoch 0 + let handshake = build_plaintext_record(0x16); + assert!( + Dtls13Record::parse(&handshake, 0).is_ok(), + "Handshake should be allowed in plaintext DTLS 1.3" + ); + + // ALLOWED: Ack (26) with epoch 0 + let ack = build_plaintext_record(0x1A); + assert!( + Dtls13Record::parse(&ack, 0).is_ok(), + "Ack should be allowed in plaintext DTLS 1.3" + ); + + // REJECTED: ApplicationData (23) + let app_data = build_plaintext_record(0x17); + assert!( + Dtls13Record::parse(&app_data, 0).is_err(), + "ApplicationData must be rejected in plaintext DTLS 1.3" + ); + + // REJECTED: ChangeCipherSpec (20) - valid in DTLS 1.2 but not DTLS 1.3 + let ccs = build_plaintext_record(0x14); + assert!( + Dtls13Record::parse(&ccs, 0).is_err(), + "ChangeCipherSpec must be rejected in DTLS 1.3" + ); + + // REJECTED: Unknown ContentType (0xFF) + let unknown = build_plaintext_record(0xFF); + assert!( + Dtls13Record::parse(&unknown, 0).is_err(), + "Unknown ContentType must be rejected in plaintext DTLS 1.3" + ); + + // REJECTED: Plaintext format with epoch 1 (invalid per RFC 9147 §4.1) + let epoch_1_handshake = build_plaintext_record_with_epoch(0x16, 1); + assert!( + Dtls13Record::parse(&epoch_1_handshake, 0).is_err(), + "Plaintext format with epoch 1 must be rejected" + ); + + // REJECTED: Plaintext format with epoch 2 (should use unified header) + let epoch_2_handshake = build_plaintext_record_with_epoch(0x16, 2); + assert!( + Dtls13Record::parse(&epoch_2_handshake, 0).is_err(), + "Plaintext format with epoch 2 must be rejected" + ); + } +} diff --git a/tests/dtls12/edge.rs b/tests/dtls12/edge.rs index 5cac815b..1e17cb2c 100644 --- a/tests/dtls12/edge.rs +++ b/tests/dtls12/edge.rs @@ -512,3 +512,158 @@ fn dtls12_rejects_renegotiation() { "Server should still receive app data after renegotiation attempt was rejected" ); } + +#[test] +#[cfg(feature = "rcgen")] +fn dtls12_mixed_datagram_plaintext_first_then_valid() { + //! Test that a UDP datagram with bogus plaintext ApplicationData FIRST + //! followed by a valid encrypted record is handled correctly: the bogus + //! record is silently discarded and the valid one is still processed. + + use dimpl::certificate::generate_self_signed_certificate; + + let _ = env_logger::try_init(); + + let client_cert = generate_self_signed_certificate().expect("gen client cert"); + let server_cert = generate_self_signed_certificate().expect("gen server cert"); + + let config = dtls12_config(); + + let mut now = Instant::now(); + + let mut client = Dtls::new_12(Arc::clone(&config), client_cert, now); + client.set_active(true); + + let mut server = Dtls::new_12(config, server_cert, now); + server.set_active(false); + + now = complete_dtls12_handshake(&mut client, &mut server, now); + + // Send valid application data from client and capture the encrypted packet. + client + .send_application_data(b"valid-data") + .expect("send valid data"); + client.handle_timeout(now).expect("client timeout"); + let client_out = drain_outputs(&mut client); + assert!(!client_out.packets.is_empty(), "Should have valid packet"); + let valid_packet = &client_out.packets[0]; + + // Craft a plaintext ApplicationData record (epoch 0). + let bogus_record = vec![ + 0x17, // content_type: ApplicationData + 0xFE, 0xFD, // version: DTLS 1.2 + 0x00, 0x00, // epoch: 0 (plaintext) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, // sequence_number + 0x00, 0x06, // length: 6 + 0x62, 0x6F, 0x67, 0x75, 0x73, 0x21, // "bogus!" + ]; + + // Construct a mixed datagram: bogus plaintext record FIRST, then valid record. + let mut mixed_datagram = bogus_record; + mixed_datagram.extend_from_slice(valid_packet); + + // Deliver the mixed datagram to server. + server + .handle_packet(&mixed_datagram) + .expect("mixed datagram should not error"); + + server.handle_timeout(now).expect("server timeout"); + let server_out = drain_outputs(&mut server); + + // The valid record should still be processed despite the bogus first record. + assert!( + server_out + .app_data + .iter() + .any(|d| d.as_slice() == b"valid-data"), + "Server should receive the valid encrypted ApplicationData even when bogus record comes first" + ); + + // The bogus plaintext record should NOT produce any output. + assert_eq!( + server_out.app_data.len(), + 1, + "Should receive exactly 1 app data (the valid one), not the bogus plaintext" + ); + assert!( + !server_out + .app_data + .iter() + .any(|d| d.as_slice() == b"bogus!"), + "Bogus plaintext ApplicationData must not be delivered" + ); +} + +#[test] +#[cfg(feature = "rcgen")] +fn dtls12_mixed_datagram_valid_first_then_bogus() { + //! Test that a UDP datagram with a valid encrypted record FIRST followed + //! by bogus plaintext ApplicationData is handled correctly: the valid + //! record is processed and the trailing bogus record is discarded. + + use dimpl::certificate::generate_self_signed_certificate; + + let _ = env_logger::try_init(); + + let client_cert = generate_self_signed_certificate().expect("gen client cert"); + let server_cert = generate_self_signed_certificate().expect("gen server cert"); + + let config = dtls12_config(); + + let mut now = Instant::now(); + + let mut client = Dtls::new_12(Arc::clone(&config), client_cert, now); + client.set_active(true); + + let mut server = Dtls::new_12(config, server_cert, now); + server.set_active(false); + + now = complete_dtls12_handshake(&mut client, &mut server, now); + + // Send valid application data from client and capture the encrypted packet. + client + .send_application_data(b"valid-data") + .expect("send valid data"); + client.handle_timeout(now).expect("client timeout"); + let client_out = drain_outputs(&mut client); + assert!(!client_out.packets.is_empty(), "Should have valid packet"); + let valid_packet = &client_out.packets[0]; + + // Craft a plaintext ApplicationData record (epoch 0). + let bogus_record = vec![ + 0x17, // content_type: ApplicationData + 0xFE, 0xFD, // version: DTLS 1.2 + 0x00, 0x00, // epoch: 0 (plaintext) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, // sequence_number + 0x00, 0x06, // length: 6 + 0x62, 0x6F, 0x67, 0x75, 0x73, 0x21, // "bogus!" + ]; + + // Construct a mixed datagram: valid record FIRST, then bogus plaintext. + let mut mixed_datagram = valid_packet.clone(); + mixed_datagram.extend_from_slice(&bogus_record); + + // Deliver the mixed datagram to server. + server + .handle_packet(&mixed_datagram) + .expect("mixed datagram should not error"); + + server.handle_timeout(now).expect("server timeout"); + let server_out = drain_outputs(&mut server); + + // The valid record should be processed. + assert!( + server_out + .app_data + .iter() + .any(|d| d.as_slice() == b"valid-data"), + "Server should receive the valid encrypted ApplicationData even when bogus record follows" + ); + + // The bogus trailing record should NOT produce any output. + assert_eq!( + server_out.app_data.len(), + 1, + "Should receive exactly 1 app data (the valid one), not the bogus plaintext" + ); +} diff --git a/tests/dtls13/edge.rs b/tests/dtls13/edge.rs index 354101ab..52232b5a 100644 --- a/tests/dtls13/edge.rs +++ b/tests/dtls13/edge.rs @@ -572,9 +572,10 @@ fn dtls13_discards_plaintext_after_handshake() { ]; // Delivering a plaintext handshake record after the handshake is complete should - // either be silently discarded or produce an error. Either way the connection - // should remain operational for application data. - let _ = client.handle_packet(&bogus); + // be silently discarded per RFC 9147. The connection must remain operational. + client + .handle_packet(&bogus) + .expect("silently discard should not return error"); // Verify application data exchange still works. client @@ -1019,3 +1020,292 @@ fn dtls13_client_hello_padded_to_mtu() { "ClientHello should contain a padding extension (type 0x0015)" ); } + +#[test] +#[cfg(feature = "rcgen")] +fn dtls13_mixed_datagram_during_handshake_bogus_first() { + //! Test that during handshake, a mixed datagram with bogus plaintext + //! ApplicationData first and valid handshake record second is handled + //! correctly: bogus is discarded, valid handshake proceeds. + + use dimpl::certificate::generate_self_signed_certificate; + + let _ = env_logger::try_init(); + + let client_cert = generate_self_signed_certificate().expect("gen client cert"); + let server_cert = generate_self_signed_certificate().expect("gen server cert"); + + let config = dtls13_config(); + + let mut now = Instant::now(); + + let mut client = Dtls::new_13(Arc::clone(&config), client_cert, now); + client.set_active(true); + + let mut server = Dtls::new_13(config, server_cert, now); + server.set_active(false); + + // Client sends ClientHello. + client.handle_timeout(now).expect("client timeout"); + let client_out = drain_outputs(&mut client); + assert!(!client_out.packets.is_empty(), "Should have ClientHello"); + let client_hello = &client_out.packets[0]; + + // Craft bogus plaintext ApplicationData. + let bogus = vec![ + 0x17, // content_type: ApplicationData + 0xFE, 0xFD, // version: DTLS 1.2 + 0x00, 0x00, // epoch: 0 (plaintext) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, // sequence_number + 0x00, 0x05, // length: 5 + 0x62, 0x6F, 0x67, 0x75, 0x73, // "bogus" + ]; + + // Build mixed datagram: bogus first, then ClientHello. + let mut mixed = bogus; + mixed.extend_from_slice(client_hello); + + // Deliver mixed datagram to server. + server + .handle_packet(&mixed) + .expect("mixed datagram should not error"); + + // Server should process the ClientHello despite the bogus record. + server.handle_timeout(now).expect("server timeout"); + let server_out = drain_outputs(&mut server); + assert!( + !server_out.packets.is_empty(), + "Server should send ServerHello flight despite bogus record" + ); + + // Continue handshake normally. + deliver_packets(&server_out.packets, &mut client); + + let mut client_connected = false; + let mut server_connected = false; + for _ in 0..40 { + client.handle_timeout(now).expect("client timeout"); + server.handle_timeout(now).expect("server timeout"); + + let client_out = drain_outputs(&mut client); + let server_out = drain_outputs(&mut server); + + client_connected |= client_out.connected; + server_connected |= server_out.connected; + + deliver_packets(&client_out.packets, &mut server); + deliver_packets(&server_out.packets, &mut client); + + if client_connected && server_connected { + break; + } + now += Duration::from_millis(10); + } + + assert!( + client_connected, + "Handshake should complete despite bogus record in ClientHello datagram" + ); + assert!(server_connected, "Server should connect"); +} + +#[test] +#[cfg(feature = "rcgen")] +fn dtls13_mixed_datagram_plaintext_first_then_valid() { + //! Post-handshake: a UDP datagram with bogus plaintext ApplicationData FIRST + //! followed by a valid encrypted record is handled correctly: the bogus + //! record is silently discarded and the valid one is still processed. + + use dimpl::certificate::generate_self_signed_certificate; + + let _ = env_logger::try_init(); + + let client_cert = generate_self_signed_certificate().expect("gen client cert"); + let server_cert = generate_self_signed_certificate().expect("gen server cert"); + + let config = dtls13_config(); + + let mut now = Instant::now(); + + let mut client = Dtls::new_13(Arc::clone(&config), client_cert, now); + client.set_active(true); + + let mut server = Dtls::new_13(config, server_cert, now); + server.set_active(false); + + // Complete handshake. + let mut client_connected = false; + let mut server_connected = false; + for _ in 0..40 { + client.handle_timeout(now).expect("client timeout"); + server.handle_timeout(now).expect("server timeout"); + + let client_out = drain_outputs(&mut client); + let server_out = drain_outputs(&mut server); + + client_connected |= client_out.connected; + server_connected |= server_out.connected; + + deliver_packets(&client_out.packets, &mut server); + deliver_packets(&server_out.packets, &mut client); + + if client_connected && server_connected { + break; + } + now += Duration::from_millis(10); + } + + assert!(client_connected, "Client should be connected"); + assert!(server_connected, "Server should be connected"); + + // Send valid application data from client and capture the encrypted packet. + client + .send_application_data(b"valid-data") + .expect("send valid data"); + client.handle_timeout(now).expect("client timeout"); + let client_out = drain_outputs(&mut client); + assert!(!client_out.packets.is_empty(), "Should have valid packet"); + let valid_packet = &client_out.packets[0]; + + // Craft a plaintext ApplicationData record (epoch 0). + let bogus_record = vec![ + 0x17, // content_type: ApplicationData + 0xFE, 0xFD, // version: DTLS 1.2 + 0x00, 0x00, // epoch: 0 (plaintext) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, // sequence_number + 0x00, 0x06, // length: 6 + 0x62, 0x6F, 0x67, 0x75, 0x73, 0x21, // "bogus!" + ]; + + // Construct a mixed datagram: bogus plaintext record FIRST, then valid record. + let mut mixed_datagram = bogus_record; + mixed_datagram.extend_from_slice(valid_packet); + + // Deliver the mixed datagram to server. + server + .handle_packet(&mixed_datagram) + .expect("mixed datagram should not error"); + + server.handle_timeout(now).expect("server timeout"); + let server_out = drain_outputs(&mut server); + + // The valid record should still be processed despite the bogus first record. + assert!( + server_out + .app_data + .iter() + .any(|d| d.as_slice() == b"valid-data"), + "Server should receive the valid encrypted ApplicationData even when bogus record comes first" + ); + + // The bogus plaintext record should NOT produce any output. + assert_eq!( + server_out.app_data.len(), + 1, + "Should receive exactly 1 app data (the valid one), not the bogus plaintext" + ); + assert!( + !server_out + .app_data + .iter() + .any(|d| d.as_slice() == b"bogus!"), + "Bogus plaintext ApplicationData must not be delivered" + ); +} + +#[test] +#[cfg(feature = "rcgen")] +fn dtls13_mixed_datagram_valid_first_then_bogus() { + //! Post-handshake: a UDP datagram with a valid encrypted record FIRST + //! followed by bogus plaintext ApplicationData is handled correctly: the + //! valid record is processed and the trailing bogus record is discarded. + + use dimpl::certificate::generate_self_signed_certificate; + + let _ = env_logger::try_init(); + + let client_cert = generate_self_signed_certificate().expect("gen client cert"); + let server_cert = generate_self_signed_certificate().expect("gen server cert"); + + let config = dtls13_config(); + + let mut now = Instant::now(); + + let mut client = Dtls::new_13(Arc::clone(&config), client_cert, now); + client.set_active(true); + + let mut server = Dtls::new_13(config, server_cert, now); + server.set_active(false); + + // Complete handshake. + let mut client_connected = false; + let mut server_connected = false; + for _ in 0..40 { + client.handle_timeout(now).expect("client timeout"); + server.handle_timeout(now).expect("server timeout"); + + let client_out = drain_outputs(&mut client); + let server_out = drain_outputs(&mut server); + + client_connected |= client_out.connected; + server_connected |= server_out.connected; + + deliver_packets(&client_out.packets, &mut server); + deliver_packets(&server_out.packets, &mut client); + + if client_connected && server_connected { + break; + } + now += Duration::from_millis(10); + } + + assert!(client_connected, "Client should be connected"); + assert!(server_connected, "Server should be connected"); + + // Send valid application data from client and capture the encrypted packet. + client + .send_application_data(b"valid-data") + .expect("send valid data"); + client.handle_timeout(now).expect("client timeout"); + let client_out = drain_outputs(&mut client); + assert!(!client_out.packets.is_empty(), "Should have valid packet"); + let valid_packet = &client_out.packets[0]; + + // Craft a plaintext ApplicationData record (epoch 0). + let bogus_record = vec![ + 0x17, // content_type: ApplicationData + 0xFE, 0xFD, // version: DTLS 1.2 + 0x00, 0x00, // epoch: 0 (plaintext) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, // sequence_number + 0x00, 0x06, // length: 6 + 0x62, 0x6F, 0x67, 0x75, 0x73, 0x21, // "bogus!" + ]; + + // Construct a mixed datagram: valid record FIRST, then bogus plaintext. + let mut mixed_datagram = valid_packet.clone(); + mixed_datagram.extend_from_slice(&bogus_record); + + // Deliver the mixed datagram to server. + server + .handle_packet(&mixed_datagram) + .expect("mixed datagram should not error"); + + server.handle_timeout(now).expect("server timeout"); + let server_out = drain_outputs(&mut server); + + // The valid record should be processed. + assert!( + server_out + .app_data + .iter() + .any(|d| d.as_slice() == b"valid-data"), + "Server should receive the valid encrypted ApplicationData even when bogus record follows" + ); + + // The bogus trailing record should NOT produce any output. + assert_eq!( + server_out.app_data.len(), + 1, + "Should receive exactly 1 app data (the valid one), not the bogus plaintext" + ); +}