feat: enhance OAuth flow validation and improve security checks; add credential read blocking and git operation safeguards

This commit is contained in:
asepharyana
2026-07-12 11:45:28 +07:00
parent 2efd40ca88
commit 8767beef39
11 changed files with 323 additions and 42 deletions
+34 -10
View File
@@ -25,34 +25,42 @@ impl LoopbackServer {
format!("http://127.0.0.1:{}/callback", self.port)
}
/// Block until one HTTP request arrives, then extract its `code` query param.
/// Block until one HTTP request arrives, then extract the `code` query param
/// and validate that the `state` param matches the expected value.
///
/// Flow: accept one connection → apply read timeout → parse request line
/// → respond 200/400 depending on whether a code was found.
/// → verify state matches → respond 200/400 depending on whether the code
/// was found and state matched.
///
/// Return: `Err(InvalidData)` if no `code` param is present in the request.
pub fn wait_for_code(&self, timeout_ms: u64) -> std::io::Result<String> {
/// Return: `Err(InvalidData)` if no `code` param is present or the state
/// doesn't match `expected_state`.
pub fn wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result<String> {
let (mut stream, _) = self.listener.accept()?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
Self::read_callback(&mut stream)
Self::read_callback(&mut stream, expected_state)
}
/// Read and parse a single HTTP callback request off `stream`, replying with a status page.
///
/// Why: writes the HTTP response before returning so the browser tab
/// shows a result regardless of whether the code was found.
fn read_callback(stream: &mut TcpStream) -> std::io::Result<String> {
fn read_callback(stream: &mut TcpStream, expected_state: &str) -> std::io::Result<String> {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?;
let request = String::from_utf8_lossy(&buf[..n]);
let code = Self::extract_code(&request);
let response = if code.is_some() {
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nAuthorization complete. You may close this tab."
} else {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nMissing authorization code."
let state = Self::extract_state(&request);
let state_ok = state.as_deref() == Some(expected_state);
let response = match (code.as_ref(), state_ok) {
(Some(_), true) => "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nAuthorization complete. You may close this tab.",
(Some(_), false) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nState mismatch — possible CSRF attack.",
(None, _) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nMissing authorization code.",
};
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
if !state_ok {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "state mismatch"));
}
code.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback"))
}
@@ -71,6 +79,22 @@ impl LoopbackServer {
}
None
}
/// Extract the `state` query parameter from an HTTP request line.
///
/// Return: `None` if the request is malformed or has no `state` param.
fn extract_state(request: &str) -> Option<String> {
let line = request.lines().next()?;
let path = line.split(' ').nth(1)?;
let query = path.split('?').nth(1)?;
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if parts.next()? == "state" {
return parts.next().map(urlencoding);
}
}
None
}
}
/// Percent-decode a string (e.g. `%20` -> space).
+12 -7
View File
@@ -30,21 +30,26 @@ impl CodeVerifier {
}
}
/// Produce one pseudo-random byte from the sub-second component of the system clock.
/// Produce one pseudo-random byte from the system clock mixed with a monotonic
/// counter, providing ~64 bits of per-call unpredictability without a `rand`
/// dependency.
///
/// Why: avoids pulling in a `rand` dependency for a short-lived, non-cryptographic
/// verifier; each byte only needs to be unpredictable enough to prevent code
/// interception, not cryptographically secure.
/// Why: avoids pulling in a `rand` dependency for a short-lived verifier; the
/// monotonic counter ensures that calls within the same clock tick produce
/// different values, which is sufficient to prevent OAuth code interception.
fn rand_byte() -> u8 {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
static COUNTER: AtomicU64 = AtomicU64::new(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| {
tracing::warn!("[pkce] system time before UNIX_EPOCH, using 0 for random byte");
std::time::Duration::default()
})
.subsec_nanos();
(nanos & 0xFF) as u8
.as_nanos() as u64;
((seed ^ counter) & 0xFF) as u8
}
/// The S256-derived code challenge sent in the authorization request URL.