//! The local page `idx stockbit login` opens: a loopback server on //! 127.0.0.1 that takes the pasted session, checks it, and reports back. //! //! Stockbit has no OAuth redirect that could hand idx a session, and the //! browser will not give one site another site's cookie, so the page takes //! the value two ways, both one click: //! - **Read my clipboard**: the user copies `credentialStorage` in DevTools //! once and clicks the button (`navigator.clipboard`, allowed on localhost). //! - **Bookmarklet**: a link rendered with this run's port and secret that //! reads `document.cookie` on stockbit.com and opens the page with the value //! in the URL *fragment*, which browsers never send to the server. //! //! Safeguards: bound to 127.0.0.1, a random port, a per-run secret path (no //! secret, no page), a short life, `no-store`, no referrer, and a strict CSP. //! The session value is held in memory only on the way to `stockbit::login`; //! it is never written to a log or the response. use std::io::{Read, Write}; use std::net::{Ipv4Addr, TcpListener, TcpStream}; use std::time::{Duration, Instant}; use crate::error::IdxError; /// Largest paste accepted (the cookie is a few KB). const MAX_BODY: usize = 16 * 1024; /// Requests time out instead of pinning a thread; short, because a browser /// preconnect sends nothing at all. const IO_TIMEOUT: Duration = Duration::from_secs(3); /// Connections answered at once; beyond this, new ones are dropped. const MAX_CONNECTIONS: usize = 16; /// A running login page. pub struct LoginPage { listener: TcpListener, port: u16, secret: String, } impl LoginPage { /// Binds 127.0.0.1 on `port` (0 picks a free one). pub fn bind(port: u16) -> Result { let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).map_err(|e| { IdxError::Io(format!( "cannot open the Stockbit login page on 127.0.0.1:{port}: {e}" )) })?; let port = listener .local_addr() .map_err(|e| IdxError::Io(e.to_string()))? .port(); listener .set_nonblocking(true) .map_err(|e| IdxError::Io(e.to_string()))?; Ok(Self { listener, port, secret: random_secret(), }) } pub fn url(&self) -> String { format!("http://127.0.0.1:{}/login/{}", self.port, self.secret) } /// The port the page listens on. pub fn port(&self) -> u16 { self.port } /// Serves until `verify` accepts a paste or `timeout` passes. The error /// `verify` returns for a refused paste is shown on the page. /// /// Each connection gets its own thread: browsers open idle preconnects, /// and answering them one at a time used to hold the page back for the /// read timeout on every one. Pastes are checked one at a time, and none /// after one is accepted: two at once (a double click, the bookmarklet /// and the clipboard button) would each store a session, and the later /// could overwrite the one Stockbit had just rotated. pub fn wait(&self, timeout: Duration, verify: F) -> Result<(), IdxError> where F: Fn(&str) -> Result<(), IdxError> + Send + Sync + 'static, { let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); let accepted = std::sync::Mutex::new(false); let verify = std::sync::Arc::new(move |paste: &str| { let mut accepted = accepted.lock().unwrap_or_else(|e| e.into_inner()); if !*accepted { verify(paste)?; *accepted = true; } Ok(()) }); let page = std::sync::Arc::new(self.page()); let path = std::sync::Arc::new(format!("/login/{}", self.secret)); let busy = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let deadline = Instant::now() + timeout; loop { if done_rx.try_recv().is_ok() { return Ok(()); } if Instant::now() >= deadline { break; } match self.listener.accept() { Ok((stream, _)) => { // Browsers open a handful; anything beyond this is junk. if busy.load(std::sync::atomic::Ordering::Relaxed) >= MAX_CONNECTIONS { continue; } busy.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let (verify, page, path) = (verify.clone(), page.clone(), path.clone()); let busy = busy.clone(); let done = done_tx.clone(); std::thread::spawn(move || { if serve_once(stream, &path, &page, verify.as_ref()) { let _ = done.send(()); } busy.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); }); } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(Duration::from_millis(20)); } Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))), } } Err(IdxError::Io(format!( "the Stockbit login page timed out after {}s; its URL was {} (re-run it, or pin the \ port with --port)", timeout.as_secs(), self.url() ))) } /// The page, with this run's port and secret baked into the bookmarklet. fn page(&self) -> String { let target = self.url(); // Reads stockbit.com's session cookie and opens the page with it in // the fragment (never sent to a server, never in a referrer). let bookmarklet = format!( "javascript:(()=>{{const m=document.cookie.match(/(?:^|;\\s*)credentialStorage=([^;]*)/);\ if(!m){{alert('No Stockbit session in this browser: log in to stockbit.com first.');return}}\ window.open('{target}#t='+encodeURIComponent(m[1]))}})()" ); PAGE.replace("__TARGET__", &target) .replace("__BOOKMARKLET__", &html_escape(&bookmarklet)) } } /// The forward to run where the browser is, for the page on `port`: /// `ssh -L :127.0.0.1: @`. /// /// The target comes from `SSH_CONNECTION` (`client_ip client_port server_ip /// server_port`) when idx runs inside an SSH session, else from `USER` and /// `HOSTNAME`, else it is left for the reader to fill in. pub fn ssh_forward(port: u16) -> String { let target = ssh_target().unwrap_or_else(|| "".to_string()); format!("ssh -L {port}:127.0.0.1:{port} {target}") } fn ssh_target() -> Option { if let Ok(connection) = std::env::var("SSH_CONNECTION") { let fields: Vec<&str> = connection.split_whitespace().collect(); if let Some(server) = fields.get(2) { return Some(match std::env::var("USER") { Ok(user) => format!("{user}@{server}"), Err(_) => (*server).to_string(), }); } } let user = std::env::var("USER").ok()?; let host = std::env::var("HOSTNAME").ok()?; Some(format!("{user}@{host}")) } /// Answers one request; true when a paste was accepted. fn serve_once( mut stream: TcpStream, page_path: &str, page: &str, verify: &dyn Fn(&str) -> Result<(), IdxError>, ) -> bool { let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); let Some(request) = read_request(&mut stream) else { // An idle preconnect or a malformed request: drop it quietly. return false; }; match (request.method.as_str(), request.path.as_str()) { ("GET", p) if p == page_path => { respond( &mut stream, 200, "text/html; charset=utf-8", page.as_bytes(), ); } ("POST", p) if p == page_path => match verify(&request.body) { Ok(()) => { respond( &mut stream, 200, "application/json", br#"{"ok":true,"message":"Connected. You can close this tab."}"#, ); return true; } Err(err) => { let body = serde_json::json!({ "ok": false, "message": err.to_string() }); respond( &mut stream, 200, "application/json", body.to_string().as_bytes(), ); } }, ("GET", "/favicon.ico") => respond(&mut stream, 204, "text/plain", b""), _ => respond(&mut stream, 404, "text/plain", b"not found"), } false } fn random_secret() -> String { (0..4) .map(|_| format!("{:08x}", fastrand::u32(..))) .collect::>() .join("") } fn html_escape(value: &str) -> String { value .replace('&', "&") .replace('"', """) .replace('<', "<") .replace('>', ">") } struct Request { method: String, path: String, body: String, } /// A minimal HTTP/1.1 request: request line, headers, and a bounded body. fn read_request(stream: &mut TcpStream) -> Option { let mut raw = Vec::new(); let mut chunk = [0u8; 2048]; let header_end = loop { let n = stream.read(&mut chunk).ok()?; if n == 0 { return None; } raw.extend_from_slice(&chunk[..n]); if let Some(at) = find(&raw, b"\r\n\r\n") { break at + 4; } if raw.len() > MAX_BODY { return None; } }; let head = String::from_utf8_lossy(&raw[..header_end]).into_owned(); let mut lines = head.lines(); let mut request_line = lines.next()?.split_whitespace(); let method = request_line.next()?.to_string(); let path = request_line.next()?.split('?').next()?.to_string(); let length = lines .filter_map(|line| line.split_once(':')) .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) .and_then(|(_, value)| value.trim().parse::().ok()) .unwrap_or(0) .min(MAX_BODY); let mut body = raw[header_end..].to_vec(); while body.len() < length { let n = stream.read(&mut chunk).ok()?; if n == 0 { break; } body.extend_from_slice(&chunk[..n]); } body.truncate(length); Some(Request { method, path, body: String::from_utf8_lossy(&body).into_owned(), }) } fn find(haystack: &[u8], needle: &[u8]) -> Option { haystack .windows(needle.len()) .position(|window| window == needle) } fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) { let reason = match status { 200 => "OK", 204 => "No Content", 400 => "Bad Request", _ => "Not Found", }; let head = format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\ Content-Length: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\n\ X-Content-Type-Options: nosniff\r\n\ Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; \ script-src 'unsafe-inline'; connect-src 'self'\r\nConnection: close\r\n\r\n", body.len() ); let _ = stream.write_all(head.as_bytes()); let _ = stream.write_all(body); let _ = stream.flush(); } const PAGE: &str = r#" Connect Stockbit to idx
idxStockbit

