mirror of https://github.com/ospab/ostp.git
Compare commits
No commits in common. "9a891310f9ce17661870201a818003072dede91b" and "cd12b01bc356d7b95ceb09811da875f373a40097" have entirely different histories.
9a891310f9
...
cd12b01bc3
|
|
@ -284,15 +284,7 @@ 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' }}
|
||||||
# cross-rs's own source (not ours, not a dependency of ours) uses a
|
run: cargo install cross --git https://github.com/cross-rs/cross.git --locked
|
||||||
# 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 }}
|
||||||
|
|
|
||||||
|
|
@ -1496,7 +1496,6 @@ dependencies = [
|
||||||
"sha2",
|
"sha2",
|
||||||
"simple-dns",
|
"simple-dns",
|
||||||
"socket2",
|
"socket2",
|
||||||
"subtle",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
|
||||||
|
|
@ -138,34 +138,27 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single select! loop over both directions, rather than spawning a
|
let socket = Arc::new(socket);
|
||||||
// separate task for the read side, so the whole session - physical
|
let socket_rx = socket.clone();
|
||||||
// socket included - is torn down the moment this function returns
|
|
||||||
// (e.g. when session_rx closes). The previous spawned-task version left
|
// Spawn a task to read from physical socket and send back to smoltcp
|
||||||
// that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
|
let tx_clone = smoltcp_tx.clone();
|
||||||
// alive) running forever after this function returned: nothing ever
|
tokio::spawn(async move {
|
||||||
// cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN
|
use futures::SinkExt;
|
||||||
// mode) leaked one socket + one task for the lifetime of the process.
|
let mut buf = [0u8; 65536];
|
||||||
use futures::SinkExt;
|
loop {
|
||||||
let mut buf = [0u8; 65536];
|
match socket_rx.recv_from(&mut buf).await {
|
||||||
loop {
|
Ok((n, peer)) => {
|
||||||
tokio::select! {
|
let mut lock = tx_clone.lock().await;
|
||||||
outbound = session_rx.recv() => {
|
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
|
||||||
match outbound {
|
|
||||||
Some((payload, dst)) => { socket.send_to(&payload, dst).await?; }
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inbound = socket.recv_from(&mut buf) => {
|
|
||||||
match inbound {
|
|
||||||
Ok((n, peer)) => {
|
|
||||||
let mut lock = smoltcp_tx.lock().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(())
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,3 @@ 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"
|
|
||||||
|
|
|
||||||
|
|
@ -318,18 +318,6 @@ 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;
|
||||||
|
|
@ -344,19 +332,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 secure_eq(token, &session) {
|
if 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 secure_eq(token, api_tok) {
|
if 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 secure_eq(val, api_tok) {
|
if val == api_tok {
|
||||||
allowed = true;
|
allowed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -383,7 +371,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 secure_eq(&hash_hex, &state.password_hash) {
|
if 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 }))
|
||||||
|
|
@ -893,91 +881,15 @@ 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(
|
async fn handle_get_audit(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
State(state): State<ApiState>,
|
let logs = state.audit_logs.read().unwrap();
|
||||||
headers: axum::http::HeaderMap,
|
ApiResponse::success(logs.clone())
|
||||||
) -> 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(
|
async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<CreateAuditLogRequest>) -> impl IntoResponse {
|
||||||
State(state): State<ApiState>,
|
let mut logs = state.audit_logs.write().unwrap();
|
||||||
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 {
|
||||||
|
|
@ -992,7 +904,7 @@ async fn handle_create_audit(
|
||||||
logs.truncate(100);
|
logs.truncate(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
(StatusCode::OK, ApiResponse::success(true))
|
ApiResponse::success(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk keys & Router Rules ─────────────────────────────────────────────────
|
// ── Bulk keys & Router Rules ─────────────────────────────────────────────────
|
||||||
|
|
@ -1094,16 +1006,10 @@ async fn handle_put_rules(
|
||||||
(StatusCode::OK, ApiResponse::success(true))
|
(StatusCode::OK, ApiResponse::success(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_clear_audit(
|
async fn handle_clear_audit(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
State(state): State<ApiState>,
|
let mut logs = state.audit_logs.write().unwrap();
|
||||||
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();
|
||||||
(StatusCode::OK, ApiResponse::success(()))
|
ApiResponse::success(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -276,18 +276,6 @@ 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}");
|
||||||
|
|
@ -301,30 +289,10 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -720,11 +720,24 @@ 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);
|
||||||
// Must match api.rs's handle_login exactly (format!("{:x}", Sha256::digest(..))) -
|
let pass_hash = {
|
||||||
// this used to be a DefaultHasher (SipHash) placeholder that produced a
|
use std::fmt::Write as _;
|
||||||
// differently-shaped digest, so a password set up through this wizard could
|
let mut hash = String::new();
|
||||||
// never actually log into the panel it just configured.
|
let digest: [u8; 32] = {
|
||||||
let pass_hash = format!("{:x}", sha2::Sha256::digest(password.as_bytes()));
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue