收口宿主壳外链打开协议

共享 HostBridge 契约新增外链 URL 协议白名单校验

Expo 移动壳打开外链前拒绝危险协议

Tauri 桌面壳打开外链前拒绝危险协议

补充共享契约、移动壳和桌面壳外链校验测试

更新宿主壳方案和团队共享决策记录
This commit is contained in:
2026-06-17 22:31:24 +08:00
parent 8b14c6ebe5
commit 61d910400e
7 changed files with 161 additions and 9 deletions

View File

@@ -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();

View File

@@ -1,3 +1,4 @@
import * as Linking from 'expo-linking';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
@@ -83,6 +84,7 @@ function expectFailed(response: HostBridgeResponse) {
}
afterEach(() => {
vi.mocked(Linking.openURL).mockReset();
configureMobileHostBridgeNavigation(null);
});
@@ -148,4 +150,28 @@ describe('handleMobileHostBridgeMessage', () => {
expect(failedResponse.error.code).toBe('unsupported_method');
});
test('app.openExternalUrl 只打开允许的外链协议', async () => {
const response = await send(
request('app.openExternalUrl', {
url: ' https://example.com/path ',
}),
);
expectOk(response);
expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/path');
});
test('app.openExternalUrl 拒绝危险协议', async () => {
const response = await send(
request('app.openExternalUrl', {
url: 'javascript:alert(1)',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('invalid_request');
expect(Linking.openURL).not.toHaveBeenCalled();
});
});

View File

@@ -14,6 +14,7 @@ import {
type HostBridgeRequest,
type HostBridgeResponse,
type NavigateNativePagePayload,
normalizeHostBridgeExternalUrl,
type OpenExternalUrlPayload,
type ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
@@ -106,9 +107,11 @@ function failure(
}
async function openExternalUrl(payload: unknown) {
const url = (payload as OpenExternalUrlPayload | undefined)?.url;
const url = normalizeHostBridgeExternalUrl(
(payload as OpenExternalUrlPayload | undefined)?.url,
);
if (!url) {
throw invalidRequest('url is required');
throw invalidRequest('url must use an allowed external protocol');
}
await Linking.openURL(url);