Compare commits

...

6 Commits

Author SHA1 Message Date
ospab 9a891310f9 fix(cli): setup wizard used a fake password hash, locking admins out of their own panel
The Server+Panel setup wizard's panel-password hashing was a placeholder:
std::collections::hash_map::DefaultHasher (SipHash, not cryptographic, and
not even a 256-bit output - only the first 8 of 32 bytes were real, the
rest zero-padded), left in by the comment "sha2 is not a direct dep of
ostp/Cargo.toml, so we use std's hasher as a placeholder digest here."

api.rs's handle_login computes the REAL SHA256 hex digest of the submitted
password and compares it against config.json's stored password_hash. Since
the wizard's placeholder never produces the same value as real SHA256 of
the same password, anyone who set up a panel through this wizard could
never actually log into it with the password it just showed them - a
complete functional break of the wizard-driven admin flow, not a corner
case.

Added sha2 as a direct ostp dependency and replaced the placeholder with
the exact same format!("{:x}", Sha256::digest(..)) api.rs's login check
uses.
2026-07-18 18:14:19 +03:00
ospab d9686c9344 fix(ci): cap lints when installing cross, so its own code can't fail our build
The mipsel-unknown-linux-musl job in v0.4.2-beta.2 failed at "Install cross":
cross-rs's own source uses a macro-at-end-of-block pattern (eyre::bail!())
that trips rustc's semicolon_in_expressions_from_macros lint on current
toolchains. `cargo install` compiles the installed package as the "local"
crate, so Cargo's usual automatic lint-capping for dependencies doesn't
apply to cross's own code - and other cross-built targets in the same run
(armv7, aarch64-linux, i686-linux) succeeded, so this reads as a race
against cross-rs's unpinned `main` branch history (no --rev/--tag) rather
than a deterministic break.

RUSTFLAGS="--cap-lints=warn" is the standard mechanism for exactly this
situation - building a third-party tool against a newer compiler than its
own lint config assumed - without touching our own build's lint levels.
More robust than pinning to one historical commit, which just relocates
the same risk to whenever that pin is next updated.
2026-07-18 17:59:02 +03:00
ospab dbf923fb16 fix(client): stop leaking a socket+task per bypassed UDP flow
start_udp_bypass_session (the TUN-mode path for UDP from apps/IPs the user
has excluded from the tunnel) spawned a separate task to read from the
physical-interface-bound socket, holding its own Arc<UdpSocket> clone.
Nothing ever cancelled that task when the outer function returned (e.g.
once session_rx closed) - it just kept running, and its socket clone kept
the OS fd alive, for the lifetime of the process. Every distinct bypassed
UDP flow (any excluded app's DNS query, game session, etc.) leaked one
socket and one task permanently.

The sibling function right below it, start_udp_session, already does this
correctly: one tokio::select! loop combining both directions in a single
task that exits (and drops the socket) as soon as either side closes.
Rewrote start_udp_bypass_session to match that pattern instead of
spawning a detached reader task.
2026-07-18 17:46:14 +03:00
ospab 51b947e6ff fix(server): rate-limit the (currently unwired) open UDP DNS listener
DnsServer::run_local_udp_listener binds 0.0.0.0 and answers every UDP
datagram by resolving it and replying to the packet's (unverified,
spoofable) source address - a textbook DNS reflection/amplification
primitive. An attacker spoofing a victim's IP as the query source turns
any server with this listener running into a free amplifier against that
victim, with zero authentication gating it (unlike the main OSTP port,
there's no Noise handshake here).

Nothing in the codebase currently calls this function - the live DNS path
is router.route_dns(), reached only through the authenticated OSTP tunnel
relay (relay.rs). But the doc comment describes this as an intended,
not-yet-wired entry point for clients that point their OS resolver
directly at the server, so it's a real latent risk for whoever connects it
without realizing the implication. Added a global (not per-source-IP -
per-IP limiting doesn't help against a reflection attack, since the
attacker never sees the replies and can spread queries across arbitrary
spoofed sources) token bucket capping total replies/sec, so connecting
this later can't silently reintroduce unbounded amplification.
2026-07-18 17:42:49 +03:00
ospab f01ed4ec25 fix(server): constant-time comparison for Management API secrets
check_token() and handle_login() compared bearer tokens, session tokens,
and the password hash with plain ==, which short-circuits on the first
differing byte - a textbook remote timing side-channel against exactly
the long-lived secrets these gates exist to protect. Added subtle (already
in the dependency tree transitively via chacha20poly1305) as a direct
dependency and route every secret comparison through a small secure_eq()
wrapper over ConstantTimeEq. Username comparison in handle_login is left
as-is: it isn't treated as a secret in this threat model (one fixed admin
username), matching standard practice of only constant-timing the
password/token side of an auth check.

