Harden auth/vault path sanitization and clean WeKnora docs
This commit is contained in:
@@ -166,6 +166,46 @@ pub fn sign_config(config: &Value, secret: &str) -> Result<OnlyOfficeSignTokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// 校验 OnlyOffice Document Server 回调 JWT(HS256)。
|
||||
/// - `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(
|
||||
|
||||
Reference in New Issue
Block a user