Skip to content

Commit 6cb5eeb

Browse files
committed
oauth: keep the callback listener alive through browser noise
Builds on the cherry-picked librespot-org#1706. That fix moves to the next connection when a request line is blank, which is right only if the blank line and the redirect arrive on separate connections. A leading CRLF on the same connection is what RFC 9112 section 2.2 tells servers to tolerate, and skipping the connection there discards the code and then blocks forever in accept(). Read past blank lines within a connection instead, and fall through to the next connection only when this one produces no request line at all, so both shapes work. Also stop treating a parseable but non-callback request as fatal. A GET /favicon.ico still returned Err from get_code, dropped the listener and closed the port, so the redirect carrying the code hit a dead port and the user was stranded on a browser "unable to connect" page. Answer those with a 400 and keep waiting. A per-connection read timeout stops a socket that is opened but never written from parking the login, and bounded skip counts keep a misbehaving client from holding the server open indefinitely. accept_authcode is split out so tests can bind port 0 instead of racing for a fixed port. Covers both librespot#1705 shapes plus the favicon case.
1 parent b6ca08a commit 6cb5eeb

1 file changed

Lines changed: 201 additions & 26 deletions

File tree

oauth/src/lib.rs

Lines changed: 201 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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};
2828
use thiserror::Error;
2929
use 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.
170209
fn 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\ncontent-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\ncontent-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\ncontent-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)]
521602
mod 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\nHost: 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\nHost: 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

Comments
 (0)