Added tests for secure_eq() itself (equal, different, different-length,
empty) alongside the existing check_token coverage.
2026-07-18 17:38:09 +03:00
ospab c2a1a53b4d fix(server): audit-log API endpoints had no auth check at all
GET/POST/DELETE /api/audit were the only three handlers in the whole
Management API that never called check_token() - every other endpoint
(status, users, rules, config) does. Concretely, with the panel's
credentials configured, an unauthenticated request could still:
  - read the full audit log (GET)
  - inject arbitrary forged entries, e.g. fake "success" events to cover
    tracks (POST)
  - wipe the entire audit log (DELETE) - the exact mechanism meant to
    detect and investigate unauthorized actions, erasable with zero auth

Added the same check_token() gate the rest of the file uses, and fixed
these three handlers' raw .unwrap() on the audit_logs lock to the
poison-recovery pattern (unwrap_or_else(|e| e.into_inner())) used
everywhere else, for consistency.

Added focused unit tests on check_token() itself (missing header, correct/
wrong bearer, raw token, session token, and the documented open-panel
mode when no credentials are configured) - it's the single gate every
sensitive handler depends on, worth pinning down independently of any one
handler.
2026-07-18 17:27:58 +03:00
8 changed files with 182 additions and 51 deletions

View File

@ -284,7 +284,15 @@ jobs:
- name: Install cross (if not cached) - name: Install cross (if not cached)
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }} if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
run: cargo install cross --git https://github.com/cross-rs/cross.git --locked # cross-rs's own source (not ours, not a dependency of ours) uses a
# macro-at-end-of-block pattern that trips rustc's
# semicolon_in_expressions_from_macros lint on current toolchains -
# harmless in cross's actual behavior, but `cargo install` compiles
# the installed package as the "local" crate, so dependency lint
# capping doesn't shield it. --cap-lints=warn is the standard escape
# hatch for building a third-party tool against a newer compiler than
# its own lint config assumed; it doesn't touch our own build.
run: RUSTFLAGS="--cap-lints=warn" cargo install cross --git https://github.com/cross-rs/cross.git --locked
- name: Build (cross) - name: Build (cross)
if: ${{ matrix.use_cross }} if: ${{ matrix.use_cross }}

1
Cargo.lock generated
View File

@ -1496,6 +1496,7 @@ dependencies = [
"sha2", "sha2",
"simple-dns", "simple-dns",
"socket2", "socket2",
"subtle",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",

View File

@ -138,27 +138,34 @@ async fn start_udp_bypass_session(
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name); let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
} }
let socket = Arc::new(socket); // A single select! loop over both directions, rather than spawning a
let socket_rx = socket.clone(); // separate task for the read side, so the whole session - physical
// socket included - is torn down the moment this function returns
// Spawn a task to read from physical socket and send back to smoltcp // (e.g. when session_rx closes). The previous spawned-task version left
let tx_clone = smoltcp_tx.clone(); // that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
tokio::spawn(async move { // alive) running forever after this function returned: nothing ever
// cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN
// mode) leaked one socket + one task for the lifetime of the process.
use futures::SinkExt; use futures::SinkExt;
let mut buf = [0u8; 65536]; let mut buf = [0u8; 65536];
loop { loop {
match socket_rx.recv_from(&mut buf).await { tokio::select! {
outbound = session_rx.recv() => {
match outbound {
Some((payload, dst)) => { socket.send_to(&payload, dst).await?; }
None => break,
}
}
inbound = socket.recv_from(&mut buf) => {
match inbound {
Ok((n, peer)) => { Ok((n, peer)) => {
let mut lock = tx_clone.lock().await; let mut lock = smoltcp_tx.lock().await;
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await; let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
} }
Err(_) => break, Err(_) => break,
} }
} }
}); }
while let Some((payload, dst)) = session_rx.recv().await {
socket.send_to(&payload, dst).await?;
} }
Ok(()) Ok(())

