feat: 更新分享链接功能,支持包含 Base64 密码,优化用户体验

This commit is contained in:
cmliu committed 2026-07-28 01:54:23 +08:00
1 parent c171825713
commit 602e8502e7
3 files changed
+20 -8

No files matched your search

+3 -3
View File
@@ -15,7 +15,7 @@
- 首次连接时暂停认证并显示主机 SHA-256 指纹,确认后才会发送 SSH 凭据。
- SSH 连接成功后自动写入浏览器本地“历史记录”;条目统一以 `用户名@主机:端口` 命名,第二行显示最后连接时间。
- 历史记录可恢复连接配置、经 AES-GCM 加密的密码和主机指纹,但不保存私钥。
- 支持 UTF-8、GB18030、Big5 显示编码、初始命令和无凭据分享链接。
- 支持 UTF-8、GB18030、Big5 显示编码、初始命令和分享链接(密码认证时链接携带 Base64 密码,粘贴即自动连接)。
- 提供一次性会话票据、同源检查、HTTPS 强制、安全响应头和公网目标校验。
- 运行时无 SSH 第三方依赖,SSH 数据包、密钥交换、加密、认证和通道逻辑均使用 TypeScript 与 Web Crypto 实现。
@@ -77,7 +77,7 @@ Cloudflare Worker
- API 和 WebSocket 必须来自页面同源 `Origin`;Cloudflare 边缘上的 HTTP API 请求会被拒绝,页面请求会重定向至 HTTPS。
- 域名解析后逐个检查公网地址,并直接连接已验证 IP,以限制 SSRF 与 DNS 重绑定。
- SSH 主机密钥会验证交换签名并计算 `SHA256:` 指纹;没有固定指纹时,认证会暂停等待用户确认。
- SSH 连接成功后,连接配置会自动写入浏览器 Local Storage 的“历史记录”。密码使用 AES-256-GCM 加密,Local Storage 仅保存密码密文、随机 IV 和格式版本;随机生成的不可导出密钥保存在当前站点的 IndexedDB 中,不写入 Local Storage。密钥缺失、不匹配或密文校验失败时,历史记录仍会载入,但密码字段保持为空。私钥不会写入浏览器存储,任何 SSH 凭据都不会写入 Durable Object 存储。凭据输入框在发起授权后立即清空,Worker 在生成认证请求后释放凭据引用。
- SSH 连接成功后,连接配置会自动写入浏览器 Local Storage 的“历史记录”。密码使用 AES-256-GCM 加密,Local Storage 仅保存密码密文、随机 IV 和格式版本;随机生成的不可导出密钥保存在当前站点的 IndexedDB 中,不写入 Local Storage。密钥缺失、不匹配或密文校验失败时,历史记录仍会载入,但密码字段保持为空。私钥不会写入浏览器存储,任何 SSH 凭据都不会写入 Durable Object 存储。凭据输入框在连接生命周期内保留已输入内容(历史记录可随时恢复密码),仅在切换历史记录、清空表单或载入新配置时清空;Worker 在生成认证请求后释放凭据引用。
- 响应设置 CSP、HSTS、`X-Frame-Options`、`X-Content-Type-Options` 等安全头。
### 信任边界
@@ -457,7 +457,7 @@ ssh-keygen -t ed25519 -f webssh_ed25519 -N ""
- 开启 Cloudflare 账户 MFA,定期审查 Access 策略、WAF 与限流规则,并限制部署 API Token 权限。
- 监控 Worker、Durable Objects 和网络使用量。每个活动终端都会占用 Durable Object 与出站 TCP 连接,费用和限制以 Cloudflare 当前套餐为准。
- 更新前执行 `npm run check`,保留已经发布的 Durable Object migrations,并在低峰期部署。
- 不要在 URL、Issue、日志、截图或分享链接中放入密码、私钥或一次性票据。
- 不要在 Issue、日志或截图中放入密码、私钥或一次性票据。分享链接按钮生成的 URL 默认携带 Base64 密码,仅发送给可信对象,使用后从浏览器历史记录中删除。
## 许可证
+3 -3
View File
@@ -223,9 +223,9 @@
id="share-link"
class="icon-button bordered"
type="button"
aria-label="复制安全连接链接"
data-i18n-aria-label-zh="复制安全连接链接"
data-i18n-aria-label-en="Copy safe connection link"
aria-label="复制连接链接(含密码)"
data-i18n-aria-label-zh="复制连接链接(含密码)"
data-i18n-aria-label-en="Copy connection link (includes password)"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
</button>
+14 -2
View File
@@ -1455,6 +1455,14 @@ function createDecoder(encoding: string): TextDecoder {
}
}
// UTF-8 -> Base64, matching the decoder in applyURLParameters (and the
// canonical form enforced by decodeLegacyPassword).
function encodePasswordForURL(password: string): string {
let binary = '';
for (const byte of new TextEncoder().encode(password)) binary += String.fromCharCode(byte);
return btoa(binary);
}
function copySafeLink(): void {
applyFormDefaults();
const error = validateProfileFields();
@@ -1469,8 +1477,12 @@ function copySafeLink(): void {
url.searchParams.set('term', ui.termType.value);
if (ui.initialCommand.value) url.searchParams.set('command', ui.initialCommand.value);
if (ui.encoding.value !== 'utf-8') url.searchParams.set('encoding', ui.encoding.value);
const includesPassword = authMethod() === 'password' && ui.password.value !== '';
if (includesPassword) url.searchParams.set('password', encodePasswordForURL(ui.password.value));
void navigator.clipboard.writeText(url.toString()).then(
() => toast(bilingual('安全连接链接已复制(不含凭据)。', 'Safe connection link copied (credentials excluded).')),
() => toast(includesPassword
? bilingual('连接链接已复制(含 Base64 密码,粘贴即自动连接,请谨慎分享)。', 'Connection link copied (Base64 password included; pasting auto-connects — share carefully).')
: bilingual('连接链接已复制(不含凭据)。', 'Connection link copied (credentials excluded).')),
() => toast(bilingual('无法访问剪贴板。', 'Could not access the clipboard.'), 'error'),
);
}
@@ -1514,7 +1526,7 @@ function applyURLParameters(): boolean {
try {
const bytes = Uint8Array.from(atob(legacyPassword), (character) => character.charCodeAt(0));
ui.password.value = new TextDecoder().decode(bytes);
toast(bilingual('已载入旧版密码 URL,使用后请从浏览器历史记录中删除。', 'A legacy password URL was loaded. Remove it from browser history after use.'), 'error');
toast(bilingual('已从链接载入密码,使用后请从浏览器历史记录中删除该链接。', 'Password loaded from the link. Remove the link from browser history after use.'), 'error');
} catch {
toast(bilingual('密码 URL 参数不是有效的 Base64。', 'The password URL parameter is not valid Base64.'), 'error');
}