收紧桌面壳分享地址边界
桌面壳分享文本只接受公开主站同源地址 拒绝外域和危险分享地址且不回退缓存目标 补充桌面壳分享地址回归测试和配置门禁 更新 HostBridge 分享边界文档和共享决策
This commit is contained in:
@@ -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` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。
|
||||
|
||||
Reference in New Issue
Block a user