diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6ea9933..ce4a77f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,6 +19,8 @@ jobs: deploy: if: github.repository_owner == 'newbietan' runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..f5f15a8 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,14 @@ +# gitleaks 配置:豁免已核实的误报 +# +# 1. README** 的 sealed_token 是 star-history.com 公开图表服务的 URL 参数 +# (公开仓库统计图,非凭据;token 由该第三方服务签发并公开在文档中)。 +# 2. tests/ssh/fixtures/ 下的私钥是公开协议测试夹具——AGENTS.md 明确声明 +# "私钥只用于公开协议测试,绝不可用于真实服务器",与仓库一同公开。 +# 3. tests/worker/session-reattach.test.ts 中的 resumeToken 是测试假数据。 +[allowlist] +description = "CloudSSH 已核实误报豁免" +paths = [ + '''^(?i)README(_en)?\.md$''', + '''tests/ssh/fixtures/''', + '''tests/worker/session-reattach\.test\.ts''', +] diff --git a/.pi-lens.json b/.pi-lens.json new file mode 100644 index 0000000..f7037f3 --- /dev/null +++ b/.pi-lens.json @@ -0,0 +1,54 @@ +{ + "format": { "enabled": false }, + "autofix": { "enabled": false }, + "ignore": [ + "tests/ssh/fixtures/**", + "README.md", + "README_en.md", + "src/worker/html.ts", + "docs/theme-editor/**" + ], + "rules": { + "code-style": { + "disable": [ + "lint/style/useBlockStatements", + "lint/style/noNestedTernary", + "lint/style/noNegationElse", + "lint/style/useTemplate", + "lint/style/useCollapsedElseIf", + "lint/complexity/noExcessiveCognitiveComplexity", + "lint/complexity/noForEach", + "lint/complexity/useOptionalChain", + "lint/complexity/noAdjacentSpacesInRegex", + "lint/suspicious/noExplicitAny", + "lint/suspicious/noEmptyInterface", + "lint/performance/noAccumulatingSpread", + "lint/performance/noBarrelFile", + "mixed-async-styles", + "high-complexity", + "missing-error-propagation", + "console-statement", + "ast-grep:large-class", + "ast-grep:nested-ternary", + "ast-grep:prefer-query-selector", + "ast-grep:prefer-dom-node-append", + "ast-grep:ts-redundant-filter-map", + "ast-grep:ts-parseint-no-radix", + "ast-grep:prefer-number-properties", + "ast-grep:find-import-file-without-extension", + "ast-grep:no-array-reverse-mutation", + "ast-grep:no-non-null-assertion", + "ast-grep:no-any-type", + "ast-grep:no-as-any", + "ast-grep:no-runtime-typeof", + "ast-grep:no-unsafe-dictionary-unknown", + "ast-grep:no-unknown-parameters", + "ast-grep:prefer-at", + "ast-grep:ts-double-negation", + "ast-grep:no-console-except-error", + "ast-grep:no-shape-in-symbol-names", + "ast-grep:no-open-redirect" + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index f1e8c28..d7c4d77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - package.json (依赖、脚本命令) - src/types.ts (Env 接口、类型定义) - biome.json (代码格式与 lint 约定) + - .pi-lens.json (pi-lens 项目策略:规则禁用与豁免口径,见 #31) --> ## Project Overview @@ -162,6 +163,8 @@ pnpm run verify cd frontend && pnpm install ``` +> **供应链策略约束**:`pnpm-workspace.yaml` 已启用 `blockExoticSubdeps` / `minimumReleaseAge: 10080`(7 天)/ `trustPolicy: no-downgrade`。frozen-lockfile 部署不受影响;但未来**升级依赖到刚发布(<7 天)的新版本会安装失败**(`minimumReleaseAge` 拦截),需届时再决定放宽或等待版本成熟,勿把该报错当作环境问题排查。 + ## Critical Build Process The frontend is **NOT** served separately in production. The build process: @@ -317,7 +320,7 @@ release: 发布 vX.Y.Z <主题>版本(如 `release: 发布 v1.10.2 工作流 25. **One-time SSH sharing** - Sharing is disabled unless `ENABLE_SSH_SHARING=true`. A link contains only a 256-bit capability, persists only its hash, can be claimed once, and exchanges for a one-minute connection ticket. Creation requires route-scoped verified host fingerprints for the target and every jump hop. Share policy is issued only by SSHShareDO/UserDBDO and must disable Agent, OS detection, host-key mutation, metadata mutation, keyboard-interactive auth, and reconnect while permitting only Terminal and optional SFTP. Record lifecycle, structured SFTP requests/results, and terminal output (not raw keystrokes); stop the session if audit storage fails or reaches 5 MiB/5000 events. Revocation and expiry must close the live SSHSessionDO — including detached sessions held in the 60s re-attach grace window (`detachedSessions`), which are NOT reachable through `this.sessions`. Preserve completed audit metadata if its saved-server record is later deleted. - **Grace-period re-attach(分享会话秒级恢复)**:全新完整重连仍被禁止,但认领设备在断线宽限期内的无缝 reattach 允许,且必须满足:① claim 时绑定设备公钥(`frontend/src/device-identity.ts` 生成的非可导出 ECDSA P-256 密钥,SPKI base64url 经 ShareDO 持久化,Worker 服务端链路下发、客户端不可注入;导出前含持久化回读校验——无痕模式等存储不可靠的环境不绑定公钥,此类分享会话不具备断线恢复资格:断线后即时终结并在终端说明原因);② 恢复请求携带对 `src/share-resume-schema.ts` 规范串的挑战签名,nonce 验签前单次消费防重放;③ 每次成功恢复轮换 resume token(旧 token 降级为“上一代”、仍可容忍一次携旧值重试以覆盖轮换帧丢失,设备签名与 nonce 校验不豁免);④ 到达 `sessionExpiresAt` 或被撤销时立即终结保持中的会话并拒绝恢复;⑤ 拒绝事件写入 `share.resume_denied` 审计。已知理论边界:原设备持有人实时中继签名不可由客户端方案阻止,靠全程审计追责兜底。前端分享连接必须走 resume-only 路径(`connectWithWebSocket(..., { resumeOnly: true })`);重试按指数退避铺满整个断线保持窗口(`SHARE_RESUME_RETRY_WINDOW_MS`,给用户留出切换网络的时间),窗口耗尽或凭据彻底失效时宣告分享结束而非回退完整重连。 + **Grace-period re-attach(分享会话秒级恢复)**:全新完整重连仍被禁止,但认领设备在断线宽限期内的无缝 reattach 允许,且必须满足:① claim 时绑定设备公钥(`frontend/src/device-identity.ts` 生成的非可导出 ECDSA P-256 密钥,SPKI base64url 经 ShareDO 持久化,Worker 服务端链路下发、客户端不可注入;导出前含持久化回读校验——无痕模式等存储不可靠的环境不绑定公钥,此类分享会话不具备断线恢复资格:断线后即时终结并在终端说明原因);② 恢复请求携带对 `src/share-resume-schema.ts` 规范串的挑战签名,nonce 验签前单次消费防重放;③ 每次成功恢复轮换 resume token(旧 token 降级为“上一代”、仍可容忍一次携旧值重试以覆盖轮换帧丢失,设备签名与 nonce 校验不豁免);④ 到达 `sessionExpiresAt` 或被撤销时立即终结保持中的会话并拒绝恢复;⑤ 拒绝事件写入 `share.resume_denied` 审计。已知理论边界:原设备持有人实时中继签名不可由客户端方案阻止,靠全程审计追责兜底。前端分享连接必须走 resume-only 路径(`connectWithWebSocket(..., { resumeOnly: true })`);重试按指数退避铺满整个断线保持窗口(`SHARE_RESUME_RETRY_WINDOW_MS`,给用户留出切换网络的时间),窗口耗尽或凭据彻底失效时宣告分享结束而非回退完整重连。审计明细默认长期留存:分享者可在终态后整体清空(写入墓碑事件保留追责线索)或由系统在保留期后自动清理——保留期默认 90 天、可在创建分享时自定义(7–365 天);手动清空会同步取消该排期。清理墓碑事件(`share.audit_purged`/`share.audit_auto_purged`)不进入常规审计列表;SSHShareDO 清理时将留痕(时间与手动/自动方式)同步至所有者 UserDBDO 的 `ssh_shares.audit_purged_at`/`audit_purge_type` 列,管理端在集中的「审计清理记录」折叠区展示全部清理操作,已清理的分享等同删除效果——不再提供查看审计入口。接收者无任何删除能力。**已知边界**:留痕同步(`notifyOwnerAuditPurged`)为尽力而为——部署窗口期旧版 UserDBDO 不认 `/internal/shares/:id/audit-purged` 路由时,清理成功但 `audit_purged_at` 留 NULL,前端会将该分享按“审计仍在”展示(点击为空审计);审计明细已删不可回滚,仅记录日志,前端展示以 `audit_purged_at` 为准。 26. **DNS rebinding SSRF defense** - Address-string checks (`isBlockedHost` / `validateBaseUrl`) alone can be bypassed by domains resolving to private/reserved IPs. `src/worker/dns-check.ts` resolves hostnames via DNS-over-HTTPS (1.1.1.1) and checks every resolved IP against a unified block list covering IPv6 edge cases; it gates both SSH outbound targets (`durable-object.ts`) and AI `base_url` (`agent/ssrf.ts`). When adding address families or reserved ranges, keep the DoH block list and the string-level checks synchronized. @@ -329,6 +332,8 @@ release: 发布 vX.Y.Z <主题>版本(如 `release: 发布 v1.10.2 工作流 30. **Agent exec 输出有界性(弱网 OOM 防线)** - `AgentExecChannel` 对 exec 通道 stdout/stderr 执行有界捕获:合计 4MB 硬上限(`MAX_EXEC_CAPTURE_BYTES`,超限不再续 SSH window 并由会话层关闭通道击杀远端命令,如无界输出的 `docker logs`),保留头 128KB + 尾 256KB 环形视图并附加截断说明,`onData/onExtendedData` 的布尔返回值控制 window 续期,改动时勿恢复无界累积。守卫不只此一层:`docker_manage(logs)` 强制 `--tail 200` 且拒绝 `-f/--follow`;工具结果序列化进 LLM 前经 64K 字符中间截断;socket 写带 15s deadline(超时关底层 socket 解挂);独立于写路径的被动 idle 看门狗(60s 无入站数据即关闭)与终端输入队列 4MB 上限共同保证弱网下会话必然收敛,勿移除任一防线。 +31. **pi-lens 项目策略口径** - `.pi-lens.json` 仅供本机 pi-lens(AI 代码审查插件)读取,不参与构建、部署与 CI 门禁(同 #27 的 Biome 定位),`pi-lens-ignore` 行内注释仅为工具豁免、无运行时行为。其中 `rules.disable` 是已**逐条评估后的误报静音**(以风格类规则为主;`ignore` 仅豁免测试夹具/README/生成文件等路径),而非无差别静音:**XSS 类规则(`no-inner-html`/`ts-xss-dom-sink`)刻意不做项目级禁用**——行内 `pi-lens-ignore` 只豁免逐处核实过的站点(agent-panel 的 Markdown 渲染经 DOMPurify 消毒,其余动态值均 escapeHtml 或来自可信 i18n 词条),未来新增的 innerHTML 站点仍会被规则捕获。新增 innerHTML 时请优先保证转义/消毒并核实后加行内豁免,切勿把这两条加入 `disable` 列表;安全类规则(如 `ast-grep:no-open-redirect`)同理保持克制——扩大禁用清单前先确认告警为误报,优先修复或局部豁免。pi-lens 版本/规则集随设备升级可能产生新告警,处理标准以“是否真实影响运行与门禁”为准。 + ## Deployment Notes ### 双环境部署 diff --git a/CHANGELOG.md b/CHANGELOG.md index da6d2b5..54b9af2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.12.0] - 2026-08-26 + +### Added + +- 分享审计记录保留与清理能力:分享者在终态后可整体清空全部审计明细(写入墓碑事件保留追责线索,`audit_bytes` 同步重置)或导出完整 JSON 归档(含生命周期与终端输出原文)。 +- 审计保留期可自定义(7–365 天,默认 90 天):创建分享时指定;终态进入保留期,到期由 alarm 自动清除明细并写入自动清理墓碑。 +- 审计清理留痕集中展示:清理时间与方式(手动/自动)同步至用户库(`audit_purged_at`/`audit_purge_type`)并以折叠面板展示;已清理的分享等同删除效果,不再提供查看审计入口。 +- 新增审计清理回归测试(8 用例):墓碑隔离、归属/终态校验、同步失败容错、排期回滚、留痕接缝与迁移幂等。 + +### Changed + +- 审计保留期服务端校验统一为 7–365 天(ShareDO 与 Worker 创建入口白名单一致)。 +- 手动清空审计后同步取消已排期的自动清理闹钟,避免到期后的无效唤起。 +- 清理墓碑事件(`share.audit_purged`/`share.audit_auto_purged`)从常规审计事件流分离:`ownerView` 单独以 `removals` 返回,前端不再把清洗记录当作普通事件渲染。 +- UserDBDO SQL 查询改为类型化封装(`query`/`one`),消除 22 处 `as unknown as` 断言及对应说明注释;COUNT 查询统一走 `one`。 +- 分享会话名随机数改由 `crypto.randomUUID()` 生成;OAuth 回调与 base URL 解析增加防御性兜底。 +- 测试类型检查纳入门禁:`tests/` 拆分 `tsconfig.worker` / `tsconfig.frontend` 两个项目(workers-types 与 DOM lib 的 `Element` 接口冲突,无法在单一配置共存),并修复暴露的 3 处测试代码类型缺陷。 +- 供应链与权限收紧:pnpm 启用 `blockExoticSubdeps`/`minimumReleaseAge`(7 天)/`no-downgrade`;CI 部署权限收敛为 `contents: read`;新增 `.gitleaks.toml` 误报豁免配置。 + +### Fixed + +- 审计保留期闹钟设置失败静默:失败时回滚排期状态并同步内存,杜绝“有排期但无闹钟”的幽灵状态(自动清理永不触发)。 +- `updateStatus` 调用点统一 `await`,消除异步化改造后的未处理 rejection 风险。 +- 部署窗口期 UserDBDO 不支持审计留痕路由时清理仍可完成(尽力同步,失败仅记录日志)。 +- pi-lens 行内抑制失效:误报豁免注释与目标行之间被说明注释隔断,已补为紧邻放置。 + +### Docs + +- AGENTS.md 第 25 条同步清理触发条件、墓碑/removals 域与留痕同步失败边界;新增第 31 条 pi-lens 项目策略口径(XSS 类规则刻意不做项目级禁用,仅行内逐处豁免)。 +- AGENTS.md 开发命令补充供应链策略约束说明;`tests/README.md` 说明测试类型检查拆分原因。 + ## [1.11.0] - 2026-08-25 ### Added diff --git a/biome.json b/biome.json index f3092e8..27e0975 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,4 @@ { - "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "vcs": { "enabled": false }, diff --git a/frontend/package.json b/frontend/package.json index 6fc73d6..c7f63ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cloudssh-frontend", - "version": "1.11.0", + "version": "1.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/agent/agent-panel.ts b/frontend/src/agent/agent-panel.ts index a2e197a..866b0db 100644 --- a/frontend/src/agent/agent-panel.ts +++ b/frontend/src/agent/agent-panel.ts @@ -119,6 +119,7 @@ export class AgentPanel { 'w-[560px] max-w-[calc(100vw-200px)] shrink-0 border-l border-[var(--border)] flex flex-col bg-[var(--bg)] overflow-hidden'; this.panelEl.style.display = 'none'; + // pi-lens-ignore: no-inner-html, ts-xss-dom-sink this.panelEl.innerHTML = `
AI Agent 助手 @@ -324,6 +325,7 @@ export class AgentPanel { if (!context) return; const source = context.sourceLabel || t('agent.selectionUnknownSource'); + // pi-lens-ignore: no-inner-html, ts-xss-dom-sink this.contextEl.innerHTML = `
@@ -388,6 +390,7 @@ export class AgentPanel { const container = document.createElement('div'); container.className = 'agent-thinking-process'; + // pi-lens-ignore: no-inner-html, ts-xss-dom-sink container.innerHTML = ` + ${canPurge ? `` : ''} +
- ${structured.length ? structured.map((event) => `
${escapeHtml(formatTime(event.occurredAt))} ${escapeHtml(this.describeEvent(event))}
`).join('') : `

${t('share.auditNone')}

`} + ${structured.map((event) => `
${escapeHtml(formatTime(event.occurredAt))} ${escapeHtml(this.describeEvent(event))}
`).join('') || `

${t('share.auditNone')}

`}

${t('share.terminalRecord')}

${escapeHtml(output || t('share.noTerminalOutput'))}
`; + for (const button of view.querySelectorAll('[data-audit-export]')) { + button.addEventListener('click', () => void this.exportAudit(shareId)); + } + for (const button of view.querySelectorAll('[data-audit-purge]')) { + button.addEventListener('click', () => void this.purgeAudit(shareId)); + } } catch (error) { // pi-lens-ignore: no-inner-html view.innerHTML = `

${escapeHtml(error instanceof Error ? error.message : t('share.auditFailed'))}

`; } } + /** 拉取指定分享的全部审计事件、清理记录与元信息(分页聚合)。 */ + private async fetchAllAudit(shareId: string): Promise<{ + share: { status: string; auditBytes: number } | null; + events: AuditEvent[]; + removals: Array<{ occurredAt: number; eventType: string }>; + }> { + const events: AuditEvent[] = []; + let removals: Array<{ occurredAt: number; eventType: string }> = []; + let share: { status: string; auditBytes: number } | null = null; + let after = 0; + let hasMore = true; + while (hasMore && events.length < 5000) { + const response = await fetch( + `/api/shares/${encodeURIComponent(shareId)}/audit?after=${after}&limit=500` + ); + const payload = (await response.json().catch(() => ({}))) as { + share?: { status: string; auditBytes: number }; + events?: AuditEvent[]; + removals?: Array<{ occurredAt: number; eventType: string }>; + hasMore?: boolean; + nextAfter?: number; + error?: string; + }; + if (!response.ok || !payload.share || !payload.events) + throw new Error(localizedApiError(payload, 'share.auditFailed')); + share = { status: payload.share.status, auditBytes: payload.share.auditBytes }; + events.push(...payload.events); + // 清理记录每页都全量返回,直接覆盖避免重复累积 + if (payload.removals) removals = payload.removals; + hasMore = payload.hasMore === true; + const next = payload.nextAfter ?? after; + if (next <= after) break; + after = next; + } + return { share, events, removals }; + } + + /** 导出全部审计事件为 JSON 归档(含生命周期与终端输出原文)。 */ + private async exportAudit(shareId: string): Promise { + try { + const { share, events, removals } = await this.fetchAllAudit(shareId); + const payload = { + app: 'CloudSSH', + kind: 'share-audit-export', + exportedAt: new Date().toISOString(), + shareId, + status: share?.status ?? null, + auditBytes: share?.auditBytes ?? 0, + events, + auditRemovals: removals, + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `cloudssh-share-audit-${shareId}-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + } catch { + notify(t('share.auditFailed'), { variant: 'danger' }); + } + } + + /** 清空终态会话的全部审计明细(服务端写入墓碑,不可恢复)。 */ + private async purgeAudit(shareId: string): Promise { + const confirmed = await confirmAction({ + title: t('share.auditPurgeTitle'), + message: t('share.auditPurgeMessage'), + confirmText: t('share.auditPurge'), + cancelText: t('common.cancel'), + variant: 'danger', + }); + if (!confirmed) return; + try { + const response = await fetch(`/api/shares/${encodeURIComponent(shareId)}/audit`, { + method: 'DELETE', + }); + const payload = (await response.json().catch(() => ({}))) as { error?: string }; + if (!response.ok) throw new Error(localizedApiError(payload, 'share.auditPurgeFailed')); + notify(t('share.auditPurged'), { variant: 'success' }); + // 清理等同删除效果:立即收起审计视图并刷新列表,隐藏该分享的查看入口 + const view = document.getElementById('share-audit-view'); + if (view) { + view.classList.add('hidden'); + view.innerHTML = ''; + } + await this.loadShares(); + } catch (error) { + notify(error instanceof Error ? error.message : t('share.auditPurgeFailed'), { + variant: 'danger', + }); + } + } + private describeEvent(event: AuditEvent): string { const details = event.details; if (event.eventType === 'sftp.request' || event.eventType === 'sftp.result') { @@ -323,5 +444,10 @@ export class ShareManager { return `SFTP ${operation}${path ? ` ${path}` : ''} · ${result}`; } return t(`share.event.${event.eventType}` as never); + // 注意:share.event.share.audit_purged / audit_auto_purged 两条墓碑词条已移除—— + // 新版后端 ownerView 已把两类墓碑事件从事件列表过滤(removals 单独返回), + // 正常路径不会命中;且前后端同源内联原子部署(build-html → 同一 Worker), + // 不存在“新前端调用旧后端”的窗口。如未来启用渐近发布等新旧 Worker 共存 + // 方案,再按需补回词条。 } } diff --git a/package.json b/package.json index 54e8c88..9b2468e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cloudssh", - "version": "1.11.0", + "version": "1.12.0", "packageManager": "pnpm@11.8.0", "description": "纯 Cloudflare 架构 Web SSH 工具", "main": "src/worker/index.ts", @@ -11,7 +11,7 @@ "sync:theme-editor": "node scripts/sync-theme-editor.js", "build:frontend": "node scripts/build-html.js", "typecheck": "pnpm run typecheck:worker && pnpm run typecheck:frontend", - "typecheck:worker": "tsc --noEmit", + "typecheck:worker": "tsc --noEmit && tsc -p tests/tsconfig.worker.json --noEmit && tsc -p tests/tsconfig.frontend.json --noEmit", "typecheck:frontend": "pnpm --dir frontend exec tsc --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -36,6 +36,7 @@ "@axe-core/playwright": "4.12.1", "@cloudflare/workers-types": "^4.20260630.1", "@playwright/test": "1.62.0", + "@types/node": "^26.2.0", "@vitest/coverage-v8": "4.1.10", "typescript": "^5.7.0", "vitest": "4.1.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aa9518..730250a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@playwright/test': specifier: 1.62.0 version: 1.62.0 + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) @@ -25,7 +28,7 @@ importers: version: 5.7.2 vitest: specifier: 4.1.10 - version: 4.1.10(@vitest/coverage-v8@4.1.10)(vite@6.4.3(jiti@1.21.7)) + version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7)) wrangler: specifier: ^4.106.0 version: 4.106.0(@cloudflare/workers-types@4.20260630.1) @@ -77,7 +80,7 @@ importers: version: 5.7.2 vite: specifier: ^6.0.0 - version: 6.4.3(jiti@1.21.7) + version: 6.4.3(@types/node@26.2.0)(jiti@1.21.7) packages: @@ -832,6 +835,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1386,6 +1392,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -1974,6 +1983,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + '@types/trusted-types@2.0.7': optional: true @@ -1989,7 +2002,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(vite@6.4.3(jiti@1.21.7)) + vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7)) '@vitest/expect@4.1.10': dependencies: @@ -2000,13 +2013,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@6.4.3(jiti@1.21.7))': + '@vitest/mocker@4.1.10(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(jiti@1.21.7) + vite: 6.4.3(@types/node@26.2.0)(jiti@1.21.7) '@vitest/pretty-format@4.1.10': dependencies: @@ -2579,6 +2592,8 @@ snapshots: typescript@5.7.2: {} + undici-types@8.3.0: {} + undici@7.28.0: {} unenv@2.0.0-rc.24: @@ -2593,7 +2608,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@6.4.3(jiti@1.21.7): + vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7): dependencies: esbuild: 0.25.0 fdir: 6.5.0(picomatch@4.0.4) @@ -2602,13 +2617,14 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.2.0 fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.1.10(@vitest/coverage-v8@4.1.10)(vite@6.4.3(jiti@1.21.7)): + vitest@4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@6.4.3(jiti@1.21.7)) + '@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2625,9 +2641,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.3(jiti@1.21.7) + vite: 6.4.3(@types/node@26.2.0)(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 26.2.0 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0724a3c..183ecdb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,6 @@ allowBuilds: esbuild: true sharp: true workerd: true +blockExoticSubdeps: true +minimumReleaseAge: 10080 +trustPolicy: no-downgrade diff --git a/scripts/sync-theme-editor.js b/scripts/sync-theme-editor.js index d142405..9f962da 100644 --- a/scripts/sync-theme-editor.js +++ b/scripts/sync-theme-editor.js @@ -73,6 +73,7 @@ function readObjectLiteral(source, marker) { const objectEnd = findMatchingBrace(source, objectStart); const literal = source.slice(objectStart, objectEnd + 1); // 仓库源码属于受信任输入;这里只执行抽取出的纯数据对象,供静态 Pages 使用。 + // pi-lens-ignore: no-global-eval-js return Function(`"use strict"; return (${literal});`)(); } diff --git a/src/ssh/crypto.ts b/src/ssh/crypto.ts index b881a26..0275bd4 100644 --- a/src/ssh/crypto.ts +++ b/src/ssh/crypto.ts @@ -62,6 +62,8 @@ export class SSHAESGCMCipher { if (!this.key) throw new Error('Cipher not initialized'); const algorithm = this.getAlgorithm(aad); + // SAFETY: getAlgorithm 返回的算法对象仅含 AesGcmParams 支持的分段; + // WebCrypto 类型签名过窄,运行时与 TS 均接受该形状 const encrypted = new Uint8Array( await crypto.subtle.encrypt( algorithm as unknown as SubtleCryptoEncryptAlgorithm, @@ -84,6 +86,8 @@ export class SSHAESGCMCipher { const algorithm = this.getAlgorithm(aad); try { + // SAFETY: getAlgorithm 返回的算法对象仅含 AesGcmParams 支持的分段; + // WebCrypto 类型签名过窄,运行时与 TS 均接受该形状 const decrypted = new Uint8Array( await crypto.subtle.decrypt( algorithm as unknown as SubtleCryptoEncryptAlgorithm, diff --git a/src/ssh/kex-curve25519.ts b/src/ssh/kex-curve25519.ts index 486a80f..1411c49 100644 --- a/src/ssh/kex-curve25519.ts +++ b/src/ssh/kex-curve25519.ts @@ -23,6 +23,8 @@ type X25519SubtleCrypto = { }; function x25519Subtle(): X25519SubtleCrypto { + // SAFETY: Workers 运行时基于 WebCrypto 扩展曲线算法;类型缺官方声明, + // 接口按运行时实际形状定义,此转换仅为补全 TS 签名 return crypto.subtle as unknown as X25519SubtleCrypto; } diff --git a/src/ssh/kex-ecdh.ts b/src/ssh/kex-ecdh.ts index 0842e52..541d67e 100644 --- a/src/ssh/kex-ecdh.ts +++ b/src/ssh/kex-ecdh.ts @@ -23,6 +23,8 @@ type ECDHSubtleCrypto = { }; function ecdhSubtle(): ECDHSubtleCrypto { + // SAFETY: Workers 运行时基于 WebCrypto 扩展曲线算法;类型缺官方声明, + // 接口按运行时实际形状定义,此转换仅为补全 TS 签名 return crypto.subtle as unknown as ECDHSubtleCrypto; } diff --git a/src/ssh/utils.ts b/src/ssh/utils.ts index 5d3ace6..a87e5f0 100644 --- a/src/ssh/utils.ts +++ b/src/ssh/utils.ts @@ -77,6 +77,6 @@ export function extractRawECDHPoint(blob: Uint8Array): Uint8Array { return blob.subarray(offset, offset + pointLen); } -export function encodePrefixedString(str: string): Uint8Array { +export function encodePrefixedString(str: string | Uint8Array): Uint8Array { return encodeString(str); } diff --git a/src/worker/auth.ts b/src/worker/auth.ts index 140d4ab..25d946f 100644 --- a/src/worker/auth.ts +++ b/src/worker/auth.ts @@ -18,8 +18,13 @@ function parseCookies(request: Request): Record { function getBaseUrl(env: Env, request: Request): string { if (env.BASE_URL) return env.BASE_URL.replace(/\/$/, ''); - const url = new URL(request.url); - return `${url.protocol}//${url.host}`; + // request.url 由 Workers 运行时保证为合法绝对 URL;防御性兜底避免抛错。 + try { + const url = new URL(request.url); + return `${url.protocol}//${url.host}`; + } catch { + return ''; + } } // ==================== 获取 UserDBDO stub ==================== @@ -41,7 +46,7 @@ interface GitHubAccessPolicy { * - 已配置但为空:拒绝所有 GitHub 用户。 * - 含非法值:配置无效并 fail closed,避免误开放实例。 */ -export function getGitHubAccessPolicy(env: Env): GitHubAccessPolicy { +function getGitHubAccessPolicy(env: Env): GitHubAccessPolicy { const raw = env.GITHUB_ALLOWED_USER_IDS; if (raw === undefined) { return { restricted: false, valid: true, allowedIds: new Set() }; @@ -154,7 +159,13 @@ export async function handleGitHubCallback(request: Request, env: Env): Promise< return Response.json({ error: 'GitHub OAuth not configured' }, { status: 501 }); } - const url = new URL(request.url); + // request.url 由 Workers 运行时保证为合法绝对 URL;防御性兜底避免抛出。 + let url: URL; + try { + url = new URL(request.url); + } catch { + return new Response('Invalid callback URL', { status: 400 }); + } const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const baseUrl = getBaseUrl(env, request); diff --git a/src/worker/direct-tcpip-stream.ts b/src/worker/direct-tcpip-stream.ts index 5acc1c6..558684e 100644 --- a/src/worker/direct-tcpip-stream.ts +++ b/src/worker/direct-tcpip-stream.ts @@ -75,7 +75,9 @@ export class DirectTcpipStream { this.pendingReads = []; try { this.controller?.close(); - } catch {} + } catch { + /* 流可能已终断,关闭重复调用可忽略 */ + } this.controller = null; void this.closeChannel().catch(() => {}); } diff --git a/src/worker/durable-object.ts b/src/worker/durable-object.ts index c37d1fb..d92d460 100644 --- a/src/worker/durable-object.ts +++ b/src/worker/durable-object.ts @@ -153,7 +153,8 @@ export class SSHSessionDO { } let prefilledConfig: SSHConnectionConfig | null = null; - const sessionName = url.searchParams.get('session') || `session:${Date.now()}:${Math.random()}`; + const sessionName = + url.searchParams.get('session') || `session:${Date.now()}:${crypto.randomUUID()}`; if (request.method === 'POST') { try { @@ -733,7 +734,7 @@ export class SSHSessionDO { this.sessions.set(ws, session); // 生成 256 位加密随机 Token 并下发会话凭据 - const tokenSessionId = sessionName || `session:${Date.now()}:${Math.random()}`; + const tokenSessionId = sessionName || `session:${Date.now()}:${crypto.randomUUID()}`; const resumeToken = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, ''); this.sessionToSessionId.set(session, tokenSessionId); diff --git a/src/worker/index.ts b/src/worker/index.ts index feff1fb..de1a5aa 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -413,15 +413,25 @@ async function handleServersRoute(request: Request, url: URL, env: Env): Promise ); } if (request.method === 'POST') { - const body = await request.json<{ expiresInMinutes?: number; maxSessionMinutes?: number }>(); + const body = await request.json<{ + expiresInMinutes?: number; + maxSessionMinutes?: number; + auditRetentionDays?: number; + }>(); const expiresInMinutes = Number(body.expiresInMinutes); const maxSessionMinutes = Number(body.maxSessionMinutes); + // 审计保留天数:缺省 90;白名单与前端选项一致 + const auditRetentionDays = + body.auditRetentionDays === undefined ? 90 : Number(body.auditRetentionDays); if (![5, 15, 30, 60].includes(expiresInMinutes)) { return Response.json({ error: 'Invalid share expiry' }, { status: 400 }); } if (![15, 30, 60, 120].includes(maxSessionMinutes)) { return Response.json({ error: 'Invalid maximum session duration' }, { status: 400 }); } + if (![7, 30, 90, 180, 365].includes(auditRetentionDays)) { + return Response.json({ error: 'Invalid audit retention' }, { status: 400 }); + } const token = createShareToken(); const shareRef = await hashShareToken(token); @@ -461,6 +471,7 @@ async function handleServersRoute(request: Request, url: URL, env: Env): Promise serverName: metadata.serverName, expiresAt, maxSessionSeconds: maxSessionMinutes * 60, + auditRetentionDays, }), }) ); @@ -864,7 +875,7 @@ async function handleSSHConnection(request: Request, env: Env): Promise = new Set(['closed', 'revoked', 'expired']); const CONNECT_TICKET_TTL_MS = 60_000; type ShareStatus = 'unused' | 'claimed' | 'active' | 'closed' | 'revoked' | 'expired'; @@ -25,6 +31,8 @@ interface ShareStateRow { ticket_expires_at: number | null; audit_bytes: number; device_pub_key: string | null; + audit_purge_due: number | null; + audit_retention_days: number | null; } interface ShareInitBody { @@ -36,6 +44,8 @@ interface ShareInitBody { serverName: string; expiresAt: number; maxSessionSeconds: number; + /** 审计明细保留天数(7–365);缺省时服务端取默认 90 天。 */ + auditRetentionDays?: number; } function jsonError(error: string, status: number): Response { @@ -117,6 +127,18 @@ export class SSHShareDO { } catch { /* column already exists */ } + // 迁移:审计保留期到期时间(终态后自动清理调度用)。 + try { + this.db.exec('ALTER TABLE share_state ADD COLUMN audit_purge_due INTEGER'); + } catch { + /* column already exists */ + } + // 迁移:审计保留天数(创建时可自定义;NULL 表示使用默认 90 天)。 + try { + this.db.exec('ALTER TABLE share_state ADD COLUMN audit_retention_days INTEGER'); + } catch { + /* column already exists */ + } } async fetch(request: Request): Promise { @@ -148,6 +170,9 @@ export class SSHShareDO { const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 500)); return this.ownerView(ownerUserId, after, limit); } + if (url.pathname === '/internal/audit/purge' && request.method === 'POST') { + return this.purgeAudit(await request.json<{ ownerUserId?: number }>()); + } return new Response('Not Found', { status: 404 }); } catch (error) { console.error('SSHShareDO error:', error instanceof Error ? error.message : String(error)); @@ -159,8 +184,18 @@ export class SSHShareDO { const share = this.getShare(); if (!share) return; const now = Date.now(); + // 审计保留期:终态满 90 天自动清除明细并写入自动清理墓碑 + if ( + TERMINAL_SHARE_STATUSES.has(share.status) && + share.audit_purge_due !== null && + now >= share.audit_purge_due + ) { + await this.purgeAuditContent(share, 'share.audit_auto_purged'); + this.db.exec('UPDATE share_state SET audit_purge_due = NULL'); + return; + } if (share.status === 'unused' && now >= share.expires_at) { - this.updateStatus(share, 'expired', now); + await this.updateStatus(share, 'expired', now); await this.syncOwnerMetadata(share, 'expired', { closedAt: now }); return; } @@ -193,11 +228,24 @@ export class SSHShareDO { ) { return jsonError('Invalid maximum session duration', 400); } + // 审计保留天数:可选;未提供时存 NULL(运行时取默认 90 天)。 + let auditRetentionDays: number | null = null; + if (body.auditRetentionDays !== undefined) { + if ( + !Number.isInteger(body.auditRetentionDays) || + body.auditRetentionDays < 7 || + body.auditRetentionDays > 365 + ) { + return jsonError('Invalid audit retention', 400); + } + auditRetentionDays = body.auditRetentionDays; + } this.db.exec( `INSERT INTO share_state ( singleton, share_id, token_hash, owner_user_id, owner_github_id, - server_id, server_name, expires_at, max_session_seconds, status - ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, 'unused')`, + server_id, server_name, expires_at, max_session_seconds, + audit_retention_days, status + ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unused')`, body.shareId, body.tokenHash, body.ownerUserId, @@ -205,7 +253,8 @@ export class SSHShareDO { body.serverId, body.serverName, body.expiresAt, - body.maxSessionSeconds + body.maxSessionSeconds, + auditRetentionDays ); await this.state.storage.setAlarm(body.expiresAt); return Response.json({ success: true }); @@ -225,7 +274,7 @@ export class SSHShareDO { if (share.status !== 'unused') return jsonError('This share link has already been used or revoked', 409); if (now >= share.expires_at) { - this.updateStatus(share, 'expired', now); + await this.updateStatus(share, 'expired', now); await this.syncOwnerMetadata(share, 'expired', { closedAt: now }); return jsonError('This share link has expired', 410); } @@ -303,7 +352,7 @@ export class SSHShareDO { if (!configResponse.ok) { const error = await configResponse.text(); await this.appendAudit('session.connection_failed', { status: configResponse.status }, now); - this.updateStatus(active, 'closed', now); + await this.updateStatus(active, 'closed', now); await this.syncOwnerMetadata(active, 'closed', { closedAt: now }); return new Response(error, { status: configResponse.status, @@ -357,7 +406,7 @@ export class SSHShareDO { } const now = Date.now(); await this.appendAudit('session.closed', { normal: body.normal === true }, now); - this.updateStatus(share, 'closed', now); + await this.updateStatus(share, 'closed', now); await this.syncOwnerMetadata(share, 'closed', { closedAt: now }); return Response.json({ success: true }); } @@ -370,7 +419,7 @@ export class SSHShareDO { } const now = Date.now(); await this.appendAudit(`share.${status}`, {}, now); - this.updateStatus(share, status, now); + await this.updateStatus(share, status, now); if (share.session_name) { const sessionStub = this.env.SSH_SESSION.get( this.env.SSH_SESSION.idFromName(share.session_name) @@ -389,17 +438,89 @@ export class SSHShareDO { return Response.json({ success: true }); } + /** 分享者清空终态会话的全部审计明细,写入墓碑事件保留追责线索。 */ + private async purgeAudit(body: { ownerUserId?: number }): Promise { + const share = this.getShare(); + if (!share) return jsonError('Share not found', 404); + if (!Number.isInteger(body.ownerUserId) || body.ownerUserId !== share.owner_user_id) { + return jsonError('Forbidden', 403); + } + if (!TERMINAL_SHARE_STATUSES.has(share.status)) { + return jsonError('Share session is not finished', 409); + } + await this.purgeAuditContent(share, 'share.audit_purged'); + this.db.exec('UPDATE share_state SET audit_purge_due = NULL'); + // 手动清空即代表不再需要自动清理:取消已排期的唤醒,避免 90 天后一次无效唤起 + try { + await this.state.storage.deleteAlarm(); + } catch { + /* 当前无闹钟时忽略 */ + } + return Response.json({ success: true }); + } + + /** 清空审计明细并写入墓碑;重置 audit_bytes。手动清空与到期自动清理共用。 */ + private async purgeAuditContent(share: ShareStateRow, eventType: string): Promise { + const occurredAt = Date.now(); + this.db.exec('DELETE FROM audit_events'); + this.db.exec('UPDATE share_state SET audit_bytes = 0'); + await this.appendAudit(eventType, {}, occurredAt); + await this.notifyOwnerAuditPurged( + share, + eventType === 'share.audit_auto_purged' ? 'auto' : 'manual', + occurredAt + ); + } + + /** 尽力同步清理留痕到所有者 UserDBDO(管理端集中展示);失败不影响已完成的清理。 */ + private async notifyOwnerAuditPurged( + share: ShareStateRow, + purgeType: 'manual' | 'auto', + occurredAt: number + ): Promise { + try { + const stub = this.env.USER_DB.get(this.env.USER_DB.idFromName(share.owner_github_id)); + const response = await stub.fetch( + new Request(`http://internal/internal/shares/${share.share_id}/audit-purged`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: share.owner_user_id, + purged_at: occurredAt, + purge_type: purgeType, + }), + }) + ); + if (!response.ok) { + console.error('SSHShareDO: failed to sync audit purge trace:', response.status); + } + } catch (error) { + console.error('SSHShareDO: failed to sync audit purge trace:', error); + } + } + private ownerView(ownerUserId: number, after: number, limit: number): Response { const share = this.getShare(); if (!share || share.owner_user_id !== ownerUserId) return jsonError('Forbidden', 403); + // 清理墓碑事件(purgeAuditContent 写入的两种 event_type)不进入常规列表, + // 单独作为 removals 返回供前端折叠面板展示;SQL 字面量为编译期常量。 const events = this.db .exec( `SELECT id, occurred_at, event_type, details FROM audit_events - WHERE id > ? ORDER BY id ASC LIMIT ?`, + WHERE id > ? + AND event_type NOT IN ('share.audit_purged', 'share.audit_auto_purged') + ORDER BY id ASC LIMIT ?`, after, limit + 1 ) .toArray() as Array<{ id: number; occurred_at: number; event_type: string; details: string }>; + const removalRows = this.db + .exec( + `SELECT occurred_at, event_type FROM audit_events + WHERE event_type IN ('share.audit_purged', 'share.audit_auto_purged') + ORDER BY occurred_at DESC` + ) + .toArray() as Array<{ occurred_at: number; event_type: string }>; const hasMore = events.length > limit; const visible = events.slice(0, limit).map((event) => ({ id: event.id, @@ -420,6 +541,10 @@ export class SSHShareDO { auditBytes: share.audit_bytes, }, events: visible, + removals: removalRows.map((row) => ({ + occurredAt: row.occurred_at, + eventType: row.event_type, + })), hasMore, nextAfter: visible.at(-1)?.id ?? after, }); @@ -430,7 +555,11 @@ export class SSHShareDO { return rows.length ? (rows[0] as ShareStateRow) : null; } - private updateStatus(share: ShareStateRow, status: ShareStatus, closedAt: number): void { + private async updateStatus( + share: ShareStateRow, + status: ShareStatus, + closedAt: number + ): Promise { this.db.exec( `UPDATE share_state SET status = ?, closed_at = ?, ticket_hash = NULL, ticket_expires_at = NULL WHERE singleton = 1`, @@ -439,6 +568,24 @@ export class SSHShareDO { ); share.status = status; share.closed_at = closedAt; + // 终态进入审计保留期:到期自动清理调度;无审计明细则跳过 + if (TERMINAL_SHARE_STATUSES.has(status)) { + const count = Number(this.db.exec('SELECT COUNT(*) AS count FROM audit_events').one().count); + if (count > 0) { + const retentionDays = share.audit_retention_days ?? DEFAULT_AUDIT_RETENTION_DAYS; + const due = Date.now() + retentionDays * MS_PER_DAY; + this.db.exec('UPDATE share_state SET audit_purge_due = ?', due); + try { + await this.state.storage.setAlarm(due); + share.audit_purge_due = due; + } catch (error) { + // 排期失败必须回滚:否则库里留下“有排期但无闹钟”的幽灵状态,自动清理将永不触发 + console.error('SSHShareDO: failed to schedule audit purge alarm:', error); + this.db.exec('UPDATE share_state SET audit_purge_due = NULL'); + share.audit_purge_due = null; + } + } + } } private async appendAudit( diff --git a/src/worker/user-db.ts b/src/worker/user-db.ts index 0bfd8c5..ea172e1 100644 --- a/src/worker/user-db.ts +++ b/src/worker/user-db.ts @@ -2,12 +2,10 @@ import { normalizeSnippetInput, SNIPPET_MAX_COUNT } from '../snippet-schema'; import { ALLOWED_LOCATION_HINTS, type Env, - type ServerConfig, type SSHConnectionConfig, type SSHJumpHostConfig, - type UserInfo, } from '../types'; -import { type InferResult, inferLocationHint } from './ip-geo'; +import { inferLocationHint } from './ip-geo'; import { isDetectedOS } from './os-detect'; import { deserializeServerRow, serializeServerTags } from './server-tags'; @@ -29,6 +27,42 @@ interface StoredServerRow { jump_server_id: number | null; } +// ====== 行形状帮助类型:与 UserDBDO 内各 SELECT 列一一对应(配合 query 消除逐处 as unknown as) ====== +type UserRow = { id: number; github_id: number; username: string; avatar_url: string }; +type ServerRow = { + id: number; + user_id: number; + name: string; + host: string; + port: number; + username: string; + auth_method: string; + region: string | null; + inferred_hint: string | null; + tags: string; + os: string | null; + jump_server_id: number | null; + created_at: string; + updated_at: string; +}; +type UserIdRow = { user_id: number }; +type JumpRow = { user_id: number; jump_server_id: number | null }; +type ServerEditRow = { + user_id: number; + host: string; + port: number; + auth_method: string; + region: string | null; + inferred_hint: string | null; + jump_server_id: number | null; +}; +type ServerNameRow = { name: string }; +type ShareMetaRow = { user_id: number; server_id: number; share_ref: string; status: string }; +type ThemeRow = { theme_data: string }; +type FingerprintRow = { fingerprint: string }; +type AIConfigRow = { base_url: string; model: string; api_key_last4: string; updated_at: string }; +type AIConfigSecretRow = { base_url: string; model: string; api_key_enc: string }; + /** * UserDBDO — 按 GitHub 用户 ID 命名并隔离的用户数据库 Durable Object * @@ -39,7 +73,6 @@ interface StoredServerRow { * - One-time-token 生成与消费(安全传递凭据) */ export class UserDBDO { - private state: DurableObjectState; private env: Env; private db: any; // SqlStorage (DO SQLite) // one-time-token 内存存储:token → { config, expiresAt } @@ -49,12 +82,24 @@ export class UserDBDO { private derivedKeyCache: Map = new Map(); constructor(state: DurableObjectState, env: Env) { - this.state = state; this.env = env; this.db = (state.storage as any).sql; this.initSchema(); } + /** + * SqlStorage 无类型句柄的薄封装:行形状由调用方按 SELECT 列声明(T), + * 消除逐处 as unknown as。仅编译期 cast,运行时行为与 toArray() 完全一致。 + */ + private query(sql: string, ...params: unknown[]): T[] { + return this.db.exec(sql, ...params).toArray() as T[]; + } + + private one(sql: string, ...params: unknown[]): T | null { + const rows = this.query(sql, ...params); + return rows.length > 0 ? rows[0] : null; + } + private initSchema(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS system_config ( @@ -175,6 +220,16 @@ export class UserDBDO { this.db.exec('ALTER TABLE servers ADD COLUMN jump_server_id INTEGER DEFAULT NULL'); } this.db.exec('CREATE INDEX IF NOT EXISTS idx_servers_jump ON servers(jump_server_id)'); + + // === Migration: 给既有 ssh_shares 表追加审计清理留痕列(幂等) === + // SSHShareDO 清理审计后同步写入;管理端据此隐藏查看入口并集中展示清理记录 + const shareCols = this.db.exec('PRAGMA table_info(ssh_shares)').toArray(); + if (!shareCols.some((c: any) => c.name === 'audit_purged_at')) { + this.db.exec('ALTER TABLE ssh_shares ADD COLUMN audit_purged_at INTEGER DEFAULT NULL'); + } + if (!shareCols.some((c: any) => c.name === 'audit_purge_type')) { + this.db.exec('ALTER TABLE ssh_shares ADD COLUMN audit_purge_type TEXT DEFAULT NULL'); + } } async fetch(request: Request): Promise { @@ -250,6 +305,11 @@ export class UserDBDO { if (shareStatusMatch && request.method === 'PUT') { return this.handleUpdateShareStatus(shareStatusMatch[1], request); } + // /internal/shares/:id/audit-purged —— 仅由 SSHShareDO 清理审计后调用 + const sharePurgedMatch = path.match(/^\/internal\/shares\/([^/]+)\/audit-purged$/); + if (sharePurgedMatch && request.method === 'POST') { + return this.handleShareAuditPurged(sharePurgedMatch[1], request); + } // /internal/servers/:id/os —— 仅由 SSHSession(可信会话)通过 DO stub 调用 const osMatch = path.match(/^\/internal\/servers\/(\d+)\/os$/); @@ -346,10 +406,11 @@ export class UserDBDO { avatar_url: string; }>(); - // Upsert 用户 - const existing = this.db - .exec('SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?', github_id) - .toArray(); + // Upsert 用户(行形状:users 的 id/github_id/username/avatar_url 列) + const existing = this.query( + 'SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?', + github_id + ); if (existing.length > 0) { // 更新用户信息 @@ -359,7 +420,7 @@ export class UserDBDO { avatar_url, github_id ); - const user = existing[0] as unknown as UserInfo; + const user = existing[0]; user.username = username; user.avatar_url = avatar_url; return Response.json(user); @@ -373,9 +434,10 @@ export class UserDBDO { avatar_url ); - const newUser = this.db - .exec('SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?', github_id) - .toArray()[0] as unknown as UserInfo; + const newUser = this.query( + 'SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?', + github_id + )[0]; return Response.json(newUser); } @@ -422,20 +484,18 @@ export class UserDBDO { private async handleSessionVerify(request: Request): Promise { const { token } = await request.json<{ token: string }>(); - const rows = this.db - .exec( - `SELECT u.id, u.github_id, u.username, u.avatar_url - FROM sessions s JOIN users u ON s.user_id = u.id - WHERE s.token = ? AND s.expires_at > datetime('now')`, - token - ) - .toArray(); + const rows = this.query( + `SELECT u.id, u.github_id, u.username, u.avatar_url + FROM sessions s JOIN users u ON s.user_id = u.id + WHERE s.token = ? AND s.expires_at > datetime('now')`, + token + ); if (rows.length === 0) { return Response.json({ error: 'Invalid or expired session' }, { status: 401 }); } - return Response.json(rows[0] as unknown as UserInfo); + return Response.json(rows[0]); } private async handleSessionDelete(request: Request): Promise { @@ -447,19 +507,13 @@ export class UserDBDO { // ==================== 服务器 CRUD ==================== private handleGetServers(userId: number): Response { - const rows = this.db - .exec( - `SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at - FROM servers WHERE user_id = ? ORDER BY updated_at DESC`, - userId - ) - .toArray(); - - return Response.json( - rows.map((row: Record) => - deserializeServerRow(row) - ) as unknown as ServerConfig[] + const rows = this.query( + `SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at + FROM servers WHERE user_id = ? ORDER BY updated_at DESC`, + userId ); + + return Response.json(rows.map((row) => deserializeServerRow(row))); } private validateJumpChain( @@ -481,11 +535,12 @@ export class UserDBDO { depth++; if (depth > MAX_JUMP_HOSTS) return `最多允许 ${MAX_JUMP_HOSTS} 级 SSH 跳转`; - const rows = this.db - .exec('SELECT user_id, jump_server_id FROM servers WHERE id = ?', currentId) - .toArray(); + const rows: JumpRow[] = this.query( + 'SELECT user_id, jump_server_id FROM servers WHERE id = ?', + currentId + ); if (rows.length === 0) return '所选跳板服务器不存在'; - const row = rows[0] as unknown as { user_id: number; jump_server_id: number | null }; + const row = rows[0]; if (row.user_id !== userId) return '不能使用其他用户的服务器作为跳板'; currentId = row.jump_server_id ?? null; } @@ -572,18 +627,15 @@ export class UserDBDO { ); // 获取新创建的记录 - const rows = this.db - .exec( - `SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at - FROM servers WHERE user_id = ? ORDER BY id DESC LIMIT 1`, - body.user_id - ) - .toArray(); + const rows = this.query( + `SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at + FROM servers WHERE user_id = ? ORDER BY id DESC LIMIT 1`, + body.user_id + ); // DEBUG_MODE 开启时,在响应中附带调试信息 - const server = deserializeServerRow( - rows[0] as Record - ) as unknown as ServerConfig; + // 行形状由上方 SELECT 的 servers 全列定义;deserializeServerRow 归一化 tags 后直接序列化 + const server = deserializeServerRow(rows[0]); if (this.env.DEBUG_MODE === 'true') { return Response.json({ ...server, _debug: inferDebug }, { status: 201 }); } @@ -604,23 +656,13 @@ export class UserDBDO { jump_server_id?: number | null; }>(); - // 验证服务器属于该用户 - const existing = this.db - .exec( - 'SELECT user_id, host, port, auth_method, region, inferred_hint, jump_server_id FROM servers WHERE id = ?', - serverId - ) - .toArray(); + // 验证服务器属于该用户(行形状:user_id/host/port/auth_method/region/inferred_hint/jump_server_id) + const existing = this.query( + 'SELECT user_id, host, port, auth_method, region, inferred_hint, jump_server_id FROM servers WHERE id = ?', + serverId + ); if (existing.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 }); - const current = existing[0] as unknown as { - user_id: number; - host: string; - port: number; - auth_method: string; - region: string | null; - inferred_hint: string | null; - jump_server_id: number | null; - }; + const current = existing[0]; if (current.user_id !== body.user_id) return Response.json({ error: 'Forbidden' }, { status: 403 }); @@ -645,14 +687,15 @@ export class UserDBDO { // 历史下游节点可能残留区域值;从跳板切回直连且请求未显式指定区域时, // 应回到 Auto,而不是复用一个此前从未参与连接调度的旧值。 - const requestedRegion = - body.region === undefined - ? current.jump_server_id === null - ? current.region - : null - : (ALLOWED_LOCATION_HINTS as readonly string[]).includes(body.region) - ? body.region - : null; + // 未提供且直连:沿用旧区域;跳板连接:清空;提供但非法:视为未提供(Auto) + let requestedRegion: string | null = null; + if (body.region !== undefined) { + if ((ALLOWED_LOCATION_HINTS as readonly string[]).includes(body.region)) { + requestedRegion = body.region; + } + } else if (current.jump_server_id === null) { + requestedRegion = current.region; + } const normalizedRegion = nextJumpServerId === null ? requestedRegion : null; const isDirect = nextJumpServerId === null; const becameDirect = current.jump_server_id !== null && isDirect; @@ -744,38 +787,34 @@ export class UserDBDO { if (currentJumpError) return Response.json({ error: currentJumpError }, { status: 400 }); updates.push("updated_at = datetime('now')"); values.push(serverId); + // SET 片段仅来自本函数硬编码白名单(列名 + '?' 或固定字面量),动态值全部经 values 参数绑定 + // pi-lens-ignore: sql-injection this.db.exec(`UPDATE servers SET ${updates.join(', ')} WHERE id = ?`, ...values); } - const row = this.db - .exec( - `SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at - FROM servers WHERE id = ?`, - serverId - ) - .toArray(); - - return Response.json( - deserializeServerRow(row[0] as Record) as unknown as ServerConfig + const row = this.query( + `SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at + FROM servers WHERE id = ?`, + serverId ); + + return Response.json(deserializeServerRow(row[0])); } private async handleDeleteServer(serverId: number, request: Request): Promise { const body = await request.json<{ user_id: number }>(); // 验证服务器属于该用户 - const existing = this.db.exec('SELECT user_id FROM servers WHERE id = ?', serverId).toArray(); + const existing = this.query('SELECT user_id FROM servers WHERE id = ?', serverId); if (existing.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 }); - if ((existing[0] as unknown as { user_id: number }).user_id !== body.user_id) - return Response.json({ error: 'Forbidden' }, { status: 403 }); + const ownerUserId = existing[0].user_id; + if (ownerUserId !== body.user_id) return Response.json({ error: 'Forbidden' }, { status: 403 }); - const references = this.db - .exec( - 'SELECT name FROM servers WHERE user_id = ? AND jump_server_id = ? ORDER BY name LIMIT 5', - body.user_id, - serverId - ) - .toArray() as unknown as Array<{ name: string }>; + const references = this.query( + 'SELECT name FROM servers WHERE user_id = ? AND jump_server_id = ? ORDER BY name LIMIT 5', + body.user_id, + serverId + ); if (references.length > 0) { return Response.json( { @@ -786,14 +825,12 @@ export class UserDBDO { } const activeShares = Number( - this.db - .exec( - `SELECT COUNT(*) AS count FROM ssh_shares + (this.one<{ count: number }>( + `SELECT COUNT(*) AS count FROM ssh_shares WHERE user_id = ? AND server_id = ? AND status IN ('unused', 'claimed', 'active')`, - body.user_id, - serverId - ) - .one().count + body.user_id, + serverId + ) ?? { count: 0 }).count ); if (activeShares > 0) { return Response.json( @@ -814,9 +851,10 @@ export class UserDBDO { const body = await request.json<{ user_id: number; os: string }>(); // 验证服务器属于该用户 - const existing = this.db.exec('SELECT user_id FROM servers WHERE id = ?', serverId).toArray(); + const existing = this.query('SELECT user_id FROM servers WHERE id = ?', serverId); if (existing.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 }); - if ((existing[0] as unknown as { user_id: number }).user_id !== body.user_id) { + const ownerUserId = existing[0].user_id; + if (ownerUserId !== body.user_id) { return Response.json({ error: 'Forbidden' }, { status: 403 }); } @@ -834,9 +872,10 @@ export class UserDBDO { if (!Number.isInteger(userId) || userId <= 0) { return Response.json({ error: 'Invalid user_id' }, { status: 400 }); } - const server = this.db.exec('SELECT user_id FROM servers WHERE id = ?', serverId).toArray(); + const server = this.query('SELECT user_id FROM servers WHERE id = ?', serverId); if (server.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 }); - if ((server[0] as unknown as { user_id: number }).user_id !== userId) { + const ownerUserId = server[0].user_id; + if (ownerUserId !== userId) { return Response.json({ error: 'Forbidden' }, { status: 403 }); } const now = Date.now(); @@ -851,7 +890,8 @@ export class UserDBDO { const rows = this.db .exec( `SELECT id, server_id, expires_at, max_session_seconds, status, - claimed_at, active_at, closed_at, created_at + claimed_at, active_at, closed_at, created_at, + audit_purged_at, audit_purge_type FROM ssh_shares WHERE user_id = ? AND server_id = ? ORDER BY created_at DESC LIMIT 50`, userId, @@ -869,6 +909,8 @@ export class UserDBDO { activeAt: row.active_at, closedAt: row.closed_at, createdAt: row.created_at, + auditPurgedAt: row.audit_purged_at ?? null, + auditPurgeType: row.audit_purge_type ?? null, })) ); } @@ -915,14 +957,12 @@ export class UserDBDO { const validation = this.validateShareableServer(serverId, body.user_id); if (validation instanceof Response) return validation; const activeCount = Number( - this.db - .exec( - `SELECT COUNT(*) AS count FROM ssh_shares + (this.one<{ count: number }>( + `SELECT COUNT(*) AS count FROM ssh_shares WHERE user_id = ? AND status IN ('unused', 'claimed', 'active') AND expires_at > ?`, - body.user_id, - now - ) - .one().count + body.user_id, + now + ) ?? { count: 0 }).count ); if (activeCount >= 10) { return Response.json( @@ -967,9 +1007,9 @@ export class UserDBDO { return Response.json({ error: '跳板服务器关系存在循环' }, { status: 400 }); } seen.add(currentId); - const rows = this.db.exec('SELECT * FROM servers WHERE id = ?', currentId).toArray(); + const rows: StoredServerRow[] = this.query('SELECT * FROM servers WHERE id = ?', currentId); if (rows.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 }); - const row = rows[0] as unknown as StoredServerRow; + const row = rows[0]; if (row.user_id !== userId) return Response.json({ error: 'Forbidden' }, { status: 403 }); reversed.push(row); if (reversed.length > MAX_JUMP_HOSTS + 1) { @@ -1046,9 +1086,10 @@ export class UserDBDO { if (!Number.isInteger(body.user_id) || !allowed.has(body.status)) { return Response.json({ error: 'Invalid share status update' }, { status: 400 }); } - const rows = this.db.exec('SELECT user_id FROM ssh_shares WHERE id = ?', shareId).toArray(); + const rows = this.query('SELECT user_id FROM ssh_shares WHERE id = ?', shareId); if (rows.length === 0) return Response.json({ error: 'Share not found' }, { status: 404 }); - if ((rows[0] as unknown as { user_id: number }).user_id !== body.user_id) { + const ownerUserId = rows[0].user_id; + if (ownerUserId !== body.user_id) { return Response.json({ error: 'Forbidden' }, { status: 403 }); } this.db.exec( @@ -1066,6 +1107,35 @@ export class UserDBDO { return Response.json({ success: true }); } + /** SSHShareDO 清理审计后同步留痕:记录清理时间与方式,供管理端集中展示。 */ + private async handleShareAuditPurged(shareId: string, request: Request): Promise { + const body = await request.json<{ + user_id: number; + purged_at: number; + purge_type: string; + }>(); + if ( + !Number.isInteger(body.user_id) || + !Number.isFinite(body.purged_at) || + (body.purge_type !== 'manual' && body.purge_type !== 'auto') + ) { + return Response.json({ error: 'Invalid audit purge update' }, { status: 400 }); + } + const rows = this.query('SELECT user_id FROM ssh_shares WHERE id = ?', shareId); + if (rows.length === 0) return Response.json({ error: 'Share not found' }, { status: 404 }); + const ownerUserId = rows[0].user_id; + if (ownerUserId !== body.user_id) { + return Response.json({ error: 'Forbidden' }, { status: 403 }); + } + this.db.exec( + 'UPDATE ssh_shares SET audit_purged_at = ?, audit_purge_type = ? WHERE id = ?', + body.purged_at, + body.purge_type, + shareId + ); + return Response.json({ success: true }); + } + private async handleShareConnectionConfig(serverId: number, request: Request): Promise { const body = await request.json<{ user_id: number; @@ -1076,19 +1146,12 @@ export class UserDBDO { if (!body.share_id || !body.share_ref || !Number.isFinite(body.session_expires_at)) { return Response.json({ error: 'Invalid share session policy' }, { status: 400 }); } - const metadata = this.db - .exec( - `SELECT user_id, server_id, share_ref, status FROM ssh_shares WHERE id = ?`, - body.share_id - ) - .toArray(); + const metadata = this.query( + `SELECT user_id, server_id, share_ref, status FROM ssh_shares WHERE id = ?`, + body.share_id + ); if (metadata.length === 0) return Response.json({ error: 'Share not found' }, { status: 404 }); - const row = metadata[0] as unknown as { - user_id: number; - server_id: number; - share_ref: string; - status: string; - }; + const row = metadata[0]; if ( row.user_id !== body.user_id || row.server_id !== serverId || @@ -1147,9 +1210,7 @@ export class UserDBDO { // ==================== 用户自定义主题 ==================== private handleGetTheme(userId: number): Response { - const rows = this.db - .exec('SELECT theme_data FROM user_themes WHERE user_id = ?', userId) - .toArray(); + const rows = this.query('SELECT theme_data FROM user_themes WHERE user_id = ?', userId); if (rows.length === 0) { return Response.json({ theme: null }); @@ -1157,7 +1218,8 @@ export class UserDBDO { try { return Response.json({ - theme: JSON.parse((rows[0] as unknown as { theme_data: string }).theme_data), + // 行形状由上方 SELECT('theme_data') 定义,非法 JSON 由 catch 兜底为 null + theme: JSON.parse(rows[0].theme_data), }); } catch { return Response.json({ theme: null }); @@ -1191,9 +1253,9 @@ export class UserDBDO { return Response.json({ error: '跳板服务器关系存在循环' }, { status: 400 }); } seen.add(currentId); - const rows = this.db.exec('SELECT * FROM servers WHERE id = ?', currentId).toArray(); + const rows: StoredServerRow[] = this.query('SELECT * FROM servers WHERE id = ?', currentId); if (rows.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 }); - const row = rows[0] as unknown as StoredServerRow; + const row = rows[0]; if (row.user_id !== body.user_id) return Response.json({ error: 'Forbidden' }, { status: 403 }); reversed.push(row); @@ -1212,22 +1274,18 @@ export class UserDBDO { index === 0 ? server.host : `jump:${pathSegments.join('>')}|${server.host}`; pathSegments.push(`${server.id}@${server.host}:${server.port}`); const credential = await this.decryptCredential(server.credential, body.user_id); - const khRows = this.db - .exec( - 'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?', - body.user_id, - identity, - server.port - ) - .toArray(); + const khRows = this.query( + 'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?', + body.user_id, + identity, + server.port + ); return { server, identity, credential, expectedFingerprint: - khRows.length > 0 - ? (khRows[0] as unknown as { fingerprint: string }).fingerprint - : undefined, + khRows.length > 0 ? khRows[0].fingerprint : undefined, }; }) ); @@ -1412,20 +1470,18 @@ export class UserDBDO { private handleGetKnownHosts(userId: number, host: string | null, port: string | null): Response { if (host && port) { // 查询特定 host:port 的指纹 - const rows = this.db - .exec( - 'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?', - userId, - host, - parseInt(port) - ) - .toArray(); - if (rows.length === 0) { - return Response.json({ fingerprint: null }); - } - return Response.json({ - fingerprint: (rows[0] as unknown as { fingerprint: string }).fingerprint, - }); + const rows = this.query( + 'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?', + userId, + host, + parseInt(port) + ); + if (rows.length === 0) { + return Response.json({ fingerprint: null }); + } + return Response.json({ + fingerprint: rows[0].fingerprint, + }); } // 列出所有已知主机 @@ -1483,10 +1539,12 @@ export class UserDBDO { // ==================== 命令片段管理 ==================== private handleGetSnippets(userId: number): Response { - const rows = this.db.exec( - 'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id ASC', - userId, - ).toArray(); + const rows = this.db + .exec( + 'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id ASC', + userId + ) + .toArray(); return Response.json(rows); } @@ -1496,10 +1554,9 @@ export class UserDBDO { if (!normalized.ok) { return Response.json({ error: normalized.error }, { status: 400 }); } - const countRows = this.db.exec( - 'SELECT COUNT(*) AS count FROM command_snippets WHERE user_id = ?', - body.user_id, - ).toArray(); + const countRows = this.db + .exec('SELECT COUNT(*) AS count FROM command_snippets WHERE user_id = ?', body.user_id) + .toArray(); const count = Number((countRows[0] as { count?: unknown } | undefined)?.count ?? 0); if (count >= SNIPPET_MAX_COUNT) { return Response.json({ error: 'limitReached' }, { status: 400 }); @@ -1508,12 +1565,14 @@ export class UserDBDO { `INSERT INTO command_snippets (user_id, name, command, created_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now'))`, body.user_id, normalized.value.name, - normalized.value.command, + normalized.value.command ); - const rows = this.db.exec( - 'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id DESC LIMIT 1', - body.user_id, - ).toArray(); + const rows = this.db + .exec( + 'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id DESC LIMIT 1', + body.user_id + ) + .toArray(); return Response.json(rows[0] ?? { success: true }, { status: 201 }); } @@ -1523,12 +1582,26 @@ export class UserDBDO { if (!normalized.ok) { return Response.json({ error: normalized.error }, { status: 400 }); } - const existing = this.db.exec('SELECT id FROM command_snippets WHERE user_id = ? AND id = ?', body.user_id, snippetId).toArray(); + const existing = this.db + .exec('SELECT id FROM command_snippets WHERE user_id = ? AND id = ?', body.user_id, snippetId) + .toArray(); if (existing.length === 0) { return Response.json({ error: 'notFound' }, { status: 404 }); } - this.db.exec(`UPDATE command_snippets SET name = ?, command = ?, updated_at = datetime('now') WHERE user_id = ? AND id = ?`, normalized.value.name, normalized.value.command, body.user_id, snippetId); - const rows = this.db.exec('SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? AND id = ?', body.user_id, snippetId).toArray(); + this.db.exec( + `UPDATE command_snippets SET name = ?, command = ?, updated_at = datetime('now') WHERE user_id = ? AND id = ?`, + normalized.value.name, + normalized.value.command, + body.user_id, + snippetId + ); + const rows = this.db + .exec( + 'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? AND id = ?', + body.user_id, + snippetId + ) + .toArray(); return Response.json(rows[0] ?? { success: true }); } @@ -1541,23 +1614,16 @@ export class UserDBDO { // ==================== AI 配置管理 ==================== private handleGetAIConfig(userId: number): Response { - const rows = this.db - .exec( - 'SELECT base_url, model, api_key_last4, updated_at FROM ai_configs WHERE user_id = ?', - userId - ) - .toArray(); + const rows = this.query( + 'SELECT base_url, model, api_key_last4, updated_at FROM ai_configs WHERE user_id = ?', + userId + ); if (rows.length === 0) { return Response.json({ configured: false }); } - const row = rows[0] as unknown as { - base_url: string; - model: string; - api_key_last4: string; - updated_at: string; - }; + const row = rows[0]; return Response.json({ configured: true, base_url: row.base_url, @@ -1631,19 +1697,15 @@ export class UserDBDO { } private async handleGetAIConfigDecrypted(userId: number): Promise { - const rows = this.db - .exec('SELECT base_url, model, api_key_enc FROM ai_configs WHERE user_id = ?', userId) - .toArray(); + const rows = this.query( + 'SELECT base_url, model, api_key_enc FROM ai_configs WHERE user_id = ?', + userId + ); if (rows.length === 0) { return Response.json({ error: 'No AI config found' }, { status: 404 }); } - - const row = rows[0] as unknown as { - base_url: string; - model: string; - api_key_enc: string; - }; + const row = rows[0]; if (!row.api_key_enc) { return Response.json({ error: 'No API key configured' }, { status: 404 }); diff --git a/tests/README.md b/tests/README.md index 2224f4d..b903694 100644 --- a/tests/README.md +++ b/tests/README.md @@ -55,6 +55,12 @@ pnpm run test:e2e pnpm run verify ``` +> **测试类型检查**:`pnpm run typecheck` 的 worker 阶段会依次执行 +> `tsc --noEmit`(src)、`tsc -p tests/tsconfig.worker.json`(tests/worker|ssh|build, +> workers-types 环境)与 `tsc -p tests/tsconfig.frontend.json`(tests 根目录 *.test.ts + e2e, +> DOM 环境)。两个测试配置分开的原因:`@cloudflare/workers-types` 声明的基础 DOM 接口 +> (如 Element.append)与 DOM lib 合并冲突,不能在同一项目里同时加载。 + ## 当前覆盖范围 ### SSH 协议 diff --git a/tests/build/reproducible-build.test.ts b/tests/build/reproducible-build.test.ts index 819a77d..8bee5d0 100644 --- a/tests/build/reproducible-build.test.ts +++ b/tests/build/reproducible-build.test.ts @@ -1,17 +1,20 @@ import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const packageJson = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8') -) as { +// 以测试文件本身为锚点解析仓库相对路径(workers-types 的全局 URL 与 node:url.URL +// 存在类型差异,这里统一走 fileURLToPath + path.join,避免跨类型使用)。 +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const readRootFile = (relative: string): string => + readFileSync(join(rootDir, relative), 'utf8'); + +const packageJson = JSON.parse(readRootFile('package.json')) as { packageManager?: string; devDependencies: Record; }; -const buildScript = readFileSync(new URL('../../scripts/build-html.js', import.meta.url), 'utf8'); -const deployWorkflow = readFileSync( - new URL('../../.github/workflows/deploy.yml', import.meta.url), - 'utf8' -); +const buildScript = readRootFile('scripts/build-html.js'); +const deployWorkflow = readRootFile('.github/workflows/deploy.yml'); describe('reproducible build and deployment gate', () => { it('pins the package manager and matching Vitest packages', () => { diff --git a/tests/e2e/ui-regressions.spec.ts b/tests/e2e/ui-regressions.spec.ts index f41b04d..7f9a8da 100644 --- a/tests/e2e/ui-regressions.spec.ts +++ b/tests/e2e/ui-regressions.spec.ts @@ -128,9 +128,12 @@ test('Turnstile 跟随 Standard Light 和后续主题切换', async ({ page }) = .toEqual([{ id: 'widget-1', theme: 'light' }]); await page.evaluate(() => { - const selector = document.getElementById('theme-selector') as HTMLSelectElement; - selector.value = 'standard-dark'; - selector.dispatchEvent(new Event('change', { bubbles: true })); + const themeSelector = document.getElementById('theme-selector'); + if (!(themeSelector instanceof HTMLSelectElement)) { + throw new Error('theme-selector not found'); + } + themeSelector.value = 'standard-dark'; + themeSelector.dispatchEvent(new Event('change', { bubbles: true })); }); await expect diff --git a/tests/global-shim.d.ts b/tests/global-shim.d.ts new file mode 100644 index 0000000..2896ece --- /dev/null +++ b/tests/global-shim.d.ts @@ -0,0 +1,8 @@ +/** + * 仅为 DOM 配置的测试类型检查提供最小 shape:src/types.ts 的 Env 接口 + * 引用 Workers 全局类 DurableObjectNamespace。该全局类型来自 + * @cloudflare/workers-types,但它声明的基础 DOM 接口(如 Element.append) + * 与 DOM lib 合并冲突,不能在 DOM 配置中同时加载,故以最小声明替代。 + * Env 在测试中仅作类型引用、从不实例化,不影响检查真实性。 + */ +declare class DurableObjectNamespace {} diff --git a/tests/tsconfig.frontend.json b/tests/tsconfig.frontend.json new file mode 100644 index 0000000..01c7e6c --- /dev/null +++ b/tests/tsconfig.frontend.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "types": ["node", "vitest/globals"], + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["./*.test.ts", "./e2e/**/*.ts", "./global-shim.d.ts"], + "exclude": ["node_modules", "dist", "../frontend"] +} diff --git a/tests/tsconfig.worker.json b/tests/tsconfig.worker.json new file mode 100644 index 0000000..a79b781 --- /dev/null +++ b/tests/tsconfig.worker.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "types": ["@cloudflare/workers-types", "node", "vitest/globals"], + "lib": ["ES2022"] + }, + "include": ["./worker/**/*.ts", "./ssh/**/*.ts", "./build/**/*.ts"], + "exclude": ["node_modules", "dist", "../frontend"] +} diff --git a/tests/worker/agent-tool-executor.test.ts b/tests/worker/agent-tool-executor.test.ts index 15f4e3b..a524f30 100644 --- a/tests/worker/agent-tool-executor.test.ts +++ b/tests/worker/agent-tool-executor.test.ts @@ -17,7 +17,9 @@ function makeExecutor(execResult: { stderr: string; exitCode: number; }) { - const execCommand = vi.fn(async () => execResult); + const execCommand = vi.fn( + async (command: string): Promise => execResult + ); const askConfirmation = vi.fn(async () => true); const executor = new ToolExecutor( new TerminalContext(), diff --git a/tests/worker/os-detect-session.test.ts b/tests/worker/os-detect-session.test.ts index fb5703e..e820cc5 100644 --- a/tests/worker/os-detect-session.test.ts +++ b/tests/worker/os-detect-session.test.ts @@ -3,7 +3,10 @@ import { SSHSession } from '../../src/worker/ssh-session'; function createSession(stdout: string) { const send = vi.fn(); - const fetch = vi.fn(async () => Response.json({ success: true })); + const fetch = vi.fn( + async (input: string | URL | Request, init?: RequestInit): Promise => + Response.json({ success: true }) + ); const env = { USER_DB: { idFromName: vi.fn(() => 'user-db-id'), diff --git a/tests/worker/security.test.ts b/tests/worker/security.test.ts index 67c1f60..073f075 100644 --- a/tests/worker/security.test.ts +++ b/tests/worker/security.test.ts @@ -290,7 +290,9 @@ describe('安全 — 强制 GitHub 登录模式', () => { REQUIRE_GITHUB_AUTH: value, }) ); - expect((await res.json()).githubAuthRequired).toBe(expected); + expect( + ((await res.json()) as { githubAuthRequired?: boolean }).githubAuthRequired + ).toBe(expected); }); it('强制登录时拒绝匿名 SSH WebSocket', async () => { @@ -408,7 +410,6 @@ describe('安全 — 越权防护(IDOR)', () => { } // DO 收到 PUT /internal/servers/:id,检查 belong,属于他人 → 403 if (req.url.match(/\/internal\/servers\/\d+$/) && req.method === 'PUT') { - const body = await req.json(); // 模拟:服务器 record.user_id=2 !== body.user_id=1 return new Response(JSON.stringify({ error: 'Server does not belong to user' }), { status: 403, @@ -623,7 +624,7 @@ describe('安全 — SSRF 接缝(AI base_url)', () => { expect(res.status).toBe(400); const data = await res.json(); - expect(data.error).toBeTruthy(); // validateBaseUrl 返回的中文 reason + expect((data as { error?: string }).error).toBeTruthy(); // validateBaseUrl 返回的中文 reason // 不应到达 DO 持久化 expect(env.USER_DB.get({} as any).fetch).not.toHaveBeenCalledWith( expect.objectContaining({ url: expect.stringContaining('/internal/ai-config') }) @@ -971,7 +972,7 @@ describe('安全 — 一次性 token 与接缝鉴权', () => { expect(res.status).toBe(403); const data = await res.json(); - expect(data.error).toMatch(/token|无效|expired/i); + expect((data as { error?: string }).error).toMatch(/token|无效|expired/i); }); it('SFTP attach 缺 session 参数 → 403 Missing SFTP attach token', async () => { @@ -990,7 +991,7 @@ describe('安全 — 一次性 token 与接缝鉴权', () => { expect(res.status).toBe(403); const data = await res.json(); - expect(data.error).toMatch(/token|missing/i); + expect((data as { error?: string }).error).toMatch(/token|missing/i); }); }); @@ -998,13 +999,17 @@ describe('安全 — 一次性 SSH 分享边界', () => { it('分享功能默认关闭,仅显式配置 true 时公开', async () => { const worker = await loadWorker(); const disabled = await worker.fetch(makeRequest('/api/config'), makeEnv()); - expect((await disabled.json()).sshSharingEnabled).toBe(false); + expect(((await disabled.json()) as { sshSharingEnabled?: boolean }).sshSharingEnabled).toBe( + false + ); const enabled = await worker.fetch( makeRequest('/api/config'), makeEnv({ ENABLE_SSH_SHARING: 'true' }) ); - expect((await enabled.json()).sshSharingEnabled).toBe(true); + expect(((await enabled.json()) as { sshSharingEnabled?: boolean }).sshSharingEnabled).toBe( + true + ); }); it('公开领取接口对非法 JSON 和非 URL-safe 凭证返回 400', async () => { diff --git a/tests/worker/session-reattach.test.ts b/tests/worker/session-reattach.test.ts index 37433b2..0feeb8d 100644 --- a/tests/worker/session-reattach.test.ts +++ b/tests/worker/session-reattach.test.ts @@ -30,7 +30,8 @@ if (typeof (globalThis as any).WebSocketPair === 'undefined') { const OriginalResponse = globalThis.Response; class MockResponse extends OriginalResponse { private _mockStatus: number; - public webSocket?: any; + // 基类 Response 要求 webSocket 为必填只读属性;此 Mock 在 101 升级时赋值。 + public webSocket: any; constructor(body?: any, init?: any) { if (init && init.status === 101) { @@ -91,7 +92,7 @@ describe('SSHSession Re-attach & Backpressure Flow Control', () => { const internal = session as any; internal.state = 'ready'; - const channel = new SSHChannel(0, 1000, 1024 * 1024, 32768); + const channel = new SSHChannel(); internal.shellChannel = channel; internal.channels.set(0, channel); diff --git a/tests/worker/sftp-upload-conflict.test.ts b/tests/worker/sftp-upload-conflict.test.ts index 0068f38..a523cbc 100644 --- a/tests/worker/sftp-upload-conflict.test.ts +++ b/tests/worker/sftp-upload-conflict.test.ts @@ -1,4 +1,6 @@ import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { SSHChannel } from '../../src/ssh/channel'; import { @@ -37,10 +39,8 @@ function createHandler(sftpOverrides: Record) { describe('SFTP 同名上传保护', () => { it('SSH 会话仅接受布尔 true 作为显式覆盖授权', () => { - const source = readFileSync( - new URL('../../src/worker/ssh-session.ts', import.meta.url), - 'utf8' - ); + const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const source = readFileSync(join(rootDir, 'src/worker/ssh-session.ts'), 'utf8'); expect(source).toContain( 'this.sftpHandler.uploadStart(msg.path, msg.size || 0, msg.overwrite === true)' ); diff --git a/tests/worker/share-audit-purge.test.ts b/tests/worker/share-audit-purge.test.ts new file mode 100644 index 0000000..cb76e3b --- /dev/null +++ b/tests/worker/share-audit-purge.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SSHShareDO } from '../../src/worker/share-do'; +import { UserDBDO } from '../../src/worker/user-db'; + +// ==================== SSHShareDO 侧:内存伪 SQL ==================== + +interface FakeAuditEvent { + id: number; + occurred_at: number; + event_type: string; + details: string; + byte_size: number; +} + +function seedShare(overrides: Record = {}): Record { + return { + singleton: 1, + share_id: 'share-1', + token_hash: 'token-hash', + owner_user_id: 7, + owner_github_id: 'gh-7', + server_id: 1, + server_name: 'srv-1', + expires_at: Date.now() - 60_000, + max_session_seconds: 600, + status: 'closed', + claimed_at: 1000, + active_at: 2000, + closed_at: 3000, + session_expires_at: null, + session_name: null, + ticket_hash: null, + ticket_expires_at: null, + audit_bytes: 500, + device_pub_key: null, + audit_purge_due: null, + audit_retention_days: 30, + ...overrides, + }; +} + +class ShareFakeSql { + share: Record | null; + events: FakeAuditEvent[]; + calls: string[] = []; + private nextId = 1; + + constructor(share: Record | null, events: FakeAuditEvent[] = []) { + this.share = share ? { ...share } : null; + this.events = events.map((event) => ({ ...event })); + } + + exec(sql: string, ...values: unknown[]): { toArray: () => unknown[]; one: () => unknown } { + this.calls.push(sql); + const tableRead = { + toArray: () => [] as unknown[], + one: () => ({}) as unknown, + }; + + if (sql.includes('SELECT * FROM share_state WHERE singleton = 1')) { + return { toArray: () => (this.share ? [{ ...this.share }] : []), one: () => ({}) }; + } + if (sql.includes('SELECT COUNT(*) AS count FROM audit_events')) { + return { toArray: () => [], one: () => ({ count: this.events.length }) }; + } + if (sql.includes('event_type NOT IN')) { + const after = Number(values[0]); + const limit = Number(values[1]); + const rows = this.events + .filter( + (event) => + event.id > after && + event.event_type !== 'share.audit_purged' && + event.event_type !== 'share.audit_auto_purged' + ) + .slice(0, limit); + return { toArray: () => rows, one: () => ({}) }; + } + if (sql.includes('WHERE event_type IN')) { + const rows = this.events.filter( + (event) => + event.event_type === 'share.audit_purged' || + event.event_type === 'share.audit_auto_purged' + ); + return { toArray: () => rows, one: () => ({}) }; + } + if (sql.includes('DELETE FROM audit_events')) { + this.events = []; + return tableRead; + } + if (sql.includes('UPDATE share_state SET audit_bytes = 0')) { + if (this.share) this.share.audit_bytes = 0; + return tableRead; + } + if (sql.includes('UPDATE share_state SET audit_bytes = audit_bytes +')) { + if (this.share) this.share.audit_bytes = Number(this.share.audit_bytes ?? 0) + Number(values[0]); + return tableRead; + } + if (sql.includes('UPDATE share_state SET audit_purge_due = NULL')) { + if (this.share) this.share.audit_purge_due = null; + return tableRead; + } + if (sql.includes('UPDATE share_state SET audit_purge_due = ?')) { + if (this.share) this.share.audit_purge_due = Number(values[0]); + return tableRead; + } + if (sql.includes('UPDATE share_state SET status = ?')) { + if (this.share) { + this.share.status = String(values[0]); + this.share.closed_at = Number(values[1]); + } + return tableRead; + } + if (sql.includes('INSERT INTO audit_events')) { + this.events.push({ + id: this.nextId++, + occurred_at: Number(values[0]), + event_type: String(values[1]), + details: String(values[2]), + byte_size: Number(values[3]), + }); + return tableRead; + } + if (sql.startsWith('CREATE') || sql.startsWith('ALTER')) { + // 建表/迁移语句:伪 SQL 中直接忽略 + return tableRead; + } + return tableRead; + } +} + +function makeShareDo(options: { + share: Record | null; + events?: FakeAuditEvent[]; + userDbFetch?: (request: Request) => Promise | Response; + deleteAlarm?: () => Promise; +}): { + doInstance: SSHShareDO; + fakeSql: ShareFakeSql; + userDbFetch: ReturnType; + deleteAlarm: ReturnType; +} { + const fakeSql = new ShareFakeSql(options.share, options.events ?? []); + const userDbFetch = vi.fn( + options.userDbFetch ?? (async () => new Response('{}', { status: 200 }) as Response) + ); + const deleteAlarm = vi.fn(options.deleteAlarm ?? (async () => {})); + const state = { + storage: { + sql: fakeSql, + setAlarm: vi.fn(async () => {}), + deleteAlarm, + }, + }; + const env = { + USER_DB: { + get: vi.fn(() => ({ fetch: userDbFetch })), + idFromName: vi.fn((name: string) => name), + }, + }; + const doInstance = new SSHShareDO(state as unknown as DurableObjectState, env as never); + return { doInstance, fakeSql, userDbFetch, deleteAlarm }; +} + +function ownerViewRequest(ownerUserId: number): Request { + return new Request( + `http://internal/internal/owner-view?owner_user_id=${ownerUserId}&after=0&limit=500`, + { method: 'GET' } + ); +} + +function purgeRequest(ownerUserId: number): Request { + return new Request('http://internal/internal/audit/purge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ownerUserId }), + }); +} + +async function readOwnerView(response: Response): Promise<{ + share: { auditBytes: number; status: string }; + events: Array<{ eventType: string }>; + removals: Array<{ occurredAt: number; eventType: string }>; +}> { + return (await response.json()) as { + share: { auditBytes: number; status: string }; + events: Array<{ eventType: string }>; + removals: Array<{ occurredAt: number; eventType: string }>; + }; +} + +describe('SSHShareDO 审计清理留痕', () => { + const sampleEvents: FakeAuditEvent[] = [ + { id: 1, occurred_at: 1000, event_type: 'share.claimed', details: '{}', byte_size: 2 }, + { id: 2, occurred_at: 2000, event_type: 'session.connecting', details: '{}', byte_size: 2 }, + { id: 3, occurred_at: 3000, event_type: 'terminal.output', details: '{}', byte_size: 2 }, + ]; + + it('手动清空后:墓碑不进常规事件列表,removals 返回 1 条,audit_bytes 归零', async () => { + const { doInstance } = makeShareDo({ share: seedShare(), events: sampleEvents }); + const purge = await doInstance.fetch(purgeRequest(7)); + expect(purge.status).toBe(200); + + const view = await readOwnerView(await doInstance.fetch(ownerViewRequest(7))); + // 清理后 audit_bytes 归零后仅剩墓碑本身('{}' 恰为 2 字节) + expect(view.share.auditBytes).toBe(2); + expect(view.events).toHaveLength(0); + expect(view.events.some((event) => event.eventType === 'share.audit_purged')).toBe(false); + expect(view.removals).toHaveLength(1); + expect(view.removals[0].eventType).toBe('share.audit_purged'); + }); + + it('手动清空失败时不产生任何清理留痕(归属校验 403、非终态 409)', async () => { + const { doInstance } = makeShareDo({ share: seedShare(), events: sampleEvents }); + expect((await doInstance.fetch(purgeRequest(8))).status).toBe(403); + expect((await doInstance.fetch(purgeRequest(7))).status).toBe(200); + + const unused = makeShareDo({ + share: seedShare({ status: 'unused', server_name: 'srv-x' }), + events: [], + }); + expect((await unused.doInstance.fetch(purgeRequest(7))).status).toBe(409); + }); + + it('部署窗口期 UserDBDO 同步失败不影响清理成功(尽力而为)', async () => { + const { doInstance } = makeShareDo({ + share: seedShare(), + events: sampleEvents, + userDbFetch: async () => { + throw new Error('old UserDBDO: route not found'); + }, + }); + const purge = await doInstance.fetch(purgeRequest(7)); + expect(purge.status).toBe(200); + const view = await readOwnerView(await doInstance.fetch(ownerViewRequest(7))); + expect(view.removals).toHaveLength(1); + expect(view.removals[0].eventType).toBe('share.audit_purged'); + }); + + it('到期自动清理:走 alarm 路径、写 auto 墓碑、清空 purge_due 并同步 auto 留痕', async () => { + const { doInstance, userDbFetch } = makeShareDo({ + share: seedShare({ audit_purge_due: Date.now() - 1000 }), + events: sampleEvents, + }); + await doInstance.alarm(); + + const view = await readOwnerView(await doInstance.fetch(ownerViewRequest(7))); + expect(view.removals).toHaveLength(1); + expect(view.removals[0].eventType).toBe('share.audit_auto_purged'); + // 清除后仅剩自动清理墓碑自身的字节数 + expect(view.share.auditBytes).toBe(2); + + // notifyOwnerAuditPurged 应以 auto 方式同步到 UserDBDO + expect(userDbFetch).toHaveBeenCalledTimes(1); + const [callRequest] = userDbFetch.mock.calls[0] as [Request]; + expect(callRequest.url).toContain('/internal/shares/share-1/audit-purged'); + const callBody = JSON.parse(await callRequest.text()) as { purge_type: string }; + expect(callBody.purge_type).toBe('auto'); + }); + + it('updateStatus 排期闹钟失败时回滚 audit_purge_due(修复幽灵状态)', async () => { + const { doInstance, fakeSql } = makeShareDo({ + share: seedShare({ status: 'claimed', session_expires_at: 1, audit_retention_days: 30 }), + events: sampleEvents, + }); + // setAlarm 拒绝:模拟闹钟设置静默失败 + const state = (doInstance as unknown as { state: { storage: { setAlarm: ReturnType } } }) + .state; + state.storage.setAlarm = vi.fn(async () => { + throw new Error('alarm quota exceeded'); + }); + const res = await doInstance.fetch( + new Request('http://internal/internal/session/closed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ normal: true }), + }) + ); + expect(res.status).toBe(200); + // 失败必须回滚:库里不留“有排期但无闹钟”的幽灵状态 + expect(fakeSql.share?.status).toBe('closed'); + expect(fakeSql.share?.audit_purge_due).toBeNull(); + }); +}); + +// ==================== UserDBDO 侧 ==================== + +class UserDbFakeSql { + shareColumns: string[]; + serverColumns = ['region', 'inferred_hint', 'tags', 'os', 'jump_server_id']; + shareRows: Array<{ user_id: number }>; + updates: Array<{ sql: string; values: unknown[] }> = []; + + constructor(shareColumns: string[], shareRows: Array<{ user_id: number }> = []) { + this.shareColumns = shareColumns; + this.shareRows = shareRows; + } + + exec(sql: string, ...values: unknown[]): { toArray: () => unknown[]; one: () => unknown } { + if (sql.includes('PRAGMA table_info(servers)')) { + return { toArray: () => this.serverColumns.map((name) => ({ name })), one: () => ({}) }; + } + if (sql.includes('PRAGMA table_info(ssh_shares)')) { + return { toArray: () => this.shareColumns.map((name) => ({ name })), one: () => ({}) }; + } + if (sql === 'SELECT user_id FROM ssh_shares WHERE id = ?') { + return { toArray: () => this.shareRows, one: () => ({}) }; + } + if (sql.startsWith('ALTER TABLE ssh_shares')) { + this.updates.push({ sql, values }); + return { toArray: () => [], one: () => ({}) }; + } + if (sql.startsWith('UPDATE ssh_shares SET audit_purged_at')) { + this.updates.push({ sql, values }); + return { toArray: () => [], one: () => ({}) }; + } + return { toArray: () => [], one: () => ({}) }; + } +} + +function makeUserDb(sql: UserDbFakeSql): UserDBDO { + return new UserDBDO( + { storage: { sql } } as unknown as DurableObjectState, + {} as never + ); +} + +function auditPurgedRequest(body: unknown): Request { + return new Request('http://internal/internal/shares/share-1/audit-purged', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('UserDBDO 审计清理留痕接缝', () => { + it('同步留痕:归属匹配时更新 audit_purged_at/audit_purge_type', async () => { + const sql = new UserDbFakeSql( + ['id', 'user_id', 'audit_purged_at', 'audit_purge_type'], + [{ user_id: 7 }] + ); + const db = makeUserDb(sql); + const res = await db.fetch( + auditPurgedRequest({ user_id: 7, purged_at: 123456, purge_type: 'manual' }) + ); + expect(res.status).toBe(200); + expect(sql.updates).toHaveLength(1); + expect(sql.updates[0].values).toEqual([123456, 'manual', 'share-1']); + }); + + it('归属不匹配拒绝写入(403),非法 purge_type 拒绝(400)', async () => { + const sql = new UserDbFakeSql( + ['id', 'user_id', 'audit_purged_at', 'audit_purge_type'], + [{ user_id: 7 }] + ); + const db = makeUserDb(sql); + expect((await db.fetch(auditPurgedRequest({ user_id: 8, purged_at: 1, purge_type: 'manual' }))).status).toBe(403); + expect((await db.fetch(auditPurgedRequest({ user_id: 7, purged_at: 1, purge_type: 'manual' }))).status).toBe(200); + const sql2 = new UserDbFakeSql(['id', 'user_id'], [{ user_id: 7 }]); + const db2 = makeUserDb(sql2); + expect((await db2.fetch(auditPurgedRequest({ user_id: 7, purged_at: 1, purge_type: 'weird' }))).status).toBe(400); + expect((await db2.fetch(auditPurgedRequest({ user_id: '7', purged_at: 1, purge_type: 'manual' }))).status).toBe(400); + expect((await db2.fetch(auditPurgedRequest({ user_id: 7, purged_at: Number.NaN, purge_type: 'manual' }))).status).toBe(400); + }); + + it('ssh_shares audit 留痕迁移幂等:缺列时补列,已有时不再 ALTER', async () => { + const oldSql = new UserDbFakeSql(['id', 'user_id'], []); + makeUserDb(oldSql); + const alterCount = oldSql.updates.filter((update) => update.sql.startsWith('ALTER')).length; + expect(alterCount).toBe(2); // audit_purged_at + audit_purge_type + + const newSql = new UserDbFakeSql(['id', 'user_id', 'audit_purged_at', 'audit_purge_type'], []); + makeUserDb(newSql); + expect(newSql.updates.filter((update) => update.sql.startsWith('ALTER')).length).toBe(0); + }); +}); diff --git a/tests/worker/share-resume-security.test.ts b/tests/worker/share-resume-security.test.ts index 20b2eea..fe05b70 100644 --- a/tests/worker/share-resume-security.test.ts +++ b/tests/worker/share-resume-security.test.ts @@ -10,8 +10,8 @@ const globalShim = globalThis as any; if (!globalShim.__cloudsshWsTestShimInstalled) { globalShim.__cloudsshWsTestShimInstalled = true; - if (typeof globalThis.WebSocketPair === 'undefined') { - globalThis.WebSocketPair = class { + if (typeof (globalThis as any).WebSocketPair === 'undefined') { + (globalThis as any).WebSocketPair = class { 0: any; 1: any; constructor() { @@ -29,7 +29,8 @@ if (!globalShim.__cloudsshWsTestShimInstalled) { const OriginalResponse = globalThis.Response; class MockResponse extends OriginalResponse { private _mockStatus: number; - public webSocket?: any; + // 基类 Response 要求 webSocket 为必填只读属性;此 Mock 在 101 升级时赋值。 + public webSocket: any; constructor(body?: any, init?: any) { if (init && init.status === 101) { @@ -70,7 +71,9 @@ async function makeDeviceKeyPair(): Promise { 'sign', 'verify', ])) as CryptoKeyPair; - const spki = new Uint8Array(await crypto.subtle.exportKey('spki', kp.publicKey)); + const spki = new Uint8Array( + (await crypto.subtle.exportKey('spki', kp.publicKey)) as ArrayBuffer + ); return { privateKey: kp.privateKey as CryptoKey, publicKeyB64: toBase64Url(spki) }; }