View File

@ -31,3 +31,4 @@ hex = "0.4.3"
chacha20poly1305.workspace = true chacha20poly1305.workspace = true
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
chrono = "0.4.44" chrono = "0.4.44"
subtle = "2.6"

View File

@ -318,6 +318,18 @@ pub async fn start_api_server(
// ── Middleware: token check ────────────────────────────────────────────────── // ── Middleware: token check ──────────────────────────────────────────────────
/// Constant-time string equality for secrets (tokens, password hashes).
/// Plain `==` short-circuits on the first differing byte, which leaks how
/// many leading bytes an attacker's guess got right through response
/// timing - a classic remote timing side-channel against exactly the kind
/// of long-lived bearer/session secrets compared here. `subtle` is already
/// pulled in transitively (chacha20poly1305 etc.); pinning it as a direct
/// dependency here makes that guarantee explicit for this call site.
fn secure_eq(a: &str, b: &str) -> bool {
use subtle::ConstantTimeEq;
a.as_bytes().ct_eq(b.as_bytes()).into()
}
fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool { fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
// Both session token (for web UI) and static API token (for relays) are checked // Both session token (for web UI) and static API token (for relays) are checked
let mut allowed = false; let mut allowed = false;
@ -332,19 +344,19 @@ fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
if let Some(token) = val.strip_prefix("Bearer ") { if let Some(token) = val.strip_prefix("Bearer ") {
let current_session = state.session_token.read().unwrap_or_else(|e| e.into_inner()).clone(); let current_session = state.session_token.read().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(session) = current_session { if let Some(session) = current_session {
if token == session { if secure_eq(token, &session) {
allowed = true; allowed = true;
} }
} }
if let Some(ref api_tok) = state.api_token { if let Some(ref api_tok) = state.api_token {
if token == api_tok { if secure_eq(token, api_tok) {
allowed = true; allowed = true;
} }
} }
} else { } else {
if let Some(ref api_tok) = state.api_token { if let Some(ref api_tok) = state.api_token {
if val == api_tok { if secure_eq(val, api_tok) {
allowed = true; allowed = true;
} }
} }
@ -371,7 +383,7 @@ async fn handle_login(
let hash = sha2::Sha256::digest(password.as_bytes()); let hash = sha2::Sha256::digest(password.as_bytes());
let hash_hex = format!("{:x}", hash); let hash_hex = format!("{:x}", hash);
if hash_hex == state.password_hash { if secure_eq(&hash_hex, &state.password_hash) {
let token = uuid::Uuid::new_v4().to_string(); let token = uuid::Uuid::new_v4().to_string();
*state.session_token.write().unwrap_or_else(|e| e.into_inner()) = Some(token.clone()); *state.session_token.write().unwrap_or_else(|e| e.into_inner()) = Some(token.clone());
(StatusCode::OK, ApiResponse::success(LoginResponse { token })) (StatusCode::OK, ApiResponse::success(LoginResponse { token }))
@ -881,15 +893,91 @@ mod tests {
let state = make_test_state(""); let state = make_test_state("");
let _router = create_api_router(state); let _router = create_api_router(state);
} }
#[test]
fn test_secure_eq_matches_and_rejects() {
assert!(secure_eq("same-secret", "same-secret"));
assert!(!secure_eq("same-secret", "different"));
assert!(!secure_eq("short", "much-longer-value"));
assert!(secure_eq("", ""));
}
fn headers_with_bearer(token: &str) -> axum::http::HeaderMap {
let mut h = axum::http::HeaderMap::new();
h.insert("authorization", format!("Bearer {token}").parse().unwrap());
h
}
// These pin down check_token's behavior directly: it's the single gate
// every mutating/sensitive handler (including the audit-log ones - see
// the missing-auth fix) relies on, so its logic must be independently
// verified rather than only exercised incidentally through handlers.
#[test]
fn test_check_token_rejects_missing_header_when_configured() {
let state = make_test_state("panel");
assert!(!check_token(&state, &axum::http::HeaderMap::new()));
}
#[test]
fn test_check_token_accepts_matching_api_token_as_bearer() {
let state = make_test_state("panel");
assert!(check_token(&state, &headers_with_bearer("test-token")));
}
#[test]
fn test_check_token_accepts_matching_api_token_raw() {
let state = make_test_state("panel");
let mut h = axum::http::HeaderMap::new();
h.insert("authorization", "test-token".parse().unwrap());
assert!(check_token(&state, &h));
}
#[test]
fn test_check_token_rejects_wrong_token() {
let state = make_test_state("panel");
assert!(!check_token(&state, &headers_with_bearer("wrong-token")));
}
#[test]
fn test_check_token_accepts_matching_session_token() {
let state = make_test_state("panel");
*state.session_token.write().unwrap() = Some("live-session".to_string());
assert!(check_token(&state, &headers_with_bearer("live-session")));
}
#[test]
fn test_check_token_open_when_no_credentials_configured() {
let mut state = make_test_state("panel");
state.api_token = None;
state.username.clear();
state.password_hash.clear();
// Documented "unsafe but possible" open-panel mode: no credentials
// configured at all means every request passes, including with no
// Authorization header.
assert!(check_token(&state, &axum::http::HeaderMap::new()));
}
} }
async fn handle_get_audit(State(state): State<ApiState>) -> impl IntoResponse { async fn handle_get_audit(
let logs = state.audit_logs.read().unwrap(); State(state): State<ApiState>,
ApiResponse::success(logs.clone()) headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<Vec<AuditLogEntry>>();
}
let logs = state.audit_logs.read().unwrap_or_else(|e| e.into_inner());
(StatusCode::OK, ApiResponse::success(logs.clone()))
} }
async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<CreateAuditLogRequest>) -> impl IntoResponse { async fn handle_create_audit(
let mut logs = state.audit_logs.write().unwrap(); State(state): State<ApiState>,
headers: axum::http::HeaderMap,
Json(req): Json<CreateAuditLogRequest>,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<bool>();
}
let mut logs = state.audit_logs.write().unwrap_or_else(|e| e.into_inner());
let id = format!("{:x}", rand::random::<u64>()); let id = format!("{:x}", rand::random::<u64>());
let now = chrono::Local::now(); let now = chrono::Local::now();
let entry = AuditLogEntry { let entry = AuditLogEntry {
@ -904,7 +992,7 @@ async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<Crea
logs.truncate(100); logs.truncate(100);
} }
ApiResponse::success(true) (StatusCode::OK, ApiResponse::success(true))
} }
// ── Bulk keys & Router Rules ───────────────────────────────────────────────── // ── Bulk keys & Router Rules ─────────────────────────────────────────────────
@ -1006,10 +1094,16 @@ async fn handle_put_rules(
(StatusCode::OK, ApiResponse::success(true)) (StatusCode::OK, ApiResponse::success(true))
} }
async fn handle_clear_audit(State(state): State<ApiState>) -> impl IntoResponse { async fn handle_clear_audit(
let mut logs = state.audit_logs.write().unwrap(); State(state): State<ApiState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<()>();
}
let mut logs = state.audit_logs.write().unwrap_or_else(|e| e.into_inner());
logs.clear(); logs.clear();
ApiResponse::success(()) (StatusCode::OK, ApiResponse::success(()))
} }

