@@ -24,7 +24,7 @@ use oauth2::{
2424 StandardTokenResponse , TokenResponse , TokenUrl , basic:: BasicClient , basic:: BasicTokenType ,
2525} ;
2626
27- use log:: { error, info, trace} ;
27+ use log:: { debug , error, info, trace, warn } ;
2828use thiserror:: Error ;
2929use url:: Url ;
3030
@@ -166,6 +166,45 @@ fn get_authcode_stdin() -> Result<AuthorizationCode, OAuthError> {
166166 get_code ( buffer. trim ( ) )
167167}
168168
169+ /// How long to wait for a request line before giving up on a connection and
170+ /// listening for the next one, so a browser that opens a socket without ever
171+ /// writing to it cannot park the login forever.
172+ const REQUEST_READ_TIMEOUT : Duration = Duration :: from_secs ( 10 ) ;
173+
174+ /// How many blank lines to read past on a single connection, and how many
175+ /// unusable connections to tolerate overall. Both exist only so a misbehaving
176+ /// client cannot keep the server alive indefinitely.
177+ const MAX_BLANK_LINES : usize = 8 ;
178+ const MAX_SKIPPED_REQUESTS : usize = 64 ;
179+
180+ /// Read the request line from `reader`, skipping any blank lines that precede it.
181+ ///
182+ /// Returns `None` when the peer closed the connection, timed out, or sent nothing
183+ /// but blank lines.
184+ ///
185+ /// The blank lines are skipped *within* a connection, not by moving on to the
186+ /// next one. RFC 9112 section 2.2 requires a server to tolerate a CRLF received
187+ /// before the request line, and at least some browsers send one, so the blank
188+ /// line and the request that carries the code can arrive on the same connection.
189+ /// Abandoning the connection on a blank line would discard the code and then
190+ /// block forever waiting for a redirect that has already been delivered.
191+ fn read_request_line ( reader : & mut impl BufRead ) -> Option < String > {
192+ for _ in 0 ..MAX_BLANK_LINES {
193+ let mut line = String :: new ( ) ;
194+ match reader. read_line ( & mut line) {
195+ // Connection closed without sending a request.
196+ Ok ( 0 ) => return None ,
197+ Ok ( _) if line. trim ( ) . is_empty ( ) => continue ,
198+ Ok ( _) => return Some ( line) ,
199+ Err ( e) => {
200+ debug ! ( "OAuth callback read error, moving to the next connection: {e}" ) ;
201+ return None ;
202+ }
203+ }
204+ }
205+ None
206+ }
207+
169208/// Spawn HTTP server at provided socket address to accept OAuth callback and return auth code.
170209fn get_authcode_listener (
171210 socket_address : SocketAddr ,
@@ -178,39 +217,81 @@ fn get_authcode_listener(
178217 } ) ?;
179218 info ! ( "OAuth server listening on {socket_address:?}" ) ;
180219
181- // The server will terminate itself after collecting the first code.
220+ accept_authcode ( & listener, message)
221+ }
222+
223+ /// Accept connections until one carries the authorization code.
224+ ///
225+ /// Anything a browser may send before the redirect is skipped rather than
226+ /// mistaken for the callback: a bare CRLF, a connection opened and closed without
227+ /// a request, or an unrelated request such as `/favicon.ico`. Treating any of
228+ /// those as the callback returns an error, which drops the listener and closes
229+ /// the port, so the redirect that actually carries the code arrives at a dead
230+ /// port and the user is stranded on a browser "unable to connect" page with no
231+ /// way to recover. See librespot-org/librespot#1705.
232+ ///
233+ /// Split out from `get_authcode_listener` so tests can supply a listener bound to
234+ /// port 0 rather than racing for a fixed port.
235+ fn accept_authcode (
236+ listener : & TcpListener ,
237+ message : String ,
238+ ) -> Result < AuthorizationCode , OAuthError > {
239+ let mut skipped = 0usize ;
240+
182241 for incoming in listener. incoming ( ) {
242+ if skipped >= MAX_SKIPPED_REQUESTS {
243+ warn ! ( "OAuth callback: too many unusable requests, giving up" ) ;
244+ return Err ( OAuthError :: AuthCodeListenerTerminated ) ;
245+ }
246+
183247 let mut stream = match incoming {
184248 Ok ( stream) => stream,
185- Err ( _) => continue ,
249+ Err ( e) => {
250+ debug ! ( "OAuth callback accept error, still listening: {e}" ) ;
251+ skipped += 1 ;
252+ continue ;
253+ }
186254 } ;
255+ // Bounded so a connection that never sends a request line cannot block
256+ // the login; the real redirect waits in the accept backlog meanwhile.
257+ let _ = stream. set_read_timeout ( Some ( REQUEST_READ_TIMEOUT ) ) ;
187258
188259 let mut reader = BufReader :: new ( & stream) ;
189- let mut request_line = String :: new ( ) ;
190- reader
191- . read_line ( & mut request_line)
192- . map_err ( |_| OAuthError :: AuthCodeListenerRead ) ?;
193-
194- if request_line. trim ( ) . is_empty ( ) {
195- continue ; // Skip empty lines
196- }
260+ let Some ( request_line) = read_request_line ( & mut reader) else {
261+ debug ! ( "OAuth callback: no request line, still listening" ) ;
262+ skipped += 1 ;
263+ continue ;
264+ } ;
197265
198- let redirect_url = request_line
199- . split_whitespace ( )
200- . nth ( 1 )
201- . ok_or ( OAuthError :: AuthCodeListenerParse ) ? ;
202- let code = get_code ( & ( "http://localhost" . to_string ( ) + redirect_url ) ) ;
266+ let Some ( redirect_url) = request_line. split_whitespace ( ) . nth ( 1 ) else {
267+ debug ! ( "OAuth callback: unparseable request line, still listening" ) ;
268+ skipped += 1 ;
269+ continue ;
270+ } ;
203271
204- let response = format ! (
205- "HTTP/1.1 200 OK\r \n content-length: {}\r \n \r \n {}" ,
206- message. len( ) ,
207- message
208- ) ;
209- stream
210- . write_all ( response. as_bytes ( ) )
211- . map_err ( |_| OAuthError :: AuthCodeListenerWrite ) ?;
212- return code;
272+ match get_code ( & ( "http://localhost" . to_string ( ) + redirect_url) ) {
273+ Ok ( code) => {
274+ let response = format ! (
275+ "HTTP/1.1 200 OK\r \n content-length: {}\r \n \r \n {}" ,
276+ message. len( ) ,
277+ message
278+ ) ;
279+ stream
280+ . write_all ( response. as_bytes ( ) )
281+ . map_err ( |_| OAuthError :: AuthCodeListenerWrite ) ?;
282+
283+ return Ok ( code) ;
284+ }
285+ // Browser noise (`/favicon.ico`, `/`, a pre-flight). Answer it so the
286+ // browser is not left hanging, then keep waiting for the redirect.
287+ Err ( e) => {
288+ debug ! ( "OAuth callback: request carried no code ({e}), still listening" ) ;
289+ let _ = stream. write_all ( b"HTTP/1.1 400 Bad Request\r \n content-length: 0\r \n \r \n " ) ;
290+ skipped += 1 ;
291+ }
292+ }
213293 }
294+
214295 Err ( OAuthError :: AuthCodeListenerTerminated )
215296}
216297
@@ -519,10 +600,104 @@ pub fn get_access_token(
519600
520601#[ cfg( test) ]
521602mod test {
522- use std:: net:: { IpAddr , Ipv4Addr , Ipv6Addr , SocketAddr } ;
603+ use std:: io:: Cursor ;
604+ use std:: net:: { IpAddr , Ipv4Addr , Ipv6Addr , SocketAddr , TcpStream } ;
523605
524606 use super :: * ;
525607
608+ const CODE_REQUEST : & str = "GET /login?code=testcode123 HTTP/1.1\r \n Host: 127.0.0.1\r \n \r \n " ;
609+
610+ /// Drive `accept_authcode` against a listener on port 0 while `client` talks
611+ /// to it, and return whatever the server resolved.
612+ fn run_listener (
613+ client : impl FnOnce ( SocketAddr ) + Send + ' static ,
614+ ) -> Result < AuthorizationCode , OAuthError > {
615+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . expect ( "bind ephemeral port" ) ;
616+ let addr = listener. local_addr ( ) . expect ( "local addr" ) ;
617+ let handle = std:: thread:: spawn ( move || client ( addr) ) ;
618+ let result = accept_authcode ( & listener, "done" . to_string ( ) ) ;
619+ handle. join ( ) . expect ( "client thread panicked" ) ;
620+ result
621+ }
622+
623+ fn send ( addr : SocketAddr , bytes : & [ u8 ] ) -> TcpStream {
624+ let mut stream = TcpStream :: connect ( addr) . expect ( "connect" ) ;
625+ stream. write_all ( bytes) . expect ( "write" ) ;
626+ stream. flush ( ) . expect ( "flush" ) ;
627+ stream
628+ }
629+
630+ #[ test]
631+ fn request_line_read_past_leading_blank_lines ( ) {
632+ let mut reader = Cursor :: new ( format ! ( "\r \n \r \n {CODE_REQUEST}" ) . into_bytes ( ) ) ;
633+ let line = read_request_line ( & mut reader) . expect ( "should find the request line" ) ;
634+ assert ! (
635+ line. starts_with( "GET /login?code=testcode123" ) ,
636+ "got: {line:?}"
637+ ) ;
638+ }
639+
640+ #[ test]
641+ fn request_line_none_when_peer_sends_nothing ( ) {
642+ let mut reader = Cursor :: new ( Vec :: new ( ) ) ;
643+ assert ! ( read_request_line( & mut reader) . is_none( ) ) ;
644+ }
645+
646+ #[ test]
647+ fn request_line_none_when_only_blank_lines ( ) {
648+ let mut reader = Cursor :: new ( "\r \n " . repeat ( MAX_BLANK_LINES + 4 ) . into_bytes ( ) ) ;
649+ assert ! ( read_request_line( & mut reader) . is_none( ) ) ;
650+ }
651+
652+ /// The librespot#1705 case as a *leading CRLF on the same connection*.
653+ /// Skipping to the next connection here would discard the code and hang.
654+ #[ test]
655+ fn blank_line_before_code_on_same_connection ( ) {
656+ let code = run_listener ( |addr| {
657+ let _stream = send ( addr, format ! ( "\r \n {CODE_REQUEST}" ) . as_bytes ( ) ) ;
658+ } )
659+ . expect ( "should recover the code" ) ;
660+ assert_eq ! ( code. secret( ) , "testcode123" ) ;
661+ }
662+
663+ /// The librespot#1705 case as a *separate, dataless connection*.
664+ #[ test]
665+ fn blank_connection_before_code_on_next_connection ( ) {
666+ let code = run_listener ( |addr| {
667+ drop ( TcpStream :: connect ( addr) . expect ( "connect" ) ) ;
668+ let _stream = send ( addr, CODE_REQUEST . as_bytes ( ) ) ;
669+ } )
670+ . expect ( "should recover the code" ) ;
671+ assert_eq ! ( code. secret( ) , "testcode123" ) ;
672+ }
673+
674+ /// A parseable request that simply is not the callback must not end the flow.
675+ #[ test]
676+ fn favicon_request_before_code_is_skipped ( ) {
677+ let code = run_listener ( |addr| {
678+ let _noise = send (
679+ addr,
680+ b"GET /favicon.ico HTTP/1.1\r \n Host: 127.0.0.1\r \n \r \n " ,
681+ ) ;
682+ let _stream = send ( addr, CODE_REQUEST . as_bytes ( ) ) ;
683+ } )
684+ . expect ( "should recover the code" ) ;
685+ assert_eq ! ( code. secret( ) , "testcode123" ) ;
686+ }
687+
688+ #[ test]
689+ fn gives_up_after_too_many_unusable_requests ( ) {
690+ let result = run_listener ( |addr| {
691+ for _ in 0 ..( MAX_SKIPPED_REQUESTS + 1 ) {
692+ drop ( TcpStream :: connect ( addr) . expect ( "connect" ) ) ;
693+ }
694+ } ) ;
695+ assert ! ( matches!(
696+ result,
697+ Err ( OAuthError :: AuthCodeListenerTerminated )
698+ ) ) ;
699+ }
700+
526701 #[ test]
527702 fn get_socket_address_none ( ) {
528703 // No port
0 commit comments