收口宿主壳外链打开协议
共享 HostBridge 契约新增外链 URL 协议白名单校验 Expo 移动壳打开外链前拒绝危险协议 Tauri 桌面壳打开外链前拒绝危险协议 补充共享契约、移动壳和桌面壳外链校验测试 更新宿主壳方案和团队共享决策记录
This commit is contained in:
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
|
||||
const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
|
||||
const HOST_BRIDGE_VERSION: u8 = 1;
|
||||
const WEB_APP_ORIGIN: &str = "https://app.genarrative.world";
|
||||
const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -129,6 +130,33 @@ fn required_string_payload<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
fn external_url_protocol(raw_url: &str) -> Option<&str> {
|
||||
raw_url.split_once(':').map(|(protocol, _)| protocol)
|
||||
}
|
||||
|
||||
fn normalize_external_url(raw_url: &str) -> Option<String> {
|
||||
let url = raw_url.trim();
|
||||
if url.is_empty() || url.chars().any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let protocol = external_url_protocol(url)?;
|
||||
if protocol.is_empty()
|
||||
|| !protocol.chars().all(|character| {
|
||||
character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let protocol_with_colon = format!("{}:", protocol.to_ascii_lowercase());
|
||||
if !EXTERNAL_URL_PROTOCOLS.contains(&protocol_with_colon.as_str()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
|
||||
value
|
||||
.get(field)
|
||||
@@ -238,9 +266,18 @@ fn host_bridge_request(
|
||||
|
||||
match request.method.as_str() {
|
||||
"app.openExternalUrl" => {
|
||||
let url = match required_string_payload(&request, "url") {
|
||||
Ok(url) => url,
|
||||
Err(response) => return response,
|
||||
let url = match required_string_payload(&request, "url")
|
||||
.ok()
|
||||
.and_then(normalize_external_url)
|
||||
{
|
||||
Some(url) => url,
|
||||
None => {
|
||||
return failed(
|
||||
request.id,
|
||||
"invalid_request",
|
||||
"url must use an allowed external protocol",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
match app.opener().open_url(url, None::<&str>) {
|
||||
@@ -380,6 +417,22 @@ mod tests {
|
||||
assert_eq!(error.message, "text is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_url_normalization_allows_only_safe_protocols() {
|
||||
assert_eq!(
|
||||
normalize_external_url(" https://example.com/path "),
|
||||
Some("https://example.com/path".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_external_url("mailto:hi@example.com"),
|
||||
Some("mailto:hi@example.com".to_string())
|
||||
);
|
||||
assert_eq!(normalize_external_url("javascript:alert(1)"), None);
|
||||
assert_eq!(normalize_external_url("file:///etc/passwd"), None);
|
||||
assert_eq!(normalize_external_url("https://example.com/\nnext"), None);
|
||||
assert_eq!(normalize_external_url("/relative/path"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_text_uses_direct_share_payload() {
|
||||
let state = DesktopShareState::default();
|
||||
|
||||
Reference in New Issue
Block a user