View File

@ -276,6 +276,18 @@ impl DnsServer {
/// ///
/// Клиент может явно указать `<server_ip>:<local_port>` как DNS-сервер /// Клиент может явно указать `<server_ip>:<local_port>` как DNS-сервер
/// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь. /// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь.
///
/// SECURITY: this socket is bound on 0.0.0.0, reachable directly from the
/// public internet with no authentication (unlike the main OSTP port,
/// there is no Noise handshake gating it). Answering every UDP datagram
/// by resolving and replying to its (unverified, spoofable) source
/// address is a textbook DNS reflection/amplification primitive: an
/// attacker spoofing a victim's IP as the query source turns this server
/// into a free amplifier against that victim. There is currently no
/// caller for this function anywhere in the codebase, but the rate
/// limiter below exists so that connecting it later doesn't silently
/// reintroduce that risk - it bounds how much amplification bandwidth
/// this listener can ever contribute, regardless of query volume.
pub async fn run_local_udp_listener(self: Arc<Self>) { pub async fn run_local_udp_listener(self: Arc<Self>) {
let port = self.config.read().await.local_port; let port = self.config.read().await.local_port;
let bind_addr = format!("0.0.0.0:{port}"); let bind_addr = format!("0.0.0.0:{port}");
@ -289,10 +301,30 @@ impl DnsServer {
}; };
tracing::info!("Built-in DNS server listening on UDP {bind_addr}"); tracing::info!("Built-in DNS server listening on UDP {bind_addr}");
// Global token bucket capping total replies/sec this listener will
// ever send. Deliberately global (not per-source-IP): per-IP limiting
// does nothing against a reflection attack, since the attacker never
// sees the responses and can spread queries across arbitrarily many
// spoofed sources anyway. A global cap bounds this server's total
// contribution to any attack regardless of how the queries are
// distributed.
const MAX_REPLIES_PER_SEC: f64 = 100.0;
let mut tokens: f64 = MAX_REPLIES_PER_SEC;
let mut last_refill = tokio::time::Instant::now();
let mut buf = vec![0u8; 4096]; let mut buf = vec![0u8; 4096];
loop { loop {
match socket.recv_from(&mut buf).await { match socket.recv_from(&mut buf).await {
Ok((n, peer)) => { Ok((n, peer)) => {
let now = tokio::time::Instant::now();
tokens = (tokens + now.duration_since(last_refill).as_secs_f64() * MAX_REPLIES_PER_SEC)
.min(MAX_REPLIES_PER_SEC);
last_refill = now;
if tokens < 1.0 {
continue; // over budget: drop silently, no reply sent
}
tokens -= 1.0;
let query = buf[..n].to_vec(); let query = buf[..n].to_vec();
let srv = self.clone(); let srv = self.clone();
let sock = socket.clone(); let sock = socket.clone();

View File

@ -21,3 +21,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ostp-core = { path = "../ostp-core" } ostp-core = { path = "../ostp-core" }
colored = "2.1" colored = "2.1"
rlimit = "0.11.0" rlimit = "0.11.0"
sha2.workspace = true

View File

@ -720,24 +720,11 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
}) as char }) as char
}).collect(); }).collect();
let password = wizard_prompt("Admin password (blank for random)", &rand_pass); let password = wizard_prompt("Admin password (blank for random)", &rand_pass);
let pass_hash = { // Must match api.rs's handle_login exactly (format!("{:x}", Sha256::digest(..))) -
use std::fmt::Write as _; // this used to be a DefaultHasher (SipHash) placeholder that produced a
let mut hash = String::new(); // differently-shaped digest, so a password set up through this wizard could
let digest: [u8; 32] = { // never actually log into the panel it just configured.
use std::collections::hash_map::DefaultHasher; let pass_hash = format!("{:x}", sha2::Sha256::digest(password.as_bytes()));
use std::hash::{Hash, Hasher};
// Panel password hashing. sha2 is not a direct dep of ostp/Cargo.toml,
// so we use std's hasher as a placeholder digest here.
let mut h = DefaultHasher::new();
password.hash(&mut h);
let v = h.finish();
let mut out = [0u8; 32];
out[..8].copy_from_slice(&v.to_be_bytes());
out
};
for b in digest { let _ = write!(hash, "{:02x}", b); }
hash
};
wizard_step(4, TOTAL, "Saving configuration"); wizard_step(4, TOTAL, "Saving configuration");
let panel_bind = format!("0.0.0.0:{}", panel_port); let panel_bind = format!("0.0.0.0:{}", panel_port);