Harden auth/vault path sanitization and clean WeKnora docs

This commit is contained in:
Agent Board
2026-07-28 17:04:27 +08:00
parent 2deaf59f7b
commit 26ff1a9c9a
190 changed files with 13454 additions and 4987 deletions
+203 -2
View File
@@ -166,6 +166,46 @@ pub fn sign_config(config: &Value, secret: &str) -> Result<OnlyOfficeSignTokens,
})
}
/// 校验 OnlyOffice Document Server 回调 JWTHS256)。
/// - `secret` 为空:返回 `Ok(None)`(与 `sign_config` 一致,本地未配置 JWT 时放行)。
/// - `secret` 非空:必须提供有效 token,成功时返回解码后的 payload。
pub fn verify_callback_jwt(
token: Option<&str>,
secret: &str,
) -> Result<Option<Value>, String> {
let normalized_secret = normalize_secret(secret);
if normalized_secret.is_empty() {
return Ok(None);
}
let raw = token
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "OnlyOffice 回调缺少 JWT token".to_string())?;
let payload = verify_hs256(raw, &normalized_secret)?;
Ok(Some(payload))
}
/// 从回调 body / Authorization 提取 JWT 字符串。
/// 优先 body.token;其次 Authorization: Bearer <jwt>。
pub fn extract_callback_jwt(body: &Value, authorization: Option<&str>) -> Option<String> {
if let Some(token) = body.get("token").and_then(Value::as_str) {
let trimmed = token.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let auth = authorization?.trim();
let bearer = auth
.strip_prefix("Bearer ")
.or_else(|| auth.strip_prefix("bearer "))?;
let trimmed = bearer.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn resolve_session(input: OnlyOfficeSessionResolveInput) -> OnlyOfficeSessionResolveResult {
let session_id = input
.session_id
@@ -281,7 +321,7 @@ pub fn prepare_proxy_request(
.host_str()
.ok_or_else(|| "目标 URL 缺少 hostname".to_string())?;
if is_private_ipv4(hostname)
if is_private_or_local_ip_literal(hostname)
&& !is_local_hostname(hostname)
&& !allowed_hostnames.contains(hostname)
{
@@ -490,6 +530,53 @@ fn sign_hs256(payload: &Value, secret: &str) -> Result<String, String> {
Ok(format!("{signing_input}.{signature_part}"))
}
fn verify_hs256(token: &str, secret: &str) -> Result<Value, String> {
let mut parts = token.split('.');
let header_part = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "JWT 格式无效".to_string())?;
let payload_part = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "JWT 格式无效".to_string())?;
let signature_part = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "JWT 格式无效".to_string())?;
if parts.next().is_some() {
return Err("JWT 格式无效".to_string());
}
let header_bytes = URL_SAFE_NO_PAD
.decode(header_part)
.map_err(|_| "JWT header 无法解码".to_string())?;
let header: Value = serde_json::from_slice(&header_bytes)
.map_err(|_| "JWT header 不是合法 JSON".to_string())?;
let alg = header
.get("alg")
.and_then(Value::as_str)
.unwrap_or_default();
if !alg.eq_ignore_ascii_case("HS256") {
return Err(format!("不支持的 JWT alg: {alg}"));
}
let signature = URL_SAFE_NO_PAD
.decode(signature_part)
.map_err(|_| "JWT signature 无法解码".to_string())?;
let signing_input = format!("{header_part}.{payload_part}");
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
mac.update(signing_input.as_bytes());
mac.verify_slice(&signature)
.map_err(|_| "JWT 签名校验失败".to_string())?;
let payload_bytes = URL_SAFE_NO_PAD
.decode(payload_part)
.map_err(|_| "JWT payload 无法解码".to_string())?;
serde_json::from_slice(&payload_bytes).map_err(|_| "JWT payload 不是合法 JSON".to_string())
}
fn decode_base64url_to_utf8(input: &str) -> Result<String, String> {
let bytes = URL_SAFE_NO_PAD
.decode(input.trim())
@@ -498,7 +585,11 @@ fn decode_base64url_to_utf8(input: &str) -> Result<String, String> {
}
fn is_local_hostname(hostname: &str) -> bool {
matches!(hostname, "127.0.0.1" | "localhost" | "host.docker.internal")
let h = hostname.trim().trim_matches(|c| c == '[' || c == ']');
matches!(
h,
"127.0.0.1" | "localhost" | "host.docker.internal" | "::1" | "0:0:0:0:0:0:0:1"
)
}
fn is_private_ipv4(hostname: &str) -> bool {
@@ -522,6 +613,88 @@ fn is_private_ipv4(hostname: &str) -> bool {
|| a == 0
}
/// Block IPv6 loopback / ULA / link-local / IPv4-mapped / IPv4-compatible private literals.
fn is_private_or_local_ip_literal(hostname: &str) -> bool {
let h = hostname.trim().trim_matches(|c| c == '[' || c == ']');
if is_private_ipv4(h) {
return true;
}
let lower = h.to_ascii_lowercase();
if lower == "::1" || lower == "0:0:0:0:0:0:0:1" {
return true;
}
// link-local fe80::/10 and ULA fc00::/7
if lower.starts_with("fe80:")
|| lower.starts_with("fc")
|| lower.starts_with("fd")
|| lower.starts_with("fe8")
|| lower.starts_with("fe9")
|| lower.starts_with("fea")
|| lower.starts_with("feb")
{
return true;
}
// IPv4-mapped ::ffff:a.b.c.d
if let Some(v4) = lower.strip_prefix("::ffff:") {
if is_private_ipv4(v4) || ipv4_compat_hextets_are_private(v4) {
return true;
}
}
// IPv4-compatible / compressed forms embedding private IPv4:
// ::127.0.0.1, ::7f00:1, 0:0:0:0:0:0:7f00:1, ::ffff:7f00:1
if let Some(embedded) = extract_embedded_ipv4_from_v6_literal(&lower) {
if is_private_ipv4(&embedded) {
return true;
}
}
false
}
fn ipv4_compat_hextets_are_private(tail: &str) -> bool {
// tail may already be dotted quad or two hextets like 7f00:1
if is_private_ipv4(tail) {
return true;
}
extract_ipv4_from_two_hextets(tail)
.map(|v4| is_private_ipv4(&v4))
.unwrap_or(false)
}
fn extract_embedded_ipv4_from_v6_literal(lower: &str) -> Option<String> {
if let Some(v4) = lower.strip_prefix("::") {
if is_private_ipv4(v4) {
return Some(v4.to_string());
}
if let Some(dotted) = extract_ipv4_from_two_hextets(v4) {
return Some(dotted);
}
}
// full form ...:0:0:a.b.c.d or ...:7f00:1
let parts: Vec<&str> = lower.split(':').filter(|p| !p.is_empty()).collect();
if parts.len() >= 2 {
let last2 = format!("{}:{}", parts[parts.len() - 2], parts[parts.len() - 1]);
if let Some(dotted) = extract_ipv4_from_two_hextets(&last2) {
return Some(dotted);
}
let last = parts[parts.len() - 1];
if is_private_ipv4(last) {
return Some(last.to_string());
}
}
None
}
fn extract_ipv4_from_two_hextets(tail: &str) -> Option<String> {
let (hi, lo) = tail.split_once(':')?;
let hi = u16::from_str_radix(hi, 16).ok()?;
let lo = u16::from_str_radix(lo, 16).ok()?;
let a = (hi >> 8) as u8;
let b = (hi & 0xff) as u8;
let c = (lo >> 8) as u8;
let d = (lo & 0xff) as u8;
Some(format!("{a}.{b}.{c}.{d}"))
}
fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> {
let value = raw?.trim();
if value.is_empty() {
@@ -579,6 +752,34 @@ mod tests {
assert!(tokens.editor_config_token.is_some());
}
#[test]
fn verify_callback_jwt_roundtrip_and_rejects_tamper() {
let payload = json!({"status": 2, "url": "http://example/file.docx", "key": "k1"});
let token = sign_hs256(&payload, "secret").expect("sign");
let verified = verify_callback_jwt(Some(&token), "secret")
.expect("verify")
.expect("payload present");
assert_eq!(verified.get("status").and_then(Value::as_i64), Some(2));
assert!(verify_callback_jwt(Some(&token), "wrong").is_err());
assert!(verify_callback_jwt(None, "secret").is_err());
assert!(verify_callback_jwt(Some(&token), "").unwrap().is_none());
}
#[test]
fn extract_callback_jwt_prefers_body_then_bearer() {
let body = json!({"token": " body.jwt ", "status": 2});
assert_eq!(
extract_callback_jwt(&body, Some("Bearer header.jwt")),
Some("body.jwt".into())
);
let body_empty = json!({"status": 2});
assert_eq!(
extract_callback_jwt(&body_empty, Some("Bearer header.jwt")),
Some("header.jwt".into())
);
assert_eq!(extract_callback_jwt(&body_empty, None), None);
}
#[test]
fn proxy_request_rewrites_supabase_path_to_internal_origin() {
let encoded_url = URL_SAFE_NO_PAD.encode(