Connect your Stockbit account

idx then reads live quotes and the order book with your own session. The session stays on this machine.

  1. Sign in to Stockbit

    Open stockbit.com in this browser and sign in. If the tab is already signed in, go to the next step.

    Open stockbit.com
  2. Send the session to idx

    While the Stockbit tab is open, click this button. It reads the session cookie in your own browser.

    Send my Stockbit session

    If the browser blocks it, drag the button to your bookmarks bar, open stockbit.com, then click it there.

Or paste the cookie by hand
  1. On stockbit.com, open DevTools with F12.
  2. Go to Application, then Cookies, then https://stockbit.com.
  3. Copy the value of credentialStorage and paste it below.

idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser that shares this session signs out once idx refreshes it.

This page stops listening after a few minutes.

"#; #[cfg(test)] mod tests { use std::io::{Read as _, Write as _}; use std::net::TcpStream; use super::*; fn request(url: &str, method: &str, body: &str) -> (u16, String) { let rest = url.trim_start_matches("http://"); let (authority, path) = rest.split_once('/').expect("path"); let mut stream = TcpStream::connect(authority).expect("connect"); // A server that never answers must fail the test, not hang it. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); write!( stream, "{method} /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Length: {}\r\n\r\n{body}", body.len() ) .unwrap(); let mut raw = String::new(); stream.read_to_string(&mut raw).unwrap(); let status = raw .split_whitespace() .nth(1) .and_then(|s| s.parse().ok()) .expect("status"); let body = raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string(); (status, body) } /// Two pastes at once are checked one at a time, and the second is not /// checked at all once the first is accepted. #[test] fn concurrent_pastes_are_checked_once() { let page = LoginPage::bind(0).unwrap(); let url = page.url(); let checks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counted = checks.clone(); let handle = std::thread::spawn(move || { page.wait(Duration::from_secs(10), move |_| { counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); std::thread::sleep(Duration::from_millis(300)); Ok(()) }) }); std::thread::sleep(Duration::from_millis(100)); let clients: Vec<_> = (0..2) .map(|_| { let url = url.clone(); std::thread::spawn(move || request(&url, "POST", "good")) }) .collect(); for client in clients { let (status, body) = client.join().unwrap(); assert_eq!(status, 200); assert!(body.contains("\"ok\":true"), "{body}"); } handle.join().unwrap().unwrap(); assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 1); } #[test] fn serves_the_page_only_with_the_secret_and_accepts_a_good_paste() { let page = LoginPage::bind(0).unwrap(); let url = page.url(); let bad = url.replace(&page.secret, "not-the-secret"); let handle = std::thread::spawn(move || { let verify = |paste: &str| match paste.trim() { "good" => Ok(()), _ => Err(IdxError::InvalidInput("not a Stockbit session".to_string())), }; page.wait(Duration::from_secs(10), verify) }); std::thread::sleep(Duration::from_millis(100)); assert_eq!(request(&bad, "GET", "").0, 404); let (status, html) = request(&url, "GET", ""); assert_eq!(status, 200); assert!(html.contains("Connect Stockbit to idx")); assert!(html.contains("javascript:"), "bookmarklet rendered"); // The bookmarklet carries this run's page and a fragment, not the // secret in a query string. assert!(html.contains(&format!("{url}#t=")), "bookmarklet target"); let (status, body) = request(&url, "POST", "nonsense"); assert_eq!(status, 200); assert!(body.contains("\"ok\":false"), "{body}"); let (status, body) = request(&url, "POST", "good"); assert_eq!(status, 200); assert!(body.contains("\"ok\":true"), "{body}"); handle.join().unwrap().expect("accepted"); } /// The CSP allows no fetch of its own, so a stray asset would fail /// silently; the page also has to keep the two entry points the docs name. #[test] fn the_page_is_self_contained_and_keeps_both_entry_points() { let page = LoginPage::bind(0).unwrap().page(); for forbidden in ["