use crate::failure::Failure;
pub struct Gate {
port: u16,
token: String,
boot: Option<String>,
}
pub struct Incoming<'a> {
pub path: &'a str,
pub host: Option<&'a str>,
pub origin: Option<&'a str>,
pub token: Option<&'a str>,
pub cookie: Option<&'a str>,
}
pub enum Verdict {
Boot,
Serve,
Deny(Denial),
}
pub enum Denial {
ForeignHost,
ForeignOrigin,
NoValidToken,
}
pub(super) const SESSION_COOKIE: &str = "vivac_session";
fn session_cookie(raw: &str) -> Option<&str> {
raw.split(';').find_map(|pair| {
let (name, value) = pair.split_once('=')?;
(name.trim() == SESSION_COOKIE).then(|| value.trim())
})
}
fn random_hex(bytes: usize) -> Result<String, Failure> {
let mut buf = vec![0u8; bytes];
getrandom::getrandom(&mut buf)
.map_err(|e| Failure::Io(std::io::Error::other(e.to_string())))?;
Ok(buf.iter().map(|b| format!("{b:02x}")).collect())
}
fn constant_time_eq(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
let mut diff: u8 = (a.len() != b.len()) as u8;
for i in 0..a.len().max(b.len()) {
let x = a.get(i).copied().unwrap_or(0);
let y = b.get(i).copied().unwrap_or(0);
diff |= x ^ y;
}
diff == 0
}
fn boot_key(path: &str) -> Option<&str> {
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == "k").then_some(v)
})
}
impl Gate {
pub fn new(port: u16) -> Result<Gate, Failure> {
Ok(Gate {
port,
token: random_hex(32)?,
boot: Some(random_hex(32)?),
})
}
pub fn boot_url(&self) -> String {
format!(
"http://127.0.0.1:{}/?k={}",
self.port,
self.boot.as_deref().unwrap_or("")
)
}
pub fn token(&self) -> &str {
&self.token
}
pub fn admit(&mut self, r: &Incoming) -> Verdict {
let host_ok = match r.host {
Some(h) => {
h == format!("127.0.0.1:{}", self.port) || h == format!("localhost:{}", self.port)
}
None => false,
};
if !host_ok {
return Verdict::Deny(Denial::ForeignHost);
}
if let Some(origin) = r.origin {
let origin_ok = origin == format!("http://127.0.0.1:{}", self.port)
|| origin == format!("http://localhost:{}", self.port);
if !origin_ok {
return Verdict::Deny(Denial::ForeignOrigin);
}
}
if let (Some(key), Some(expected)) = (boot_key(r.path), self.boot.as_deref()) {
if constant_time_eq(key, expected) {
self.boot = None;
return Verdict::Boot;
}
}
let offered = r.token.or_else(|| r.cookie.and_then(session_cookie));
if let Some(token) = offered {
if constant_time_eq(token, &self.token) {
return Verdict::Serve;
}
}
Verdict::Deny(Denial::NoValidToken)
}
}
#[cfg(test)]
mod tests {
use super::*;
const PORT: u16 = 4173;
fn gate() -> Gate {
Gate::new(PORT).unwrap_or_else(|e| panic!("{}", e.message()))
}
fn incoming<'a>(
path: &'a str,
host: Option<&'a str>,
origin: Option<&'a str>,
token: Option<&'a str>,
) -> Incoming<'a> {
Incoming {
path,
host,
origin,
token,
cookie: None,
}
}
fn browsing<'a>(path: &'a str, host: &'a str, cookie: &'a str) -> Incoming<'a> {
Incoming {
path,
host: Some(host),
origin: None,
token: None,
cookie: Some(cookie),
}
}
fn is_denied(v: Verdict, want: fn(&Denial) -> bool) -> bool {
matches!(v, Verdict::Deny(d) if want(&d))
}
fn is_foreign_host(v: Verdict) -> bool {
is_denied(v, |d| matches!(d, Denial::ForeignHost))
}
fn is_foreign_origin(v: Verdict) -> bool {
is_denied(v, |d| matches!(d, Denial::ForeignOrigin))
}
fn is_no_valid_token(v: Verdict) -> bool {
is_denied(v, |d| matches!(d, Denial::NoValidToken))
}
#[test]
fn the_loopback_host_passes_by_ip_and_by_name() {
let mut g = gate();
let token = g.token().to_string();
let ip_host = format!("127.0.0.1:{PORT}");
assert!(matches!(
g.admit(&incoming("/", Some(&ip_host), None, Some(&token))),
Verdict::Serve
));
let name_host = format!("localhost:{PORT}");
assert!(matches!(
g.admit(&incoming("/", Some(&name_host), None, Some(&token))),
Verdict::Serve
));
}
#[test]
fn a_foreign_host_is_denied() {
let mut g = gate();
assert!(is_foreign_host(g.admit(&incoming(
"/",
Some("malo.com"),
None,
None
))));
}
#[test]
fn the_right_name_on_the_wrong_port_is_a_foreign_host() {
let mut g = gate();
assert!(is_foreign_host(g.admit(&incoming(
"/",
Some("127.0.0.1:9999"),
None,
None
))));
}
#[test]
fn no_host_header_at_all_is_a_foreign_host() {
let mut g = gate();
assert!(is_foreign_host(g.admit(&incoming("/", None, None, None))));
}
#[test]
fn no_origin_with_a_good_token_serves() {
let mut g = gate();
let token = g.token().to_string();
let host = format!("127.0.0.1:{PORT}");
assert!(matches!(
g.admit(&incoming("/", Some(&host), None, Some(&token))),
Verdict::Serve
));
}
#[test]
fn a_foreign_origin_is_denied() {
let mut g = gate();
let token = g.token().to_string();
let host = format!("127.0.0.1:{PORT}");
assert!(is_foreign_origin(g.admit(&incoming(
"/",
Some(&host),
Some("http://malo.com"),
Some(&token)
))));
}
#[test]
fn a_matching_origin_with_a_good_token_serves() {
let mut g = gate();
let token = g.token().to_string();
let host = format!("127.0.0.1:{PORT}");
let origin = format!("http://127.0.0.1:{PORT}");
assert!(matches!(
g.admit(&incoming("/", Some(&host), Some(&origin), Some(&token))),
Verdict::Serve
));
}
#[test]
fn a_foreign_host_stays_denied_even_with_a_good_token() {
let mut g = gate();
let token = g.token().to_string();
assert!(is_foreign_host(g.admit(&incoming(
"/",
Some("malo.com"),
None,
Some(&token)
))));
}
#[test]
fn the_boot_key_works_once_and_never_again() {
let mut g = gate();
let host = format!("127.0.0.1:{PORT}");
let key = g.boot.clone().unwrap();
let path = format!("/?k={key}");
assert!(matches!(
g.admit(&incoming(&path, Some(&host), None, None)),
Verdict::Boot
));
assert!(is_no_valid_token(g.admit(&incoming(
&path,
Some(&host),
None,
None
))));
}
#[test]
fn no_token_and_no_key_is_denied() {
let mut g = gate();
let host = format!("127.0.0.1:{PORT}");
assert!(is_no_valid_token(g.admit(&incoming(
"/",
Some(&host),
None,
None
))));
}
#[test]
fn a_wrong_token_is_denied() {
let mut g = gate();
let host = format!("127.0.0.1:{PORT}");
assert!(is_no_valid_token(g.admit(&incoming(
"/",
Some(&host),
None,
Some("not-the-token")
))));
}
#[test]
fn a_wrong_boot_key_is_denied() {
let mut g = gate();
let host = format!("127.0.0.1:{PORT}");
assert!(is_no_valid_token(g.admit(&incoming(
"/?k=not-the-key",
Some(&host),
None,
None
))));
}
#[test]
fn the_session_token_still_serves_after_the_boot_key_is_spent() {
let mut g = gate();
let host = format!("127.0.0.1:{PORT}");
let key = g.boot.clone().unwrap();
let path = format!("/?k={key}");
g.admit(&incoming(&path, Some(&host), None, None));
let token = g.token().to_string();
assert!(matches!(
g.admit(&incoming("/", Some(&host), None, Some(&token))),
Verdict::Serve
));
}
#[test]
fn two_gates_never_share_a_token() {
let a = gate();
let b = gate();
assert_ne!(a.token(), b.token());
}
#[test]
fn a_browser_carrying_only_the_cookie_is_served() {
let mut g = gate();
let token = g.token().to_string();
let host = format!("127.0.0.1:{PORT}");
let jar = format!("vivac_session={token}");
assert!(matches!(
g.admit(&browsing("/p/x/why/g1", &host, &jar)),
Verdict::Serve
));
}
#[test]
fn the_session_cookie_is_found_among_others() {
let mut g = gate();
let token = g.token().to_string();
let host = format!("127.0.0.1:{PORT}");
for jar in [
format!("theme=dark; vivac_session={token}; tz=utc"),
format!("vivac_session={token}; theme=dark"),
format!("theme=dark;vivac_session={token}"),
format!(" vivac_session = {token} "),
] {
assert!(
matches!(g.admit(&browsing("/", &host, &jar)), Verdict::Serve),
"not found in {jar}"
);
}
}
#[test]
fn a_cookie_whose_name_merely_looks_like_the_session_one_is_refused() {
let mut g = gate();
let token = g.token().to_string();
let host = format!("127.0.0.1:{PORT}");
for jar in [
format!("vivac_session_other={token}"),
format!("not_vivac_session={token}"),
format!("vivac_sessio={token}"),
] {
assert!(
is_no_valid_token(g.admit(&browsing("/", &host, &jar))),
"accepted {jar}"
);
}
}
#[test]
fn a_cookie_carrying_the_wrong_token_is_refused() {
let mut g = gate();
let host = format!("127.0.0.1:{PORT}");
assert!(is_no_valid_token(g.admit(&browsing(
"/",
&host,
"vivac_session=0000000000000000000000000000000000000000000000000000000000000000"
))));
}
#[test]
fn a_cookie_header_that_makes_no_sense_is_just_not_a_match() {
let mut g = gate();
let host = format!("127.0.0.1:{PORT}");
for jar in ["", ";", "=", "vivac_session", "vivac_session=", "; ;;"] {
assert!(
is_no_valid_token(g.admit(&browsing("/", &host, jar))),
"accepted {jar:?}"
);
}
}
#[test]
fn a_cookie_does_not_excuse_a_foreign_host_or_origin() {
let mut g = gate();
let token = g.token().to_string();
let jar = format!("vivac_session={token}");
assert!(is_foreign_host(g.admit(&Incoming {
path: "/",
host: Some("evil.example:80"),
origin: None,
token: None,
cookie: Some(&jar),
})));
let host = format!("127.0.0.1:{PORT}");
assert!(is_foreign_origin(g.admit(&Incoming {
path: "/",
host: Some(&host),
origin: Some("http://evil.example"),
token: None,
cookie: Some(&jar),
})));
}
}