Files
lxc-streamutils/apps/server-rust/src/credentials.rs
T
2026-07-16 00:12:26 -07:00

201 lines
7.4 KiB
Rust

//! CookieCloud boundary and Bilibili cookie extraction.
//!
//! Tenant input reaches an outbound HTTP client only after canonical URL
//! validation and exact allow-list matching in the caller. Redirects are
//! disabled by the shared client, the synchronization key is encoded as one
//! path segment, and only the minimum Bilibili cookie fields leave this module.
use reqwest::{
Url,
header::{REFERER, USER_AGENT},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// The secret portion is encrypted as one JSON document in PostgreSQL. The
/// host remains queryable so status pages can show where a tenant connects
/// without ever returning its key or password.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CookieCloudSecrets {
pub key: String,
pub password: String,
}
#[derive(Clone, Debug)]
pub struct CookieCloudCredentials {
pub host: String,
pub secrets: CookieCloudSecrets,
}
impl CookieCloudCredentials {
pub fn validate(&self) -> Result<(), String> {
normalize_cookiecloud_host(&self.host)?;
if self.secrets.key.trim().is_empty() || self.secrets.password.is_empty() {
return Err("CookieCloud key and password are required".into());
}
if self.secrets.key.len() > 256 || self.secrets.password.len() > 4_096 {
return Err("CookieCloud credentials are too long".into());
}
Ok(())
}
}
/// Canonicalize a deployment-approved CookieCloud base URL. Tenant input is
/// matched against these exact canonical values before any network request.
pub fn normalize_cookiecloud_host(value: &str) -> Result<String, String> {
if value.len() > 2_048 {
return Err("CookieCloud host is too long".into());
}
let mut url = Url::parse(value.trim()).map_err(|_| "CookieCloud host is not a valid URL")?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err("CookieCloud host must be an absolute http:// or https:// URL".into());
}
if !url.username().is_empty() || url.password().is_some() {
return Err("CookieCloud host must not contain URL credentials".into());
}
if url.query().is_some() || url.fragment().is_some() {
return Err("CookieCloud host must not contain a query or fragment".into());
}
let normalized_path = url.path().trim_end_matches('/').to_owned();
url.set_path(&normalized_path);
Ok(url.as_str().trim_end_matches('/').to_owned())
}
pub fn cookiecloud_endpoint(credentials: &CookieCloudCredentials) -> Result<Url, String> {
credentials.validate()?;
let host = normalize_cookiecloud_host(&credentials.host)?;
let mut endpoint = Url::parse(&host).map_err(|_| "CookieCloud host is not a valid URL")?;
endpoint
.path_segments_mut()
.map_err(|_| "CookieCloud host cannot be used as a base URL")?
.pop_if_empty()
.push("get")
.push(credentials.secrets.key.trim());
Ok(endpoint)
}
/// Resolve only Bilibili cookies from a CookieCloud sync bucket. The returned
/// value is kept in the listener task and is never included in status/errors.
pub async fn fetch_bilibili_cookie(
client: &reqwest::Client,
credentials: &CookieCloudCredentials,
) -> Result<String, String> {
let endpoint = cookiecloud_endpoint(credentials)?;
let response = client
.post(endpoint)
.form(&[("password", credentials.secrets.password.as_str())])
.header(REFERER, "https://live.bilibili.com/")
.header(USER_AGENT, "Mozilla/5.0 lxc-streamutils/2.0")
.send()
.await
// reqwest errors can include the full URL, whose path contains the
// CookieCloud key. Keep that bearer-like value out of logs/responses.
.map_err(|_| "CookieCloud network request failed".to_string())?;
if !response.status().is_success() {
return Err(format!("CookieCloud returned HTTP {}", response.status()));
}
let value: Value = response
.json()
.await
.map_err(|_| "CookieCloud response was not valid JSON".to_string())?;
cookie_header(&value)
}
fn cookie_header(value: &Value) -> Result<String, String> {
let mut cookies = Vec::new();
if let Some(domains) = value.get("cookie_data").and_then(Value::as_object) {
for (domain, stored) in domains {
if !domain.contains("bilibili.com") {
continue;
}
let entries: Vec<&Value> = if let Some(array) = stored.as_array() {
array.iter().collect()
} else {
stored
.as_object()
.map(|values| values.values().collect())
.unwrap_or_default()
};
for item in entries {
let Some(name) = item.get("name").and_then(Value::as_str) else {
continue;
};
let Some(value) = item.get("value").and_then(Value::as_str) else {
continue;
};
// Cookie names cannot contain these delimiters. Ignore malformed
// upstream entries instead of allowing header injection.
if name.is_empty()
|| name.contains([';', '=', '\r', '\n'])
|| value.contains([';', '\r', '\n'])
{
continue;
}
cookies.push(format!("{name}={value}"));
}
}
}
if cookies.iter().any(|cookie| cookie.starts_with("SESSDATA=")) {
Ok(cookies.join("; "))
} else {
Err("CookieCloud does not contain a Bilibili SESSDATA cookie".into())
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn extracts_only_bilibili_cookie_data() {
let value = json!({"cookie_data": {
".bilibili.com": [
{"name":"SESSDATA","value":"session"},
{"name":"bili_jct","value":"csrf"}
],
"example.com": [{"name":"secret","value":"must-not-leak"}]
}});
let result = cookie_header(&value).unwrap();
assert!(result.contains("SESSDATA=session"));
assert!(result.contains("bili_jct=csrf"));
assert!(!result.contains("must-not-leak"));
}
#[test]
fn requires_authenticated_cookie() {
let value = json!({"cookie_data": {"bilibili.com": [
{"name":"buvid3","value":"anonymous"}
]}});
assert!(cookie_header(&value).is_err());
}
#[test]
fn cookiecloud_key_is_encoded_as_one_path_segment() {
let credentials = CookieCloudCredentials {
host: "https://cookies.example.test/base/".into(),
secrets: CookieCloudSecrets {
key: "bucket/../../admin?x=1".into(),
password: "secret".into(),
},
};
let endpoint = cookiecloud_endpoint(&credentials).unwrap();
assert_eq!(
endpoint.as_str(),
"https://cookies.example.test/base/get/bucket%2F..%2F..%2Fadmin%3Fx=1"
);
}
#[test]
fn canonical_host_rejects_embedded_credentials_and_queries() {
assert!(normalize_cookiecloud_host("https://user:pass@example.test").is_err());
assert!(normalize_cookiecloud_host("https://example.test/?next=internal").is_err());
assert_eq!(
normalize_cookiecloud_host("https://example.test/base/").unwrap(),
"https://example.test/base"
);
}
}