//! 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 { 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 { 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 { 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 { 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" ); } }