收紧桌面壳分享地址边界

桌面壳分享文本只接受公开主站同源地址

拒绝外域和危险分享地址且不回退缓存目标

补充桌面壳分享地址回归测试和配置门禁

更新 HostBridge 分享边界文档和共享决策
This commit is contained in:
2026-06-19 03:12:27 +08:00
parent 9fcba9f4a5
commit 5ab7b53bf2
5 changed files with 164 additions and 23 deletions
@@ -53,6 +53,14 @@ const desktopHostBridgeDispatchSource = fs.readFileSync(
desktopHostBridgeDispatchPath,
'utf8',
);
const desktopHostBridgeSharePath = new URL(
'../src-tauri/src/host_bridge/share.rs',
import.meta.url,
);
const desktopHostBridgeShareSource = fs.readFileSync(
desktopHostBridgeSharePath,
'utf8',
);
const desktopShellUrlPath = new URL(
'../src-tauri/src/shell/url.rs',
import.meta.url,
@@ -1088,6 +1096,20 @@ if (desktopPublicWebOrigin !== sharedPublicWebOrigin) {
);
}
for (const expectedShareSnippet of [
'normalize_public_share_url',
'raw_url.starts_with("//")',
'url.origin() != base_url.origin()',
'DesktopSharePayload::Invalid',
'"share target is invalid"',
]) {
if (!desktopHostBridgeShareSource.includes(expectedShareSnippet)) {
throw new Error(
`desktop shell share URL boundary drifted: missing ${expectedShareSnippet}`,
);
}
}
for (const [limitName, sharedLimit] of Object.entries(
sharedHostBridgePayloadLimits,
)) {
@@ -2,12 +2,19 @@ use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse
use crate::shell::webview::WEB_APP_ORIGIN;
use serde_json::Value;
use std::sync::Mutex;
use tauri::Url;
#[derive(Debug, Default)]
pub(crate) struct DesktopShareState {
pub(crate) target: Mutex<Option<Value>>,
}
enum DesktopSharePayload {
Empty,
Invalid,
Valid(String),
}
fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
value
.get(field)
@@ -21,29 +28,59 @@ fn share_target_payload(value: &Value) -> &Value {
}
fn work_detail_url(work: &str) -> String {
format!("{}/works/detail?work={}", WEB_APP_ORIGIN, work)
let mut url = Url::parse(WEB_APP_ORIGIN).expect("desktop web origin");
url.set_path("/works/detail");
url.set_query(None);
url.query_pairs_mut().append_pair("work", work);
url.to_string()
}
fn share_text_from_value(value: &Value) -> Option<String> {
fn normalize_public_share_url(raw_url: &str) -> Option<String> {
if raw_url.starts_with("//") {
return None;
}
let base_url = Url::parse(WEB_APP_ORIGIN).ok()?;
let url = base_url.join(raw_url).ok()?;
if url.origin() != base_url.origin() {
return None;
}
Some(url.to_string())
}
fn share_text_from_value(value: &Value) -> DesktopSharePayload {
let target = share_target_payload(value);
let payload = target.get("payload").unwrap_or(target);
let title = payload_string(payload, "title");
let message = payload_string(payload, "message");
let url = payload_string(payload, "url").or_else(|| payload_string(payload, "href"));
let raw_direct_url = payload_string(payload, "url").or_else(|| payload_string(payload, "href"));
let direct_url = match raw_direct_url {
Some(url) => match normalize_public_share_url(url) {
Some(url) => Some(url),
None => return DesktopSharePayload::Invalid,
},
None => None,
};
let work_url = payload_string(payload, "work").map(work_detail_url);
let path_url = payload_string(payload, "path")
.or_else(|| payload_string(payload, "targetPath"))
.map(|path| format!("{}{}", WEB_APP_ORIGIN, path));
let resolved_url = url.map(str::to_owned).or(work_url).or(path_url);
let raw_path = payload_string(payload, "path").or_else(|| payload_string(payload, "targetPath"));
let path_url = match raw_path {
Some(path) => match normalize_public_share_url(path) {
Some(url) => Some(url),
None => return DesktopSharePayload::Invalid,
},
None => None,
};
let resolved_url = direct_url.or(work_url).or(path_url);
let parts = [title, message, resolved_url.as_deref()]
.into_iter()
.flatten()
.collect::<Vec<_>>();
if parts.is_empty() {
None
DesktopSharePayload::Empty
} else {
Some(parts.join("\n"))
DesktopSharePayload::Valid(parts.join("\n"))
}
}
@@ -52,8 +89,16 @@ pub(crate) fn share_text_from_request(
share_state: &DesktopShareState,
) -> Result<String, HostBridgeResponse> {
if let Some(payload) = request.payload.as_ref() {
if let Some(text) = share_text_from_value(payload) {
return Ok(text);
match share_text_from_value(payload) {
DesktopSharePayload::Valid(text) => return Ok(text),
DesktopSharePayload::Invalid => {
return Err(failed(
request.id.clone(),
"invalid_request",
"share target is invalid",
))
}
DesktopSharePayload::Empty => {}
}
}
@@ -69,16 +114,19 @@ pub(crate) fn share_text_from_request(
})?
.clone();
stored_target
.as_ref()
.and_then(share_text_from_value)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"share target is required",
)
})
match stored_target.as_ref().map(share_text_from_value) {
Some(DesktopSharePayload::Valid(text)) => Ok(text),
Some(DesktopSharePayload::Invalid) => Err(failed(
request.id.clone(),
"invalid_request",
"share target is invalid",
)),
Some(DesktopSharePayload::Empty) | None => Err(failed(
request.id.clone(),
"invalid_request",
"share target is required",
)),
}
}
#[cfg(test)]
@@ -105,6 +153,76 @@ mod tests {
);
}
#[test]
fn share_text_normalizes_public_urls_only() {
let state = DesktopShareState::default();
let mut direct_path = request("share.open");
direct_path.payload = Some(json!({
"title": "测试作品",
"path": "/works/detail?work=PZ-1"
}));
let path_text = share_text_from_request(&direct_path, &state).expect("path share text");
assert_eq!(
path_text,
"测试作品\nhttps://app.genarrative.world/works/detail?work=PZ-1"
);
let mut work = request("share.open");
work.payload = Some(json!({
"title": "测试作品",
"work": "PZ 1/二"
}));
let work_text = share_text_from_request(&work, &state).expect("work share text");
assert_eq!(
work_text,
"测试作品\nhttps://app.genarrative.world/works/detail?work=PZ+1%2F%E4%BA%8C"
);
}
#[test]
fn share_text_rejects_external_or_unsafe_urls_without_using_cached_target() {
let state = DesktopShareState::default();
*state.target.lock().expect("share target lock") = Some(json!({
"payload": {
"title": "缓存作品",
"work": "PZ-1"
}
}));
for field in ["url", "href", "path", "targetPath"] {
let mut open = request("share.open");
open.payload = Some(json!({
"title": "危险作品",
field: if field == "path" {
"//evil.example/works/detail?work=PZ-1"
} else {
"https://evil.example/works/detail?work=PZ-1"
}
}));
let response = share_text_from_request(&open, &state).expect_err("invalid share url");
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "share target is invalid");
}
let mut javascript_url = request("share.open");
javascript_url.payload = Some(json!({
"title": "危险作品",
"url": "javascript:alert(1)"
}));
assert!(!share_text_from_request(&javascript_url, &state)
.expect_err("unsafe share url")
.ok);
}
#[test]
fn share_text_uses_stored_work_target() {
let state = DesktopShareState::default();
@@ -25,6 +25,7 @@
- 2026-06-18 外链协议白名单门禁:`packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS``app.openExternalUrl` 唯一协议来源,当前只允许 `http:``https:``mailto:``tel:`;Expo 直接复用共享归一化逻辑,Tauri Rust 侧必须用 URL parser 镜像同一清单,根级 `npm run check:native-shells` 会拒绝共享契约与桌面壳协议清单漂移。
- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:``https:``mailto:``tel:` 交给 `Linking.openURL``javascript:``file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。
- 2026-06-19 移动壳系统分享 URL 边界:Expo `share.open` 调用 React Native 系统分享面板前,只允许把 `url``href``path``targetPath``work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。分享实现复用移动壳入口 URL 的生产主站 origin,配置检查会拒绝重新声明同值 origin 或移除协议相对 URL 拦截。
- 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url``href``path``targetPath``work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities``openHostShare``writeHostClipboardText``requestHostHapticsImpact``setHostAppTitle``exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。
- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck``npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 也由 `miniprogram/host-bridge/protocol.test.js` 反查同一共享白名单。新增能力必须先更新契约和真实壳实现,再通过这些检查。
File diff suppressed because one or more lines are too long
@@ -48,7 +48,7 @@ AI H5 sandbox
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗只在 `hostCapabilities` 声明 `share.open` 时展示“系统分享”,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,Tauri 桌面壳把分享文本写入系统剪贴板宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。
- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗只在 `hostCapabilities` 声明 `share.open` 时展示“系统分享”,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,Tauri 桌面壳把分享文本写入系统剪贴板。两端都只能把 `url``href``path``targetPath``work` 归一到 `https://app.genarrative.world` 同源公开 URL,外域、协议相对 URL、危险协议和无法归一的显式分享目标必须返回 `invalid_request`,且不得回退到之前缓存的 `share.setTarget` 目标;宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。
- `openHostShareGrid()`:微信小程序九宫格切图页。
- `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。
- `readHostClipboardText()`:原生 App 宿主的受控剪贴板读取入口。H5 只能读取纯文本结果,宿主返回内容会按 HostBridge 契约限制到 100000 字符;Expo 移动壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 桌面壳通过 Rust 侧 `clipboard-manager` 读取系统剪贴板文本。该能力不读取图片、HTML、文件列表或剪贴板监听事件,不把 Tauri / Expo 剪贴板插件 API 直接暴露给 H5;宿主未声明或读取失败时由 H5 视作失败并保留原流程。个人中心的邀请码和兑换码弹窗只在宿主声明 `clipboard.readText` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。