Require strict transport for Easy Access API calls, validate grant field lengths, and verify domain-separated server and manager signatures.

Signed-off-by: 21pages <sunboeasy@gmail.com>
This commit is contained in:
21pages 2026-05-09 12:50:12 +08:00
parent 04554d9129
commit 3d0305a797
5 changed files with 149 additions and 31 deletions

@ -1 +1 @@
Subproject commit c8cbb6be283e9215da87625016fe8838dda76c02
Subproject commit 8db11c314baa7b07c8ae529f4ce7b6f0180616a3

View file

@ -98,6 +98,7 @@ pub const MILLI1: Duration = Duration::from_millis(1);
pub const SEC30: Duration = Duration::from_secs(30);
pub const VIDEO_QUEUE_SIZE: usize = 120;
const MAX_DECODE_FAIL_COUNTER: usize = 3;
const EASY_ACCESS_GRANT_ID_LEN: usize = 32;
#[cfg(target_os = "linux")]
pub const LOGIN_MSG_DESKTOP_NOT_INITED: &str = "Desktop env is not inited";
@ -2840,8 +2841,14 @@ impl LoginConfigHandler {
}
};
let grant_id = match crate::decode64(&response.grant_id) {
Ok(grant_id) if !grant_id.is_empty() => grant_id,
Ok(_) => return None,
Ok(grant_id) if grant_id.len() == EASY_ACCESS_GRANT_ID_LEN => grant_id,
Ok(grant_id) => {
log::warn!(
"Easy access grant id has invalid length: {}",
grant_id.len()
);
return None;
}
Err(err) => {
log::warn!("Easy access grant id invalid: {}", err);
return None;

View file

@ -1124,6 +1124,29 @@ pub fn get_audit_server(api: String, custom: String, typ: String) -> String {
format!("{}/api/audit/{}", url, typ)
}
fn url_requires_strict_transport(url: &reqwest::Url) -> bool {
url.path().starts_with("/api/easy_access/")
}
fn validate_strict_transport_url(url: &str) -> ResultType<()> {
let Ok(parsed) = reqwest::Url::parse(url) else {
return Ok(());
};
if !url_requires_strict_transport(&parsed) {
return Ok(());
}
if !cfg!(debug_assertions) && parsed.scheme() != "https" {
bail!("strict HTTPS URL must use https://");
}
Ok(())
}
fn url_allows_danger_accept_invalid_cert(url: &str) -> bool {
reqwest::Url::parse(url)
.map(|url| !url_requires_strict_transport(&url))
.unwrap_or(true)
}
/// Check if we should use raw TCP proxy for API calls.
/// Returns true if USE_RAW_TCP_FOR_API builtin option is "Y", WebSocket is off,
/// and the target URL belongs to the configured non-public API host.
@ -1146,9 +1169,16 @@ fn should_use_tcp_proxy_for_api_url(url: &str, api_url: &str) -> bool {
return false;
}
let target_host = url::Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase()));
let Ok(target_url) = url::Url::parse(url) else {
return false;
};
if url_requires_strict_transport(&target_url) {
return false;
}
let target_host = target_url
.host_str()
.map(|host| host.to_ascii_lowercase());
let api_host = url::Url::parse(api_url)
.ok()
.and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase()));
@ -1328,10 +1358,16 @@ fn parse_json_header_entries(header: &str) -> ResultType<Vec<HeaderEntry>> {
/// Returns (status_code, body_text). Separating status so the wrapper can decide on fallback.
async fn post_request_http(url: &str, body: &str, header: &str) -> ResultType<(u16, String)> {
validate_strict_transport_url(&url)?;
let proxy_conf = Config::get_socks();
let tls_url = get_url_for_tls(url, &proxy_conf);
let tls_type = get_cached_tls_type(tls_url);
let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url);
let allow_danger_accept_invalid_cert = url_allows_danger_accept_invalid_cert(&url);
let danger_accept_invalid_cert = if allow_danger_accept_invalid_cert {
get_cached_tls_accept_invalid_cert(tls_url)
} else {
Some(false)
};
let response = post_request_(
url,
tls_url,
@ -1340,6 +1376,7 @@ async fn post_request_http(url: &str, body: &str, header: &str) -> ResultType<(u
tls_type,
danger_accept_invalid_cert,
danger_accept_invalid_cert,
allow_danger_accept_invalid_cert,
)
.await?;
let status = response.status().as_u16();
@ -1415,6 +1452,7 @@ async fn post_request_(
tls_type: Option<TlsType>,
danger_accept_invalid_cert: Option<bool>,
original_danger_accept_invalid_cert: Option<bool>,
allow_danger_accept_invalid_cert: bool,
) -> ResultType<reqwest::Response> {
let mut req = create_http_client_async(
tls_type.unwrap_or(TlsType::Rustls),
@ -1454,7 +1492,10 @@ async fn post_request_(
Ok(resp)
}
Err(e) => {
if (tls_type.is_none() || danger_accept_invalid_cert.is_none()) && e.is_request() {
if allow_danger_accept_invalid_cert
&& (tls_type.is_none() || danger_accept_invalid_cert.is_none())
&& e.is_request()
{
if danger_accept_invalid_cert.is_none() {
log::warn!(
"HTTP request failed: {:?}, try again, danger accept invalid cert",
@ -1468,6 +1509,7 @@ async fn post_request_(
tls_type,
Some(true),
original_danger_accept_invalid_cert,
allow_danger_accept_invalid_cert,
)
.await
} else {
@ -1480,6 +1522,7 @@ async fn post_request_(
Some(TlsType::NativeTls),
original_danger_accept_invalid_cert,
original_danger_accept_invalid_cert,
allow_danger_accept_invalid_cert,
)
.await
}
@ -1506,6 +1549,7 @@ async fn get_http_response_async(
tls_type: Option<TlsType>,
danger_accept_invalid_cert: Option<bool>,
original_danger_accept_invalid_cert: Option<bool>,
allow_danger_accept_invalid_cert: bool,
) -> ResultType<reqwest::Response> {
let http_client = create_http_client_async(
tls_type.unwrap_or(TlsType::Rustls),
@ -1561,7 +1605,10 @@ async fn get_http_response_async(
Ok(resp)
}
Err(e) => {
if (tls_type.is_none() || danger_accept_invalid_cert.is_none()) && e.is_request() {
if allow_danger_accept_invalid_cert
&& (tls_type.is_none() || danger_accept_invalid_cert.is_none())
&& e.is_request()
{
if danger_accept_invalid_cert.is_none() {
log::warn!(
"HTTP request failed: {:?}, try again, danger accept invalid cert",
@ -1576,6 +1623,7 @@ async fn get_http_response_async(
tls_type,
Some(true),
original_danger_accept_invalid_cert,
allow_danger_accept_invalid_cert,
)
.await
} else {
@ -1589,6 +1637,7 @@ async fn get_http_response_async(
Some(TlsType::NativeTls),
original_danger_accept_invalid_cert,
original_danger_accept_invalid_cert,
allow_danger_accept_invalid_cert,
)
.await
}
@ -1608,10 +1657,16 @@ async fn http_request_http(
body: Option<String>,
header: &str,
) -> ResultType<(u16, String)> {
validate_strict_transport_url(&url)?;
let proxy_conf = Config::get_socks();
let tls_url = get_url_for_tls(url, &proxy_conf);
let tls_type = get_cached_tls_type(tls_url);
let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url);
let allow_danger_accept_invalid_cert = url_allows_danger_accept_invalid_cert(&url);
let danger_accept_invalid_cert = if allow_danger_accept_invalid_cert {
get_cached_tls_accept_invalid_cert(tls_url)
} else {
Some(false)
};
let response = get_http_response_async(
url,
tls_url,
@ -1621,6 +1676,7 @@ async fn http_request_http(
tls_type,
danger_accept_invalid_cert,
danger_accept_invalid_cert,
allow_danger_accept_invalid_cert,
)
.await?;
// Serialize response headers
@ -2812,6 +2868,14 @@ mod tests {
"not a url",
"https://admin.example.com"
));
assert!(!should_use_tcp_proxy_for_api_url(
"https://admin.example.com/api/easy_access/grant",
"https://admin.example.com"
));
assert!(!should_use_tcp_proxy_for_api_url(
"http://admin.example.com/api/easy_access/grant",
"http://admin.example.com"
));
}
#[test]

View file

@ -1345,6 +1345,9 @@ pub fn main_get_uuid() -> String {
}
pub fn main_get_easy_access_device_auth() -> String {
const EASY_ACCESS_MANAGERS_DEVICE_AUTH_DOMAIN: &str = "easy_access.managers";
const EASY_ACCESS_MANAGERS_DEVICE_AUTH_VERSION: u32 = 1;
let id = get_id();
let uuid = hbb_common::get_uuid();
if id.is_empty() || uuid.is_empty() {
@ -1368,14 +1371,20 @@ pub fn main_get_easy_access_device_auth() -> String {
let Ok(device_box_sk) = ed25519::to_curve25519_sk(&device_sign_sk) else {
return String::new();
};
let plaintext = serde_json::json!({
"domain": EASY_ACCESS_MANAGERS_DEVICE_AUTH_DOMAIN,
"v": EASY_ACCESS_MANAGERS_DEVICE_AUTH_VERSION,
"uuid": BASE64_STANDARD.encode(&uuid),
})
.to_string();
let nonce = box_::gen_nonce();
let ciphertext = box_::seal(&uuid, &nonce, &server_box_pk, &device_box_sk);
let ciphertext = box_::seal(plaintext.as_bytes(), &nonce, &server_box_pk, &device_box_sk);
let mut proof = Vec::with_capacity(box_::NONCEBYTES + ciphertext.len());
proof.extend_from_slice(nonce.as_ref());
proof.extend_from_slice(&ciphertext);
serde_json::json!({
"id": id,
"ciphertext": BASE64_STANDARD.encode(proof),
"proof": BASE64_STANDARD.encode(proof),
})
.to_string()
}

View file

@ -361,6 +361,7 @@ pub struct Connection {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
terminal_user_token: Option<TerminalUserToken>,
terminal_generic_service: Option<Box<GenericService>>,
easy_access_verified: bool,
}
impl ConnInner {
@ -405,6 +406,8 @@ const SEND_TIMEOUT_VIDEO: u64 = 12_000;
const SEND_TIMEOUT_OTHER: u64 = SEND_TIMEOUT_VIDEO * 10;
const SESSION_TIMEOUT: Duration = Duration::from_secs(30);
const EASY_ACCESS_CHALLENGE_LEN: usize = 32;
const EASY_ACCESS_MANAGER_ID_LEN: usize = 16;
const EASY_ACCESS_GRANT_ID_LEN: usize = 32;
impl Connection {
pub async fn start(
@ -548,6 +551,7 @@ impl Connection {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
terminal_user_token: None,
terminal_generic_service: None,
easy_access_verified: false,
};
let addr = hbb_common::try_into_v4(addr);
if !conn.on_open(addr).await {
@ -2294,7 +2298,14 @@ impl Connection {
Self::is_permission_enabled_locally(enable_prefix_option)
}
async fn verify_easy_access(&self) -> bool {
async fn verify_easy_access(&mut self) -> bool {
// If already verified in this connection, return cached result.
// This prevents re-consuming the one-time grant when client retries
// LoginRequest (e.g., desktop not ready on first attempt).
if self.easy_access_verified {
return true;
}
const EASY_ACCESS_GRANT_VERSION: u32 = 1;
fn open_easy_access_device_bound_proof(
@ -2303,8 +2314,7 @@ impl Connection {
server_pk: &sign::PublicKey,
target_sk: &[u8],
) -> Option<Vec<u8>> {
const EASY_ACCESS_DEVICE_PROOF_NONCE_DOMAIN: &[u8] =
b"easy-access-device-proof-nonce/v1";
const EASY_ACCESS_DEVICE_PROOF_NONCE_DOMAIN: &[u8] = b"EAD1";
fn derive_easy_access_device_bound_nonce(
server_approval_signature: &[u8],
@ -2328,7 +2338,7 @@ impl Connection {
device_nonce: &[u8],
approved: bool,
) -> Vec<u8> {
const EASY_ACCESS_CONSUME_DECISION_DOMAIN: &[u8] = b"easy-access-consume-decision/v1";
const EASY_ACCESS_CONSUME_DECISION_DOMAIN: &[u8] = b"EAC1";
let mut bytes = Vec::new();
bytes.extend_from_slice(EASY_ACCESS_CONSUME_DECISION_DOMAIN);
@ -2340,6 +2350,32 @@ impl Connection {
bytes
}
fn serialize_easy_access_manager_approval_signature_payload(
target_binding_bytes: &[u8],
) -> Vec<u8> {
const EASY_ACCESS_MANAGER_APPROVAL_SIGNATURE_DOMAIN: &[u8] = b"EAM1";
let mut bytes = Vec::with_capacity(
EASY_ACCESS_MANAGER_APPROVAL_SIGNATURE_DOMAIN.len() + target_binding_bytes.len(),
);
bytes.extend_from_slice(EASY_ACCESS_MANAGER_APPROVAL_SIGNATURE_DOMAIN);
bytes.extend_from_slice(target_binding_bytes);
bytes
}
fn serialize_easy_access_server_approval_signature_payload(
manager_approval_bytes: &[u8],
) -> Vec<u8> {
const EASY_ACCESS_SERVER_APPROVAL_SIGNATURE_DOMAIN: &[u8] = b"EAS1";
let mut bytes = Vec::with_capacity(
EASY_ACCESS_SERVER_APPROVAL_SIGNATURE_DOMAIN.len() + manager_approval_bytes.len(),
);
bytes.extend_from_slice(EASY_ACCESS_SERVER_APPROVAL_SIGNATURE_DOMAIN);
bytes.extend_from_slice(manager_approval_bytes);
bytes
}
#[derive(Serialize)]
struct EasyAccessGrantConsumeDeviceAuthPayload {
uuid: String,
@ -2350,7 +2386,7 @@ impl Connection {
#[derive(Serialize)]
struct EasyAccessGrantConsumeRequest {
id: String,
ciphertext: String,
proof: String,
}
#[derive(Deserialize)]
@ -2407,7 +2443,7 @@ impl Connection {
proof.extend_from_slice(&ciphertext);
let body = match serde_json::to_string(&EasyAccessGrantConsumeRequest {
id: Config::get_id(),
ciphertext: crate::encode64(proof),
proof: crate::encode64(proof),
}) {
Ok(body) => body,
Err(err) => {
@ -2503,12 +2539,12 @@ impl Connection {
}
let grant_id = self.lr.easy_access_grant_id.clone();
if grant_id.is_empty() {
if grant_id.len() != EASY_ACCESS_GRANT_ID_LEN {
return false;
};
let target_challenge = self.hash.easy_access_challenge.clone();
if target_challenge.is_empty() {
log::warn!("Easy access target challenge missing");
if target_challenge.len() != EASY_ACCESS_CHALLENGE_LEN {
log::warn!("Easy access target challenge invalid");
return false;
}
let target_uuid = hbb_common::get_uuid();
@ -2517,12 +2553,12 @@ impl Connection {
return false;
}
let (target_sk, target_pk) = Config::get_key_pair();
if target_sk.is_empty() {
log::warn!("Easy access target private key missing");
if target_sk.len() != sign::SECRETKEYBYTES {
log::warn!("Easy access target private key invalid");
return false;
}
if target_pk.is_empty() {
log::warn!("Easy access target public key missing");
if target_pk.len() != sign::PUBLICKEYBYTES {
log::warn!("Easy access target public key invalid");
return false;
}
let server_key = crate::common::get_key(true).await;
@ -2537,6 +2573,7 @@ impl Connection {
consume_easy_access_grant(&grant_id, target_uuid.as_slice(), &target_sk, &server_pk)
.await
else {
log::warn!("Easy access consume_easy_access_grant returned None");
return false;
};
if ticket.version != EASY_ACCESS_GRANT_VERSION {
@ -2573,7 +2610,7 @@ impl Connection {
};
if !sign::verify_detached(
&server_approval_signature,
&manager_approval_bytes,
&serialize_easy_access_server_approval_signature_payload(&manager_approval_bytes),
&server_pk,
) {
log::warn!("Easy access server approval signature verify failed");
@ -2599,8 +2636,8 @@ impl Connection {
log::warn!("Easy access manager approval signature missing");
return false;
}
if manager_approval.manager_id.is_empty() {
log::warn!("Easy access manager id missing");
if manager_approval.manager_id.len() != EASY_ACCESS_MANAGER_ID_LEN {
log::warn!("Easy access manager id invalid");
return false;
}
if target_binding.challenge.as_ref() != target_challenge.as_ref() {
@ -2615,8 +2652,8 @@ impl Connection {
log::warn!("Easy access target public key mismatch");
return false;
}
if target_binding.grant_id.is_empty() {
log::warn!("Easy access grant id missing");
if target_binding.grant_id.len() != EASY_ACCESS_GRANT_ID_LEN {
log::warn!("Easy access grant id invalid");
return false;
}
let manager_pk = match sign::PublicKey::from_slice(&manager_approval.manager_pk) {
@ -2643,7 +2680,7 @@ impl Connection {
};
if !sign::verify_detached(
&manager_approval_signature,
&target_binding_bytes,
&serialize_easy_access_manager_approval_signature_payload(&target_binding_bytes),
&manager_pk,
) {
log::warn!("Easy access manager approval signature verify failed");
@ -2654,6 +2691,7 @@ impl Connection {
return false;
}
log::info!("Easy access grant verified");
self.easy_access_verified = true;
true
}