201 lines
7.4 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|