use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use tirith_core::dashboard::{self, DashboardSnapshot, HookSummary};
const TOKEN_TTL: Duration = Duration::from_secs(60 * 60);
fn mono_ttl_expired(elapsed: Duration) -> bool {
elapsed >= TOKEN_TTL
}
const ACCEPT_POLL: Duration = Duration::from_millis(500);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Ok,
Unauthorized,
Forbidden,
}
pub fn authorize(
host_header: Option<&str>,
query_token: Option<&str>,
expected_token: &str,
now: DateTime<Utc>,
issued_at: DateTime<Utc>,
) -> Decision {
match host_header {
Some(h) if is_loopback_host(h) => {}
_ => return Decision::Forbidden,
}
let age = now.signed_duration_since(issued_at);
let ttl = chrono::Duration::from_std(TOKEN_TTL).unwrap_or_else(|_| chrono::Duration::hours(1));
if age >= ttl {
return Decision::Unauthorized;
}
match query_token {
Some(t) if constant_time_eq(t.as_bytes(), expected_token.as_bytes()) => Decision::Ok,
_ => Decision::Unauthorized,
}
}
fn is_loopback_host(host: &str) -> bool {
let host = host.trim();
let hostname = match host.rsplit_once(':') {
Some((name, port)) => {
if port.is_empty() || !port.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
name
}
None => host,
};
hostname.eq_ignore_ascii_case("127.0.0.1") || hostname.eq_ignore_ascii_case("localhost")
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
let max = a.len().max(b.len());
let mut diff = (a.len() != b.len()) as u8;
for i in 0..max {
diff |= a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0);
}
diff == 0
}
fn token_from_target(target: &str) -> Option<String> {
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
for pair in query.split('&') {
let (key, val) = pair.split_once('=').unwrap_or((pair, ""));
if key == "token" {
return Some(percent_decode(val));
}
}
None
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
match (hi, lo) {
(Some(h), Some(l)) => {
out.push((h * 16 + l) as u8);
i += 3;
}
_ => {
out.push(bytes[i]);
i += 1;
}
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn build_snapshot() -> DashboardSnapshot {
let detected_shell = crate::cli::init::detect_shell().to_string();
let (_profile, hook_installed) =
crate::cli::doctor::check_shell_profile(&detected_shell, "tirith: dashboard:");
let hook = HookSummary {
shell: detected_shell,
installed: hook_installed,
};
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let cwd_str = cwd.display().to_string();
dashboard::build_snapshot(None, Some(&cwd_str), hook)
}
fn emit_error(json: bool, ctx: &str, msg: &str) -> bool {
if json {
let v = serde_json::json!({ "error": msg });
crate::cli::write_json_stdout(&v, &format!("{ctx}: failed to write JSON output"))
} else {
eprintln!("{ctx}: {msg}");
true
}
}
#[derive(serde::Serialize)]
struct ExportJson<'a> {
written: bool,
path: String,
bytes: usize,
snapshot: &'a DashboardSnapshot,
}
pub fn export(out: Option<&str>, json: bool) -> i32 {
let snapshot = build_snapshot();
let html = dashboard::render_html(&snapshot);
let path = match resolve_export_path(out) {
Ok(p) => p,
Err(e) => {
if !emit_error(json, "tirith dashboard export", &e) {
return 2;
}
return 1;
}
};
if let Err(e) = write_html_file(&path, &html) {
if !emit_error(json, "tirith dashboard export", &e) {
return 2;
}
return 1;
}
if json {
let result = ExportJson {
written: true,
path: path.display().to_string(),
bytes: html.len(),
snapshot: &snapshot,
};
if !crate::cli::write_json_stdout(
&result,
"tirith dashboard export: failed to write JSON output",
) {
return 2;
}
} else {
println!(
"Wrote dashboard to {} ({} bytes).",
path.display(),
html.len()
);
println!("Open it in a browser — it is a self-contained local file with no network calls.");
}
0
}
fn resolve_export_path(out: Option<&str>) -> Result<PathBuf, String> {
match out {
None => {
let date = Utc::now().format("%Y-%m-%d").to_string();
let filename = format!("tirith-dashboard-{date}.html");
let dir = home::home_dir()
.map(|h| h.join("Documents"))
.unwrap_or_else(|| PathBuf::from("."));
Ok(dir.join(filename))
}
Some(s) => {
let p = Path::new(s);
if s == "." || p.is_dir() {
Ok(p.join("dashboard.html"))
} else {
Ok(p.to_path_buf())
}
}
}
}
fn write_html_file(path: &Path, html: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create export directory {}: {e}", parent.display()))?;
}
if let Ok(meta) = std::fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
return Err(format!(
"refusing to write export through symlink {}",
path.display()
));
}
}
crate::cli::write_file_atomic(path, html.as_bytes(), true)
.map_err(|e| format!("write {}: {e}", path.display()))?;
#[cfg(not(unix))]
{
eprintln!(
"tirith dashboard export: WARNING: on this platform the report at {} \
is not restricted to your user account explicitly; its protection \
relies on the directory's inherited permissions. Move it somewhere \
only you can read if you copy it elsewhere.",
path.display()
);
}
Ok(())
}
fn bind_loopback(
port: Option<u16>,
) -> Result<tiny_http::Server, Box<dyn std::error::Error + Send + Sync + 'static>> {
let bind_addr = SocketAddr::from(([127, 0, 0, 1], port.unwrap_or(0)));
tiny_http::Server::http(bind_addr)
}
pub fn serve(port: Option<u16>, json: bool) -> i32 {
let token = match dashboard::generate_serve_token() {
Ok(t) => t,
Err(e) => {
if !emit_error(json, "tirith dashboard serve", &e) {
return 2;
}
return 1;
}
};
let bind_port = port.unwrap_or(0);
let server = match bind_loopback(port) {
Ok(s) => s,
Err(e) => {
if !emit_error(
json,
"tirith dashboard serve",
&format!("cannot bind 127.0.0.1:{bind_port}: {e}"),
) {
return 2;
}
return 1;
}
};
let actual_port = match server.server_addr().to_ip() {
Some(addr) => addr.port(),
None => {
if !emit_error(
json,
"tirith dashboard serve",
"bound socket has no IP address",
) {
return 2;
}
return 1;
}
};
let url = format!("http://127.0.0.1:{actual_port}/?token={token}");
let issued_at = Utc::now();
let issued_mono = Instant::now();
if json {
#[derive(serde::Serialize)]
struct ServeJson {
url: String,
host: String,
port: u16,
token_ttl_secs: u64,
}
if !crate::cli::write_json_stdout(
&ServeJson {
url: url.clone(),
host: "127.0.0.1".to_string(),
port: actual_port,
token_ttl_secs: TOKEN_TTL.as_secs(),
},
"tirith dashboard serve: failed to write JSON output",
) {
return 2;
}
} else {
println!("tirith dashboard — serving on loopback only (127.0.0.1).");
println!();
println!(" {url}");
println!();
println!(
"Open the URL above. The token is ephemeral (in memory only, TTL {} min) and is",
TOKEN_TTL.as_secs() / 60
);
println!("never written to disk. Press Ctrl-C to stop.");
}
loop_outcome_exit_code(serve_loop(&server, &token, issued_at, issued_mono))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LoopOutcome {
TtlExpired,
AcceptError,
}
fn loop_outcome_exit_code(outcome: LoopOutcome) -> i32 {
match outcome {
LoopOutcome::TtlExpired => 0,
LoopOutcome::AcceptError => 1,
}
}
fn serve_loop(
server: &tiny_http::Server,
token: &str,
issued_at: DateTime<Utc>,
issued_mono: Instant,
) -> LoopOutcome {
loop {
if mono_ttl_expired(issued_mono.elapsed()) {
eprintln!("tirith dashboard serve: token expired; stopping.");
return LoopOutcome::TtlExpired;
}
match server.recv_timeout(ACCEPT_POLL) {
Ok(Some(request)) => handle_request(request, token, issued_at, issued_mono),
Ok(None) => continue, Err(e) => {
eprintln!("tirith dashboard serve: accept error: {e}");
return LoopOutcome::AcceptError;
}
}
}
}
fn with_security_headers(
mut response: tiny_http::Response<std::io::Cursor<Vec<u8>>>,
) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
for (name, value) in [
("Content-Type", "text/html; charset=utf-8"),
(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'",
),
("X-Content-Type-Options", "nosniff"),
("Referrer-Policy", "no-referrer"),
("Cache-Control", "no-store"),
] {
if let Ok(h) = tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()) {
response = response.with_header(h);
}
}
response
}
fn handle_request(
request: tiny_http::Request,
token: &str,
issued_at: DateTime<Utc>,
issued_mono: Instant,
) {
if mono_ttl_expired(issued_mono.elapsed()) {
let response = tiny_http::Response::from_string("401 Unauthorized").with_status_code(401);
let _ = request.respond(with_security_headers(response));
return;
}
let host_header = request
.headers()
.iter()
.find(|h| h.field.equiv("Host"))
.map(|h| h.value.as_str().to_string());
let query_token = token_from_target(request.url());
let decision = authorize(
host_header.as_deref(),
query_token.as_deref(),
token,
Utc::now(),
issued_at,
);
if decision != Decision::Ok {
let response = match decision {
Decision::Unauthorized => {
tiny_http::Response::from_string("401 Unauthorized").with_status_code(401)
}
Decision::Forbidden => {
tiny_http::Response::from_string("403 Forbidden").with_status_code(403)
}
Decision::Ok => unreachable!("handled above"),
};
let _ = request.respond(with_security_headers(response));
return;
}
let snapshot = build_snapshot();
let html = dashboard::render_html(&snapshot);
let response = with_security_headers(tiny_http::Response::from_string(html));
let _ = request.respond(response);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn emit_error_json_shape_is_structured_error_object() {
let v = serde_json::json!({ "error": "write /no/such/dir/x.html: nonexistent" });
let s = serde_json::to_string(&v).expect("error JSON must serialize");
let parsed: serde_json::Value =
serde_json::from_str(&s).expect("error output must be parseable JSON, not text");
assert!(
parsed["error"].is_string(),
"JSON error must carry a string `error` field, got: {parsed}"
);
assert!(
parsed["error"]
.as_str()
.is_some_and(|e| e.contains("nonexistent")),
"the `error` field must surface the failure detail, got: {parsed}"
);
}
#[test]
fn emit_error_human_arm_returns_true_without_touching_stdout_contract() {
assert!(
emit_error(false, "tirith dashboard export", "boom"),
"human-mode emit_error must return true (only a JSON write failure returns false)"
);
assert!(
emit_error(true, "tirith dashboard export", "boom"),
"json-mode emit_error must return true when the stdout write succeeds"
);
}
#[test]
fn export_unwritable_path_exits_nonzero_in_both_modes() {
use crate::cli::test_harness::{EnvGuard, ENV_LOCK};
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home_tmp = tempfile::tempdir().expect("home tempdir");
let config_tmp = tempfile::tempdir().expect("config tempdir");
let data_tmp = tempfile::tempdir().expect("data tempdir");
let state_tmp = tempfile::tempdir().expect("state tempdir");
let _home = EnvGuard::set("HOME", home_tmp.path());
let _userprofile = EnvGuard::set("USERPROFILE", home_tmp.path());
let _xdg_config = EnvGuard::set("XDG_CONFIG_HOME", config_tmp.path());
let _xdg_data = EnvGuard::set("XDG_DATA_HOME", data_tmp.path());
let _xdg_state = EnvGuard::set("XDG_STATE_HOME", state_tmp.path());
let _appdata = EnvGuard::set("APPDATA", config_tmp.path());
let _localappdata = EnvGuard::set("LOCALAPPDATA", config_tmp.path());
let dir = tempfile::tempdir().expect("out tempdir");
let not_a_dir = dir.path().join("regular-file");
std::fs::write(¬_a_dir, b"i am a file, not a directory").unwrap();
let bad_out = not_a_dir.join("dashboard.html");
let bad_out = bad_out.to_string_lossy().into_owned();
let code_json = export(Some(&bad_out), true);
assert_ne!(
code_json, 0,
"export --json to an unwritable path must exit non-zero (exit code authoritative)"
);
assert_eq!(
code_json, 1,
"the write-failure exit code is 1 (2 is reserved for a JSON-write/broken-pipe failure)"
);
let code_human = export(Some(&bad_out), false);
assert_eq!(
code_human, 1,
"export (human mode) to an unwritable path must exit 1, same as the JSON path"
);
}
#[test]
fn loop_outcome_exit_code_maps_ttl_to_success_and_error_to_failure() {
assert_eq!(
loop_outcome_exit_code(LoopOutcome::TtlExpired),
0,
"a clean TTL expiry is the normal end-of-life and must exit 0"
);
let err_code = loop_outcome_exit_code(LoopOutcome::AcceptError);
assert_ne!(
err_code, 0,
"an accept/recv error must NOT report success (exit 0)"
);
assert_eq!(err_code, 1, "the fatal accept-error exit code is 1");
}
fn assert_bound_loopback(server: &tiny_http::Server, label: &str) {
let addr = server
.server_addr()
.to_ip()
.expect("bound socket has an IP");
assert!(
addr.ip().is_loopback(),
"{label}: bind address {} must be loopback",
addr.ip()
);
assert_eq!(
addr.ip(),
std::net::Ipv4Addr::LOCALHOST,
"{label}: bind address must be 127.0.0.1 exactly, not {} (e.g. 0.0.0.0)",
addr.ip()
);
}
#[test]
fn bind_loopback_ephemeral_binds_127_0_0_1() {
let server = bind_loopback(None).expect("ephemeral loopback bind must succeed");
assert_bound_loopback(&server, "bind_loopback(None)");
let port = server.server_addr().to_ip().unwrap().port();
assert_ne!(port, 0, "an ephemeral bind must resolve to a concrete port");
}
#[test]
fn bind_loopback_explicit_port_is_honored_and_loopback() {
let held = bind_loopback(None).expect("hold a loopback server");
assert_bound_loopback(&held, "held loopback server");
let port = held.server_addr().to_ip().unwrap().port();
let second = bind_loopback(Some(port));
assert!(
second.is_err(),
"binding the explicit, already-held port {port} must fail (proves the explicit \
port is honored, not silently replaced with an ephemeral/0.0.0.0 bind)"
);
drop(held);
}
fn token() -> &'static str {
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
}
fn now() -> DateTime<Utc> {
Utc::now()
}
#[test]
fn authorize_ok_for_loopback_host_and_good_token() {
let n = now();
assert_eq!(
authorize(Some("127.0.0.1:8080"), Some(token()), token(), n, n),
Decision::Ok
);
assert_eq!(
authorize(Some("localhost:9000"), Some(token()), token(), n, n),
Decision::Ok
);
assert_eq!(
authorize(Some("127.0.0.1"), Some(token()), token(), n, n),
Decision::Ok
);
assert_eq!(
authorize(Some("LOCALHOST"), Some(token()), token(), n, n),
Decision::Ok,
"host comparison is case-insensitive"
);
}
#[test]
fn authorize_forbids_foreign_host_dns_rebinding() {
let n = now();
assert_eq!(
authorize(Some("evil.example.com"), Some(token()), token(), n, n),
Decision::Forbidden
);
assert_eq!(
authorize(Some("attacker.test:8080"), Some(token()), token(), n, n),
Decision::Forbidden
);
assert_eq!(
authorize(Some("0.0.0.0:8080"), Some(token()), token(), n, n),
Decision::Forbidden
);
assert_eq!(
authorize(Some("127.0.0.1.evil.com"), Some(token()), token(), n, n),
Decision::Forbidden
);
assert_eq!(
authorize(None, Some(token()), token(), n, n),
Decision::Forbidden
);
assert_eq!(
authorize(Some("[::1]:8080"), Some(token()), token(), n, n),
Decision::Forbidden
);
}
#[test]
fn authorize_unauthorized_for_missing_or_wrong_token() {
let n = now();
assert_eq!(
authorize(Some("127.0.0.1:8080"), None, token(), n, n),
Decision::Unauthorized
);
assert_eq!(
authorize(Some("127.0.0.1:8080"), Some("nope"), token(), n, n),
Decision::Unauthorized
);
let mut wrong = token().to_string();
wrong.replace_range(0..1, "0");
assert_eq!(
authorize(Some("127.0.0.1:8080"), Some(&wrong), token(), n, n),
Decision::Unauthorized
);
assert_eq!(
authorize(Some("127.0.0.1:8080"), Some(""), token(), n, n),
Decision::Unauthorized
);
}
#[test]
fn authorize_unauthorized_when_token_expired() {
let issued = Utc::now() - chrono::Duration::hours(2); let n = Utc::now();
assert_eq!(
authorize(Some("127.0.0.1:8080"), Some(token()), token(), n, issued),
Decision::Unauthorized
);
let fresh = n - chrono::Duration::minutes(59);
assert_eq!(
authorize(Some("127.0.0.1:8080"), Some(token()), token(), n, fresh),
Decision::Ok
);
}
#[test]
fn mono_ttl_expired_at_and_past_the_boundary() {
assert!(!mono_ttl_expired(Duration::from_secs(0)));
assert!(!mono_ttl_expired(TOKEN_TTL - Duration::from_secs(1)));
assert!(mono_ttl_expired(TOKEN_TTL));
assert!(mono_ttl_expired(TOKEN_TTL + Duration::from_secs(1)));
assert!(mono_ttl_expired(TOKEN_TTL * 5));
}
#[test]
fn authorize_host_checked_before_ttl_and_token() {
let issued = Utc::now() - chrono::Duration::hours(5);
let n = Utc::now();
assert_eq!(
authorize(Some("evil.com"), Some("wrong"), token(), n, issued),
Decision::Forbidden
);
}
#[test]
fn loopback_host_accepts_only_known_loopback_spellings() {
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.1:8080"));
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("localhost:65535"));
assert!(is_loopback_host(" localhost:3000 "));
assert!(!is_loopback_host("0.0.0.0"));
assert!(!is_loopback_host("example.com"));
assert!(!is_loopback_host("127.0.0.1:notaport"));
assert!(!is_loopback_host("127.0.0.1:"));
assert!(!is_loopback_host("[::1]"));
assert!(!is_loopback_host("::1"));
assert!(!is_loopback_host("localhost.evil.com"));
assert!(!is_loopback_host(""));
}
#[test]
fn token_from_target_extracts_and_decodes() {
assert_eq!(token_from_target("/?token=abc"), Some("abc".to_string()));
assert_eq!(
token_from_target("/?token=abc&x=1"),
Some("abc".to_string())
);
assert_eq!(
token_from_target("/?x=1&token=def"),
Some("def".to_string())
);
assert_eq!(token_from_target("/"), None);
assert_eq!(token_from_target("/?x=1"), None);
assert_eq!(token_from_target("/?token=a%2Bb"), Some("a+b".to_string()));
assert_eq!(token_from_target("/?token=a+b"), Some("a b".to_string()));
}
#[test]
fn constant_time_eq_matches_only_equal_bytes() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"ab"));
assert!(!constant_time_eq(b"", b"x"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn resolve_export_path_variants() {
let p = resolve_export_path(Some(".")).unwrap();
assert_eq!(p, Path::new("./dashboard.html"));
let p = resolve_export_path(Some("/tmp/x/report.html")).unwrap();
assert_eq!(p, Path::new("/tmp/x/report.html"));
let p = resolve_export_path(None).unwrap();
let name = p.file_name().unwrap().to_string_lossy();
assert!(name.starts_with("tirith-dashboard-"));
assert!(name.ends_with(".html"));
}
#[test]
fn resolve_export_path_existing_dir_gets_dashboard_html() {
let dir = tempfile::tempdir().unwrap();
let p = resolve_export_path(Some(dir.path().to_str().unwrap())).unwrap();
assert_eq!(p, dir.path().join("dashboard.html"));
}
#[test]
fn write_html_file_lands_intact_bytes_and_0600_perms() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dashboard.html");
let html = "<html>\n<body>tirith dashboard export — intact?</body>\n</html>\n";
write_html_file(&path, html).expect("export must succeed");
let read_back = std::fs::read(&path).expect("read exported file");
assert_eq!(
read_back,
html.as_bytes(),
"exported file must contain the complete HTML, untruncated"
);
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n != "dashboard.html")
.collect();
assert!(
leftovers.is_empty(),
"no temp/partial files should remain after a successful export, found: {leftovers:?}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"exported report must be owner-only 0600, got {mode:o}"
);
}
}
#[test]
fn write_html_file_overwrite_preserves_intact_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dashboard.html");
write_html_file(&path, "<html>old</html>\n").expect("first export");
let new_html = "<html>\nnew and longer content here\n</html>\n";
write_html_file(&path, new_html).expect("second export");
assert_eq!(
std::fs::read(&path).unwrap(),
new_html.as_bytes(),
"re-export must atomically replace with the complete new content"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"re-exported report must remain 0600, got {mode:o}"
);
}
}
use std::io::{Read as _, Write as _};
use std::net::TcpStream;
fn raw_get_response(port: u16, target: &str, host: &str) -> (u16, String) {
let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("connect");
let req = format!("GET {target} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
stream.write_all(req.as_bytes()).expect("write request");
let mut buf = Vec::new();
stream.read_to_end(&mut buf).expect("read response");
let text = String::from_utf8_lossy(&buf).into_owned();
let status = text
.lines()
.next()
.unwrap_or_default()
.split_whitespace()
.nth(1)
.and_then(|c| c.parse().ok())
.unwrap_or(0);
(status, text)
}
fn raw_get_status(port: u16, target: &str, host: &str) -> u16 {
raw_get_response(port, target, host).0
}
#[test]
fn serve_loopback_authorizes_real_requests() {
let token = "feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface";
let issued = Utc::now();
let issued_mono = Instant::now();
let server = tiny_http::Server::http(SocketAddr::from(([127, 0, 0, 1], 0)))
.expect("bind 127.0.0.1:0");
let addr = server.server_addr().to_ip().expect("ip addr");
assert!(addr.ip().is_loopback(), "must bind a loopback address");
let port = addr.port();
let tok = token.to_string();
let handle = std::thread::spawn(move || {
for _ in 0..3 {
match server.recv() {
Ok(req) => handle_request(req, &tok, issued, issued_mono),
Err(_) => break,
}
}
});
assert_eq!(
raw_get_status(
port,
&format!("/?token={token}"),
&format!("127.0.0.1:{port}")
),
200,
"loopback Host + valid token must serve 200"
);
assert_eq!(
raw_get_status(port, &format!("/?token={token}"), "evil.example.com"),
403,
"a non-loopback Host must be refused 403"
);
assert_eq!(
raw_get_status(port, "/?token=wrong", &format!("127.0.0.1:{port}")),
401,
"a wrong token must be rejected 401"
);
handle.join().expect("server thread");
}
#[test]
fn unauthorized_responses_carry_hardening_headers() {
let token = "feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface";
let issued = Utc::now();
let issued_mono = Instant::now();
let server = tiny_http::Server::http(SocketAddr::from(([127, 0, 0, 1], 0)))
.expect("bind 127.0.0.1:0");
let port = server.server_addr().to_ip().expect("ip addr").port();
let tok = token.to_string();
let handle = std::thread::spawn(move || {
for _ in 0..2 {
match server.recv() {
Ok(req) => handle_request(req, &tok, issued, issued_mono),
Err(_) => break,
}
}
});
fn assert_hardened(status: u16, text: &str, expected_status: u16, label: &str) {
assert_eq!(status, expected_status, "{label}: wrong status");
let lower = text.to_ascii_lowercase();
for needle in [
"cache-control: no-store",
"content-security-policy: default-src 'none'; style-src 'unsafe-inline'",
"x-content-type-options: nosniff",
"referrer-policy: no-referrer",
] {
assert!(
lower.contains(needle),
"{label}: response missing header `{needle}`\nfull response: {text:?}"
);
}
}
let (status, text) = raw_get_response(port, "/?token=wrong", &format!("127.0.0.1:{port}"));
assert_hardened(status, &text, 401, "401 wrong-token response");
let (status, text) =
raw_get_response(port, &format!("/?token={token}"), "evil.example.com");
assert_hardened(status, &text, 403, "403 foreign-Host response");
handle.join().expect("server thread");
}
#[test]
fn unauthorized_body_bearing_request_is_rejected_by_auth() {
use std::time::Duration as StdDuration;
let token = "feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface";
let issued = Utc::now();
let issued_mono = Instant::now();
let server = tiny_http::Server::http(SocketAddr::from(([127, 0, 0, 1], 0)))
.expect("bind 127.0.0.1:0");
let port = server.server_addr().to_ip().expect("ip addr").port();
let tok = token.to_string();
let handle = std::thread::spawn(move || {
if let Ok(req) = server.recv() {
handle_request(req, &tok, issued, issued_mono);
}
});
let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("connect");
let body = "x".repeat(2048);
let req = format!(
"POST /?token=wrong HTTP/1.1\r\n\
Host: evil.example.com\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n\
{body}",
body.len()
);
stream.write_all(req.as_bytes()).expect("write request");
stream.flush().expect("flush");
stream
.set_read_timeout(Some(StdDuration::from_secs(10)))
.expect("set read timeout");
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf);
let text = String::from_utf8_lossy(&buf);
let status: u16 = text
.lines()
.next()
.unwrap_or_default()
.split_whitespace()
.nth(1)
.and_then(|c| c.parse().ok())
.unwrap_or(0);
assert_eq!(
status, 403,
"a foreign-Host request must be refused 403 regardless of its body; \
got status {status} (response: {text:?})"
);
handle.join().expect("server thread");
}
#[test]
fn authorized_request_is_served_without_body_drain() {
use crate::cli::test_harness::{CwdGuard, EnvGuard, ENV_LOCK};
use std::time::{Duration as StdDuration, Instant as StdInstant};
let token = "feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface";
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home_tmp = tempfile::tempdir().expect("home tempdir");
let config_tmp = tempfile::tempdir().expect("config tempdir");
let data_tmp = tempfile::tempdir().expect("data tempdir");
let state_tmp = tempfile::tempdir().expect("state tempdir");
let cwd_tmp = tempfile::tempdir().expect("cwd tempdir");
let _home = EnvGuard::set("HOME", home_tmp.path());
let _userprofile = EnvGuard::set("USERPROFILE", home_tmp.path());
let _xdg_config = EnvGuard::set("XDG_CONFIG_HOME", config_tmp.path());
let _xdg_data = EnvGuard::set("XDG_DATA_HOME", data_tmp.path());
let _xdg_state = EnvGuard::set("XDG_STATE_HOME", state_tmp.path());
let _appdata = EnvGuard::set("APPDATA", config_tmp.path());
let _localappdata = EnvGuard::set("LOCALAPPDATA", config_tmp.path());
let _policy_root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _server_url = EnvGuard::remove("TIRITH_SERVER_URL");
let _api_key = EnvGuard::remove("TIRITH_API_KEY");
let _cwd = CwdGuard::set(cwd_tmp.path());
fn serve_one(token: &str, raw_request: &str) -> u16 {
let issued = Utc::now();
let issued_mono = Instant::now();
let server = tiny_http::Server::http(SocketAddr::from(([127, 0, 0, 1], 0)))
.expect("bind 127.0.0.1:0");
let port = server.server_addr().to_ip().expect("ip addr").port();
let tok = token.to_string();
let handle = std::thread::spawn(move || {
if let Ok(req) = server.recv() {
handle_request(req, &tok, issued, issued_mono);
}
});
let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("connect");
let req = raw_request.replace("{port}", &port.to_string());
stream.write_all(req.as_bytes()).expect("write request");
stream.flush().expect("flush");
stream
.set_read_timeout(Some(StdDuration::from_secs(2)))
.expect("set read timeout");
let deadline = StdInstant::now() + StdDuration::from_secs(30);
let mut acc: Vec<u8> = Vec::with_capacity(256);
loop {
if let Some(code) = parse_status_line(&acc) {
handle.join().expect("server thread");
return code;
}
if StdInstant::now() >= deadline {
handle.join().expect("server thread");
return 0;
}
let mut buf = [0u8; 256];
match stream.read(&mut buf) {
Ok(0) => {
handle.join().expect("server thread");
return parse_status_line(&acc).unwrap_or(0);
}
Ok(n) => acc.extend_from_slice(&buf[..n]),
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => {
handle.join().expect("server thread");
return parse_status_line(&acc).unwrap_or(0);
}
}
}
}
fn parse_status_line(bytes: &[u8]) -> Option<u16> {
let text = String::from_utf8_lossy(bytes);
let (first, _rest) = text.split_once("\r\n")?;
first.split_whitespace().nth(1)?.parse().ok()
}
let get = "GET /?token={token} HTTP/1.1\r\n\
Host: 127.0.0.1:{port}\r\n\
Connection: close\r\n\r\n"
.replace("{token}", token);
assert_eq!(
serve_one(token, &get),
200,
"an authorized GET with no body must be served 200 promptly"
);
let body = "ignored-body-bytes";
let post = format!(
"POST /?token={token} HTTP/1.1\r\n\
Host: 127.0.0.1:{{port}}\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\r\n\
{body}",
body.len()
);
assert_eq!(
serve_one(token, &post),
200,
"an authorized request with a complete body must still be served 200"
);
}
}