@@ -358,7 +358,7 @@ func (p *Proxy) handleQUICStream(ctx context.Context, stream *quic.Stream, conn
358358 return
359359 }
360360
361- if ! validQUICMsg (req , p .logger ) {
361+ if ! validQUICMsg (conn , req , p .logger ) {
362362 // If a peer encounters such an error condition, it is considered a
363363 // fatal error. It SHOULD forcibly abort the connection using QUIC's
364364 // CONNECTION_CLOSE mechanism and SHOULD use the DoQ error code
@@ -424,8 +424,8 @@ func (p *Proxy) respondQUIC(d *DNSContext) error {
424424}
425425
426426// validQUICMsg validates the incoming DNS message and returns false if
427- // something is wrong with the message.
428- func validQUICMsg (req * dns.Msg , l * slog.Logger ) (ok bool ) {
427+ // something is wrong with the message. conn, req, and l must not be nil.
428+ func validQUICMsg (conn * quic. Conn , req * dns.Msg , l * slog.Logger ) (ok bool ) {
429429 // See https://www.rfc-editor.org/rfc/rfc9250.html#name-protocol-errors
430430
431431 // 1. a client or server receives a message with a non-zero Message ID.
@@ -466,11 +466,52 @@ func validQUICMsg(req *dns.Msg, l *slog.Logger) (ok bool) {
466466
467467 // 7. a server receives a "replayable" transaction in 0-RTT data
468468 //
469- // The information necessary to validate this is not exposed by quic-go.
469+ // Per RFC 9250 Section 4.5, only QUERY and NOTIFY transactions may be
470+ // processed from 0-RTT early data. Any other transaction received as early
471+ // data is treated as a protocol error and aborts the connection.
472+ if isNonReplayableEarlyData (conn , req ) {
473+ l .Debug ("client sent non-replayable transaction as 0-rtt data" , "opcode" , req .Opcode )
474+
475+ return false
476+ }
470477
471478 return true
472479}
473480
481+ // isNonReplayableEarlyData returns true if req was received as QUIC 0-RTT early
482+ // data and its opcode is not safe to process before the handshake completes.
483+ // Per RFC 9250 Section 4.5, only QUERY and NOTIFY transactions may be processed
484+ // from 0-RTT data. conn and req must not be nil.
485+ //
486+ // See https://www.rfc-editor.org/rfc/rfc9250#section-4.5.
487+ func isNonReplayableEarlyData (conn * quic.Conn , req * dns.Msg ) (ok bool ) {
488+ if isReplayableOpcode (req .Opcode ) {
489+ return false
490+ }
491+
492+ if ! conn .ConnectionState ().Used0RTT {
493+ return false
494+ }
495+
496+ // The data is early data only if it arrived before the handshake completed
497+ // on a resumed 0-RTT connection.
498+ select {
499+ case <- conn .HandshakeComplete ():
500+ return false
501+ default :
502+ return true
503+ }
504+ }
505+
506+ // isReplayableOpcode returns true if a transaction with the given DNS opcode is
507+ // safe to process from QUIC 0-RTT early data. Per RFC 9250 Section 4.5, only
508+ // QUERY and NOTIFY transactions are considered replayable.
509+ //
510+ // See https://www.rfc-editor.org/rfc/rfc9250#section-4.5.
511+ func isReplayableOpcode (opcode int ) (ok bool ) {
512+ return opcode == dns .OpcodeQuery || opcode == dns .OpcodeNotify
513+ }
514+
474515// logShortQUICRead is a logging helper for short reads from a QUIC stream.
475516func logShortQUICRead (ctx context.Context , err error , l * slog.Logger ) {
476517 if err == nil {
0 commit comments