diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml new file mode 100644 index 0000000..16a099b --- /dev/null +++ b/.github/workflows/deploy-preview.yml @@ -0,0 +1,14 @@ +name: 部署预览 Worker + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + uses: ./.github/workflows/deploy.yml + with: + deploy_preview: true + secrets: inherit diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6c7836b..7a4f8b4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,6 +20,11 @@ on: description: 需要检出并部署的提交 SHA required: false type: string + deploy_preview: + description: 是否同时部署预览 Worker + required: false + default: false + type: boolean concurrency: group: production-deploy @@ -49,6 +54,7 @@ jobs: # 域名本身不是敏感信息,推荐使用 Variable;同时兼容已将它保存为 Secret 的仓库。 CUSTOM_DOMAIN: ${{ secrets.CUSTOM_DOMAIN || vars.CUSTOM_DOMAIN }} PREVIEW_DOMAIN: ${{ vars.PREVIEW_DOMAIN }} + DEPLOY_PREVIEW_WORKER: ${{ inputs.deploy_preview && 'true' || 'false' }} steps: - name: Check out repository uses: actions/checkout@v4 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index f69c864..aafce36 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -11,7 +11,7 @@ | `AUTH_PROVIDER` | Variable | `cloudflare`(默认) | `github` | | `CLOUDFLARE_API_TOKEN` | Secret | 必填 | 必填 | | `CUSTOM_DOMAIN` | Variable | 推荐填写实际主机名 | 推荐填写实际主机名 | -| `PREVIEW_DOMAIN` | Variable | 仅填预览 hostname;留空自动生成 | 仅填预览 hostname;留空自动生成 | +| `PREVIEW_DOMAIN` | Variable | 可选;仅部署独立预览 Worker 时使用 | 可选;仅部署独立预览 Worker 时使用 | | `ADMIN_EMAIL` | Variable | 管理员邮箱 | 不需要 | | `GITHUB_CLIENT_ID` | Variable | 不需要 | OAuth App 的 Client ID | | `GITHUB_CLIENT_SECRET` | Secret | 不需要 | OAuth App 的 Client Secret | @@ -65,16 +65,20 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入 ## 独立预览 Worker 与端口转发 -远端网站可能已被入侵并返回恶意 JavaScript。同源代理会让它代发主站 SSH API;`HttpOnly` 只能保护 Cookie,挡不住同源脚本调用接口。独立 Worker 本身不代表不同 site:若仍在同一 site,主站 Cookie 仍有风险,因此必须使用跨 site 的预览域名。 +| 模式 | 默认入口 | 适用场景 | 部署要求 | +| --- | --- | --- | --- | +| `trusted` | 主 Worker `/_forward//` | 你信任的目标网站 | 无需第二 Worker;默认模式 | +| `isolated` | 独立预览 Worker | 不可信或可能被入侵的目标网站 | Actions 运行 `部署预览 Worker`,预览域名必须跨 site | -- 部署先发布主 Worker,再发布只绑定 `SSH_SESSIONS` 的预览 Worker;预览不绑定 D1、ASSETS、加密密钥或主站接口。 -- `PREVIEW_DOMAIN` 是 Actions Variable,只填 hostname。未配置时默认 `-preview.<账户子域>.workers.dev`。 -- `ssh.example.com` + 默认 `workers.dev` 可行;`ssh.example.com` + `preview.example.com` 会拒绝;同一账户下两个 `workers.dev` 也会拒绝。主站只有 `workers.dev` 时,必须为预览配置独立的自定义域名。 -- 运行时 `PREVIEW_ORIGIN` 由部署脚本生成并写入两个 Worker,用户不需要手工修改主配置。 +同源路径、HttpOnly Cookie 和新窗口不是沙箱;恶意脚本仍可代发主站 SSH API。主站 Cookie 不会转发给远端,但不防同源 JavaScript 调用接口。界面会显示风险警告并要求信任确认;连接期间禁用切换,用户需先点击停止再切换。未部署预览 Worker 时选择 isolated 不会回退到标准转发,而是明确拒绝连接。 -使用主机管理页选择主机和端口(例如 `127.0.0.1` HTTP),确认指纹后打开新预览窗口;管理页保留在原窗口。停止转发、离开预览页或 SSH 断线后,预览失效。数据流为:主站登录 -> SSH Durable Object -> 一次性 fragment -> 预览 HttpOnly capability -> 远端 HTTP。票据 60 秒内只能兑换一次;兑换后的授权最长 1 小时,二者不是同一时限。 +普通 push 或 `Deploy` 只发布主 Worker,不新建 preview。需要 isolated 时,运行 Actions **部署预览 Worker**,该 workflow 先更新主 Worker 配置,再调用原可复用 Deploy 并传入 `deploy_preview=true`,仅该流程设置 `DEPLOY_PREVIEW_WORKER`;可选填写 `PREVIEW_DOMAIN`,留空默认 `-preview.<账户子域>.workers.dev`。它发布只绑定 `SSH_SESSIONS` 的预览 Worker,不绑定 D1、ASSETS、加密密钥或主站接口,并设置主站 `PREVIEW_ORIGIN`。已有 `PREVIEW_ORIGIN` 在常规发布中保留,不自动删除;普通发布不会更新预览代码,修改预览实现时须重新运行该 workflow,现有预览 Worker 无需重新部署即可在界面切换。 -预览支持 HTTP/SSE、相对资源、表单、目标 Cookie、重定向和 HTTP Basic 鉴权;仅支持 HTTP 上游 `127.0.0.1`。上传上限 16 MiB,不改变 SFTP 的 64 MiB 限制;最多 24 个并发通道;60 秒是请求通道闲置超时,不是整个 SSH 连接的 60 秒寿命。不支持 HTTPS 上游、WebSocket、Service Worker、JavaScript 写死 `localhost` 或 OAuth 固定 callback。同一预览 origin 内的网站不相互隔离,切换前关闭旧窗口,只用于一个网站且不要分享。主站跨 site 只能防止登录态被预览站点盗取,不代表预览网站本身安全。 +主站与预览必须跨 site:自定义域名加默认 `workers.dev` 可行;自定义域名加同站自定义域名会拒绝;同账户双 `workers.dev` 也会拒绝,主站只有 `workers.dev` 时需独立自定义域名。专用 preview 同一 origin 内不同目标网站不相互隔离,切换前关闭旧预览窗口。 + +操作步骤:在端口转发页面选择主机和端口转发类型(如 `127.0.0.1` HTTP)→ 选择 `trusted` 或 `isolated`(trusted 需勾选界面信任确认,isolated 不需要)→ 确认指纹并连接。连接期间禁用模式切换,需先点击停止再切换。停止主站连接、离开主站端口转发管理页、刷新页面或 SSH 断线后预览失效;仅关闭目标预览 tab 不保证停止。isolated 票据 60 秒内只能兑换一次,兑换后的授权最长 1 小时;trusted 链接依赖主站登录及账户绑定,不使用一次性 fragment。 + +标准实现改写 HTML 属性、`srcset`、CSS URL、`Location`、Cookie 名称与 Path,并注入常见 `fetch`/XHR/EventSource/history/cookie 兼容脚本;不承诺任意网站透明代理。严格 CSP、动态 ES 模块、写死的 location、复杂 inline CSS/JS 框架仍可能需要 baseURL 配置,优先使用 isolated。仅支持 HTTP/SSE、相对资源、表单、目标 Cookie、重定向和 HTTP Basic 鉴权;HTTP 上游限 `127.0.0.1`,上传 16 MiB,CSS 重写 2 MiB,最多 24 个并发通道,通道闲置 60 秒。HTTPS 上游、WebSocket、Service Worker、写死 `localhost`、OAuth 固定 callback 不支持;SFTP 上传仍为 64 MiB。 ## 自动执行顺序 @@ -90,7 +94,7 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入 生产任务通过 concurrency 串行运行,失败可修正原因后重跑,已创建的资源会复用。请勿用多个仓库同时管理同一个 Worker。 -设置自定义域名时关闭备用 `workers.dev` 入口,所有部署关闭 preview URL。Cloudflare 模式校验 Access JWT;GitHub 模式使用 state、PKCE 和签名 HttpOnly Cookie。缺少认证不降级为匿名 SSH。 +设置自定义域名时关闭备用 `workers.dev` 入口;两个 Worker 都关闭 Cloudflare 版本预览 URL;独立 workflow 发布专用 preview Worker 的正式地址。Cloudflare 模式校验 Access JWT;GitHub 模式使用 state、PKCE 和签名 HttpOnly Cookie。缺少认证不降级为匿名 SSH。 ## 官方强制更新 @@ -115,7 +119,7 @@ EdgeSSH-Auto-Update: true | `D1_DATABASE_ID` | Variable | 指定已有 D1 UUID,不填则按名称查找 | | `ACCESS_IDP_IDS` | Variable | 新应用采用的 IdP UUID,多个用逗号分隔 | | `GITHUB_ADMIN_ID` | Variable | 仅显式更换 GitHub 管理员时填写数字用户 ID | -| `PREVIEW_DOMAIN` | Variable | 预览 hostname;留空为 `-preview.<账户子域>.workers.dev` | +| `PREVIEW_DOMAIN` | Variable | 仅 `部署预览 Worker` 使用;留空为 `-preview.<账户子域>.workers.dev` | | `ENCRYPTION_KEY` | Secret,仅恢复/迁移使用 | 仅 Worker 尚无密钥时使用;已有密钥不会覆盖 | `DB`、`SSH_SESSIONS`、`ASSETS` 是资源绑定,不是需要用户创建的变量。`CONNECT_TIMEOUT_MS` 已有默认值 `10000`。 diff --git a/README.md b/README.md index 76c7304..f873112 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ SSH 握手、密钥交换、认证与通道逻辑在 Worker 内完成。浏览 | `AUTH_PROVIDER` | Variable | `cloudflare` | `github` | | `CLOUDFLARE_API_TOKEN` | Secret | 必填 | 必填,不需要 Access 权限 | | `CUSTOM_DOMAIN` | Variable | 推荐,如 `ssh.example.com` | 推荐,如 `ssh.example.com` | -| `PREVIEW_DOMAIN` | Variable | 预览入口 hostname;仅填域名 | 预览入口 hostname;仅填域名 | +| `PREVIEW_DOMAIN` | Variable | 可选;仅在部署独立预览 Worker 时填写 hostname | 可选;仅在部署独立预览 Worker 时填写 hostname | | `ADMIN_EMAIL` | Variable | 管理员邮箱,也可在 Run workflow 输入 | 不填 | | `GITHUB_CLIENT_ID` | Variable | 不填 | OAuth App 的 Client ID | | `GITHUB_CLIENT_SECRET` | Secret | 不填 | OAuth App 的 Client Secret | @@ -162,11 +162,11 @@ npm ci | `D1_DATABASE_ID` | `00000000-0000-4000-8000-000000000001` | 复用明确指定的数据库 | | `ACCESS_IDP_IDS` | `一个或多个 IdP UUID,以逗号分隔` | 创建新应用时使用已有 GitHub/其他 IdP,而非自动配置 OTP | | `GITHUB_ADMIN_ID` | `12345678` | 仅在明确更换 GitHub 管理员时填写 | -| `PREVIEW_DOMAIN` | `preview.example.com` | 预览入口 hostname;留空自动生成 `-preview.<账户 workers.dev 子域>` | +| `PREVIEW_DOMAIN` | `preview.example.com` | 可选;仅供 `部署预览 Worker` 使用,留空自动生成 `-preview.<账户 workers.dev 子域>.workers.dev` | `ENCRYPTION_KEY` 由部署流程管理并持久保存在 **Cloudflare Worker Secrets**;Cloudflare 模式另存 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`,GitHub 模式同步 `GITHUB_CLIENT_SECRET`。不需要用户复制自动生成的值回 GitHub。加密密钥只在首次部署生成,后续保留,即使 GitHub 留有旧值也不会覆盖线上密钥。不要删除 Worker 或其加密密钥;Cloudflare 不提供密钥明文读回,丢失后无法解密已有资料。 -推送 `main` 或手动运行 `Deploy` 都会执行检查和部署。只在首次 Run workflow 输入邮箱也可以:后续未提供邮箱时保留已有认证配置。需要更换域名或重新自动配置 Access 时,请再次提供邮箱。 +推送 `main` 或手动运行 `Deploy` 都会执行检查并只部署主 Worker,不会自动新建预览 Worker。需要代理不可信网站时,再从 Actions 手动运行 **部署预览 Worker**;它调用同一套可复用部署逻辑并设置 `deploy_preview=true`,只在该工作流临时设置 `DEPLOY_PREVIEW_WORKER`。只在首次 Run workflow 输入邮箱也可以:后续未提供邮箱时保留已有认证配置。需要更换域名或重新自动配置 Access 时,请再次提供邮箱。 Fork 启用 Actions 后,`Force Update` 每小时检查一次官方 `aozorae/EdgeSSH` 的 `main`。只有当前版本与官方最新版本之间出现带有独立 Git trailer `EdgeSSH-Auto-Update: true` 的提交时,工作流才会将 Fork 的 `main` 精确同步到最新一个标记提交,并在同一次运行中直接部署该 SHA;普通提交不会触发同步。也可从 Actions 页面手动运行检查。该流程会覆盖 Fork 在 `main` 上的自定义提交,自定义开发请保留在其他分支。 @@ -219,13 +219,17 @@ EdgeSSH/ ### 独立预览 Worker -远端网站可能已被入侵并返回恶意 JavaScript。若代理与主站同源,脚本就能代发主站 SSH 管理 API;HttpOnly 只能保护 Cookie,不能阻止同源脚本调用接口。部署会先发布主 Worker,再发布只绑定 `SSH_SESSIONS` 的预览 Worker;预览不持有 D1、ASSETS 或任何 Secret。不同 Worker 不必然代表不同 site:普通域名比较末两段,`workers.dev` 比较末三段;同账户双 workers.dev 会明确拒绝,跨 site 隔离是保护主站会话边界的必要成本。 +默认端口转发路径为主 Worker 的 `/_forward//`,界面默认 **trusted**,只应连接你信任的网站。页面会显示醒目风险警告,并要求勾选信任确认;连接期间禁用 trusted/isolated 切换,用户需先点击停止再切换。路径同源、HttpOnly Cookie 和新窗口都不是沙箱:恶意脚本仍可能代发主站 SSH API;主站 Cookie 不会转发给远端,但这不能防止同源 JavaScript 调用主站接口。 -凭据只在 URL fragment 中短暂传递,随后立即换成 HttpOnly Cookie;票据一次性使用,断线或 1 小时后失效。支持常规资源、表单、目标 Cookie、Location 跳转和 HTTP Basic 鉴权。首版仅支持 HTTP-only 回环 `127.0.0.1`,上传上限 16 MiB、最多 24 个并发 SSH 通道、闲置 60 秒;不支持 HTTPS、WebSocket、Service Worker、硬编码 localhost 的 JavaScript 或 OAuth 固定回调。SFTP 文件上传仍是独立的 64 MiB 限制。同一预览 origin 内的网站不再相互隔离;切换前关闭旧预览,只用于一个网站,不能对外分享。 +需要不可信网站时,从 Actions 运行 **部署预览 Worker**(可选填写 `PREVIEW_DOMAIN`,默认 `-preview.<账户 workers.dev 子域>.workers.dev`)。该工作流先更新主 Worker 配置,再部署只绑定 `SSH_SESSIONS` 的独立 Worker,并设置主站 `PREVIEW_ORIGIN`;普通 push/`Deploy` 只发布主 Worker,已有 `PREVIEW_ORIGIN` 会保留,不会自动删除既有预览,普通发布也不会更新预览代码。现有预览 Worker 保留即可在界面切换;修改预览实现时须重新运行该工作流。运行后在界面选择 **isolated**;选择隔离模式但未部署预览 Worker 时不会回退到标准转发,而是明确拒绝连接。预览域名必须与主站跨 site:同账户双 `workers.dev` 会拒绝,主站仅用 `workers.dev` 时需独立自定义域名。专用预览同一 origin 内的不同目标网站不相互隔离,切换前关闭旧预览窗口。 + +isolated 模式凭据只在 URL fragment 中短暂传递,随后立即换成 HttpOnly Cookie;fragment 票据每 60 秒只能一次性兑换,断线或 1 小时后失效。trusted 链接依赖主站登录及账户绑定,不使用一次性 fragment。标准实现会改写 HTML 属性、`srcset`、CSS URL、`Location`、Cookie 名称与 Path,并注入常见 `fetch`/XHR/EventSource/history/cookie 兼容脚本;不承诺任意网站透明代理。严格 CSP、动态 ES 模块、写死的 location、复杂 inline CSS/JS 框架仍可能需要 baseURL 配置,优先使用独立隔离模式。 + +支持 HTTP/SSE、相对资源、表单、目标 Cookie、重定向和 HTTP Basic 鉴权;仅支持 HTTP 上游 `127.0.0.1`。上传上限 16 MiB,CSS 重写上限 2 MiB,最多 24 个并发 SSH 通道;授权最长 1 小时,单通道闲置 60 秒。不支持 HTTPS 上游、WebSocket、Service Worker、JavaScript 写死 `localhost` 或 OAuth 固定 callback。SFTP 文件上传仍是独立的 64 MiB 限制。 ### 端口转发 -在主机管理页选择主机和端口转发类型(例如 `127.0.0.1` 的 HTTP),确认 SSH 主机指纹后,系统会打开新的预览窗口。管理页会保留在原窗口;停止转发、离开预览页或 SSH 断线后,预览立即失效。预览票据只使用一次,不能分享给他人。 +在端口转发页面选择主机和端口转发类型(例如 `127.0.0.1` 的 HTTP),确认 SSH 主机指纹后,系统会打开新的预览窗口。管理页会保留在原窗口;用户停止主站连接、离开主站端口转发管理页、刷新页面或 SSH 断线后,预览立即失效;仅关闭目标预览 tab 不保证停止。预览票据只使用一次,不能分享给他人。 ### 安全边界 diff --git a/frontend/src/forward-page.css b/frontend/src/forward-page.css index 247c114..f4c1da0 100644 --- a/frontend/src/forward-page.css +++ b/frontend/src/forward-page.css @@ -1,6 +1,12 @@ body[data-view='forward'] { overflow-y: auto; background: var(--home-bg); } .forward-page { min-width: 0; width: 100%; max-width: 1240px; margin-inline: auto; padding: 36px 32px; } .forward-card { padding: 24px; border: 1px solid var(--home-line); border-radius: 10px; background: var(--home-surface); } +.forward-warning { border: 1px solid var(--home-accent); border-left-width: 4px; border-radius: 8px; padding: 18px 22px; margin-bottom: 20px; background: var(--home-accent-soft); } +.forward-warning p { margin: 8px 0 0; line-height: 1.8; } +.forward-mode-field, .forward-wide { grid-column: 1 / -1; } +#dashboard .forward-form .forward-trust { display: flex; align-items: flex-start; gap: 10px; font-size: 14px; line-height: 1.7; font-weight: 400; } +#dashboard .forward-form .forward-trust input { width: 18px; height: 18px; flex: 0 0 18px; margin-top: 3px; accent-color: var(--home-accent); } +.forward-setup { margin-top: 20px; border-top: 1px solid var(--home-line); padding-top: 18px; line-height: 1.8; } .forward-badge { padding: 6px 10px; border-radius: 5px; background: var(--home-accent-soft); color: var(--home-accent-hover); font-size: 12px; white-space: nowrap; } .forward-form { display: grid; grid-template-columns: minmax(180px, 1fr) 130px auto auto; align-items: end; gap: 16px; } .forward-form label { display: grid; gap: 8px; min-width: 0; font-weight: 600; } diff --git a/frontend/src/forward-page.ts b/frontend/src/forward-page.ts index 74ed06c..964e6cf 100644 --- a/frontend/src/forward-page.ts +++ b/frontend/src/forward-page.ts @@ -1,7 +1,7 @@ import { api, hostCredentials, type CloudHost } from './cloud-api'; import './forward-page.css'; -/** 转发持有独立 SSH 连接,不借用终端,防止预览页获得命令输入能力。 */ +/** 转发持有独立 SSH 连接;标准模式与主站同源,不能当作恶意脚本的安全边界。 */ export class ForwardPage { readonly root = document.createElement('main'); private hosts: CloudHost[] = []; @@ -18,6 +18,9 @@ export class ForwardPage { private readonly stopButton: HTMLButtonElement; private readonly openButton: HTMLButtonElement; private readonly keyDialog: HTMLDialogElement; + private readonly mode: HTMLSelectElement; + private readonly trust: HTMLInputElement; + private previewAvailable = false; private pendingKey?: { socket: WebSocket; fingerprint: string }; constructor() { @@ -26,24 +29,35 @@ export class ForwardPage { this.root.innerHTML = `

PRIVATE WEB PREVIEW

端口转发

通过 SSH,打开只监听在服务器本机的网站。

- 独立站点隔离
+ 标准路径转发 +
+ +

默认无需部署第二个 Worker。选择隔离预览可查看启用方法。

+

访问目标固定为所选服务器的 http://127.0.0.1:端口,不是你电脑的本机端口。

未连接 · 请选择主机和网站端口
- + +
-

让网站留在另一扇窗里

-

远端网站可能包含不可信脚本。预览在独立 Worker、独立站点打开,不共享 EdgeSSH 登录 Cookie,也不提供主机管理或终端接口。

+

可信网站简单用,不可信网站单独隔离

+

标准转发使用当前 Worker 的独立路径,不增加部署资源。隔离预览使用可选的独立 Worker 和跨站域名,不共享主站登录 Cookie。

  1. 选择云端主机,输入网站的 HTTP 端口。
  2. 核对 SSH 主机指纹,连接后自动打开预览。
  3. 用完点击停止;离开此页面、刷新或断线后,转发立即失效。
-

支持常见资源、表单、网站 Cookie、HTTP 登录和重定向。单次授权最长 1 小时,上传最多 16 MiB。首版不支持 HTTPS 上游、WebSocket、Service Worker,以及脚本内写死的 localhost 地址。同一预览域名一次只使用一个网站,请先关闭旧预览窗口再切换。

+

支持常见资源、表单、网站 Cookie、HTTP 登录和重定向。授权最长 1 小时,上传最多 16 MiB。不支持 HTTPS 上游、WebSocket、Service Worker。标准模式兼容常见 fetch / XHR,但不是任意网站的透明代理;复杂 SPA、动态模块或硬编码跳转可能需要网站配置 base URL。同一隔离预览域名一次只使用一个网站,切换前关闭旧窗口。

核对 SSH 主机指纹

@@ -52,12 +66,16 @@ export class ForwardPage {
`; - this.select = this.get('select'); - this.port = this.get('input'); + this.select = this.get('[name="host"]'); + this.port = this.get('[name="port"]'); + this.mode = this.get('[name="mode"]'); + this.trust = this.get('[name="trusted"]'); this.startButton = this.get('[type="submit"]'); this.stopButton = this.get('[data-stop]'); this.openButton = this.get('[data-open]'); this.keyDialog = this.get('dialog'); + this.mode.addEventListener('change', () => { this.trust.checked = false; this.render(); }); + this.trust.addEventListener('change', () => this.render()); this.get('[data-key-accept]').addEventListener('click', () => { const pending = this.pendingKey; if (pending?.socket.readyState === WebSocket.OPEN) { @@ -89,7 +107,12 @@ export class ForwardPage { this.render(); } - show(): void { this.root.hidden = false; this.get('h1').focus(); } + show(): void { + this.root.hidden = false; this.get('h1').focus(); + void api<{ previewAvailable: boolean }>('/api/forwarding').then((config) => { + this.previewAvailable = config.previewAvailable === true; this.render(); + }).catch(() => { this.previewAvailable = false; this.render(); }); + } hide(): void { if (this.root.hidden) return; this.stop(); @@ -97,9 +120,20 @@ export class ForwardPage { } private render(): void { + const isolated = this.mode.value === 'isolated'; this.select.disabled = this.busy || this.ready; this.port.disabled = this.busy || this.ready; - this.startButton.disabled = this.busy || this.ready || !this.hosts.length; + this.mode.disabled = this.busy || this.ready; + this.trust.disabled = this.busy || this.ready || isolated; + this.trust.required = !isolated; + this.get('.forward-trust').hidden = isolated; + this.get('[data-trust-warning]').hidden = isolated; + this.get('[data-preview-setup]').hidden = !isolated || this.previewAvailable; + this.get('[data-mode-badge]').textContent = isolated ? '跨站隔离预览' : '标准路径转发'; + this.get('#forward-mode-help').textContent = isolated + ? (this.previewAvailable ? '独立预览 Worker 已启用。网站内容将在另一个站点打开。' : '需要先运行「部署预览 Worker」工作流。未启用时不会降级转发。') + : '无需第二个 Worker;与主站同源,仅可访问可信网站。连接后请先停止再切换方式。'; + this.startButton.disabled = this.busy || this.ready || !this.hosts.length || (isolated ? !this.previewAvailable : !this.trust.checked); this.startButton.textContent = this.busy ? '正在连接…' : '连接并打开网站 ↗'; this.stopButton.disabled = !this.busy && !this.ready; this.openButton.hidden = !this.ready; @@ -118,6 +152,7 @@ export class ForwardPage { private async start(): Promise { if (this.busy || this.ready) return; + if (this.mode.value === 'trusted' ? !this.trust.checked : !this.previewAvailable) return; const host = this.hosts.find((item) => item.id === this.select.value); if (!host) { this.message('请先选择一台已保存的主机。'); return; } const port = Number(this.port.value); @@ -177,7 +212,10 @@ export class ForwardPage { this.get('[data-preview-link]').hidden = true; try { const { url, expiresAt } = await api<{ url: string; expiresAt: number }>( - `/api/forwarding?session=${this.sessionId}`, 'POST', { port: Number(this.port.value) }); + `/api/forwarding?session=${this.sessionId}`, 'POST', { + port: Number(this.port.value), mode: this.mode.value, + ...(this.mode.value === 'trusted' ? { trusted: this.trust.checked } : {}), + }); if (generation !== this.generation) return; this.ready = true; const link = this.get('[data-preview-link]'); @@ -199,6 +237,7 @@ export class ForwardPage { this.socket?.close(); this.socket = undefined; // 主 WebSocket 关闭就是撤销操作;无需依赖卸载页面时不可靠的异步 fetch。 this.sessionId = undefined; this.busy = false; this.ready = false; + this.trust.checked = false; this.get('[data-preview-link]').hidden = true; this.get('[data-preview-link]').removeAttribute('href'); this.message(message); this.render(); diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 95a92e6..db7d566 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -5,7 +5,7 @@ import { parse, stringify } from 'smol-toml'; import { ensureDatabase } from './cloudflare-d1.ts'; import { createDeploymentConfig, createPreviewDeploymentConfig, readDeploymentSettings } from './deployment-config.ts'; import { CloudflareApi, resolveAccountId } from './cloudflare-api.ts'; -import { resolveWorkersDevSubdomain } from './cloudflare-access.ts'; +import { resolveWorkerHostname, resolveWorkersDevSubdomain } from './cloudflare-access.ts'; import { previewOrigin } from '../src/forwarding/security.ts'; import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames, readWorkerVariable } from './deployment-secrets.ts'; import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts'; @@ -44,10 +44,13 @@ async function main(): Promise { settings.accountId = await resolveAccountId(api, settings.accountId); process.env.CLOUDFLARE_ACCOUNT_ID = settings.accountId; const existingSecrets = await readWorkerSecretNames(api, settings); - const workersSubdomain = await resolveWorkersDevSubdomain(api, settings); - const hostname = settings.customDomain || `${settings.workerName}.${workersSubdomain}.workers.dev`; - const previewHostname = settings.previewDomain || `${settings.workerName}-preview.${workersSubdomain}.workers.dev`; - const preview = previewOrigin(`https://${previewHostname}`, `https://${hostname}`); + const hostname = settings.customDomain || await resolveWorkerHostname(api, settings); + let preview = await readWorkerVariable(api, settings, 'PREVIEW_ORIGIN'); + let previewHostname: string | undefined; + if (settings.deployPreview) { + previewHostname = settings.previewDomain || `${settings.workerName}-preview.${await resolveWorkersDevSubdomain(api, settings)}.workers.dev`; + preview = previewOrigin(`https://${previewHostname}`, `https://${hostname}`); + } const database = await ensureDatabase(settings); const workspace = await readWorkspaceState(api, settings, database); const deployedProvider = workspace.authProvider @@ -68,7 +71,7 @@ async function main(): Promise { const secrets = { ...authentication.secrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) }; maskSecrets(secrets); const config = createDeploymentConfig(template, settings, database, { - hostname, previewOrigin: preview, githubAdminId: authentication.githubAdminId, + hostname, ...(preview ? { previewOrigin: preview } : {}), githubAdminId: authentication.githubAdminId, }); // 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。 await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8'); @@ -84,13 +87,15 @@ async function main(): Promise { if (!deployedSecrets.has(name)) throw new Error(`部署后缺少 Worker Secret:${name}。`); } // 预览 Worker 只做代理入口和 DO 绑定,不执行迁移、不写入任何 Secret。 - const previewConfig = createPreviewDeploymentConfig(template, settings, preview); - await writeFile(new URL(`../${previewGeneratedConfig}`, import.meta.url), stringify(previewConfig), 'utf8'); - await runWrangler(['deploy'], undefined, previewGeneratedConfig); - console.log(`部署完成:https://${hostname},预览:https://${previewHostname}`); + if (settings.deployPreview) { + const previewConfig = createPreviewDeploymentConfig(template, settings, preview!); + await writeFile(new URL(`../${previewGeneratedConfig}`, import.meta.url), stringify(previewConfig), 'utf8'); + await runWrangler(['deploy'], undefined, previewGeneratedConfig); + } + console.log(`部署完成:https://${hostname}${settings.deployPreview ? `,预览:https://${previewHostname}` : '(仅主 Worker)'}`); if (process.env.GITHUB_STEP_SUMMARY) { await appendFile(process.env.GITHUB_STEP_SUMMARY, - `## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\n登录方式:${settings.authProvider}\n\n` + `## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\n${settings.deployPreview ? `预览:https://${previewHostname}\n\n` : ''}登录方式:${settings.authProvider}\n\n` + (settings.authProvider === 'github' ? `GitHub OAuth 回调地址:https://${hostname}/auth/callback\n\n` : '') + 'D1 已迁移,运行时 Secret 已保存在 Cloudflare。后续部署保留原加密密钥与管理员资料。\n'); } diff --git a/scripts/deployment-config.ts b/scripts/deployment-config.ts index 7b87350..239d0f6 100644 --- a/scripts/deployment-config.ts +++ b/scripts/deployment-config.ts @@ -11,6 +11,7 @@ export interface DeploymentSettings { databaseId?: string; customDomain?: string; previewDomain?: string; + deployPreview?: boolean; authProvider: 'cloudflare' | 'github'; githubClientId?: string; githubAdmin?: string; @@ -39,7 +40,10 @@ export function readDeploymentSettings( if (accountId && !/^[a-f0-9]{32}$/i.test(accountId)) throw new Error('CLOUDFLARE_ACCOUNT_ID 必须是 32 位十六进制账户 ID。'); const workerName = env.WORKER_NAME?.trim() || String(template.name); if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(workerName)) throw new Error('WORKER_NAME 必须是 1–63 位小写字母、数字或连字符,且不能以连字符开头。'); - if (`${workerName}-preview`.length > 63) throw new Error('WORKER_NAME 加上 -preview 后不能超过 63 位。'); + const deployPreviewValue = env.DEPLOY_PREVIEW_WORKER?.trim().toLowerCase() || 'false'; + if (deployPreviewValue !== 'true' && deployPreviewValue !== 'false') throw new Error('DEPLOY_PREVIEW_WORKER 只能是 true 或 false。'); + const deployPreview = deployPreviewValue === 'true'; + if (deployPreview && `${workerName}-preview`.length > 63) throw new Error('WORKER_NAME 加上 -preview 后不能超过 63 位。'); const databases = template.d1_databases as TomlTable[] | undefined; const database = databases?.find((binding) => binding.binding === 'DB'); @@ -59,11 +63,11 @@ export function readDeploymentSettings( if (customDomain?.toLowerCase().endsWith('.workers.dev')) { throw new Error('CUSTOM_DOMAIN 不能填写 workers.dev 地址;使用 Worker 自带域名时请删除或留空该配置。'); } - const previewDomain = env.PREVIEW_DOMAIN?.trim(); - if (previewDomain && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(previewDomain)) { + const previewDomain = deployPreview ? env.PREVIEW_DOMAIN?.trim() : undefined; + if (deployPreview && previewDomain && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(previewDomain)) { throw new Error('PREVIEW_DOMAIN 只能填写完整域名,不能包含协议、路径或通配符。'); } - if (previewDomain?.toLowerCase().endsWith('.workers.dev')) { + if (deployPreview && previewDomain?.toLowerCase().endsWith('.workers.dev')) { throw new Error('PREVIEW_DOMAIN 不能填写 workers.dev 地址;请使用自定义跨站域名,或留空自动生成预览地址。'); } @@ -107,7 +111,7 @@ export function readDeploymentSettings( return { accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName, - databaseName, databaseId, customDomain, previewDomain, authProvider, githubClientId, githubAdmin, githubAdminId, + databaseName, databaseId, customDomain, previewDomain, deployPreview, authProvider, githubClientId, githubAdmin, githubAdminId, adminEmail, identityProviderIds, secrets, }; } diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts index a5f3720..407e773 100644 --- a/src/backend/durable-object.ts +++ b/src/backend/durable-object.ts @@ -86,6 +86,7 @@ export class SSHSessionDO implements DurableObject { if (await this.state.storage.get('account-id') !== accountId) { return Response.json({ error: 'Session owner mismatch' }, { status: 403 }); } + if (url.pathname === '/forward-http') return this.forwarding.trusted(request); if (url.pathname === '/forward') { if (request.method === 'DELETE') { this.forwarding.stop(); @@ -94,10 +95,11 @@ export class SSHSessionDO implements DurableObject { if (request.method !== 'POST') return previewError('Method not allowed', 405); const session = [...this.sessions.values()].find((item) => item.isForwardReady()); if (!session) return previewError('请先建立端口转发 SSH 连接。', 409); - const { port } = await request.json<{ port: number }>(); + const { port, mode } = await request.json<{ port: number; mode: 'trusted' | 'isolated' }>(); const origin = request.headers.get('x-preview-origin'); - if (!Number.isInteger(port) || port < 1 || port > 65535 || !origin) return previewError('Invalid forwarding configuration', 400); - try { return await this.forwarding.create(session, port, origin, this.state.id.toString()); } + if (!Number.isInteger(port) || port < 1 || port > 65535 || !origin + || (mode !== 'trusted' && mode !== 'isolated')) return previewError('Invalid forwarding configuration', 400); + try { return await this.forwarding.create(session, port, origin, this.state.id.toString(), mode); } catch { return previewError('远端端口不可达或 SSH 服务未允许 TCP 转发。', 502); } } if (url.pathname === '/sftp') return this.attachSFTP(request); diff --git a/src/forwarding/body.ts b/src/forwarding/body.ts index 2906aa5..e112d6f 100644 --- a/src/forwarding/body.ts +++ b/src/forwarding/body.ts @@ -1,5 +1,5 @@ /** 小型授权接口也可能收到流式大请求:边读边限制,而不是 text() 后才检查。 */ -export async function readBoundedText(request: Request, limit: number): Promise { +export async function readBoundedText(request: Pick, limit: number): Promise { if (!request.body) return ''; const reader = request.body.getReader(); const decoder = new TextDecoder(); diff --git a/src/forwarding/http.ts b/src/forwarding/http.ts index 435a363..d96a87c 100644 --- a/src/forwarding/http.ts +++ b/src/forwarding/http.ts @@ -1,5 +1,7 @@ import type { ForwardChannel } from './channel.ts'; import { PREVIEW_COOKIE, PREVIEW_PREFIX } from './security.ts'; +import { mountURL, mountCookie, mountCSS, mountSrcset, upstreamCookies, RUNTIME_PATH } from './mount.ts'; +import { readBoundedText } from './body.ts'; const encoder = new TextEncoder(); const decoder = new TextDecoder('latin1'); @@ -13,10 +15,11 @@ export function stripHopHeaders(headers: Headers): void { } function isInternalCookie(name: string): boolean { - return name.toLowerCase() === PREVIEW_COOKIE.toLowerCase() || /^cf_authorization$/i.test(name); + return name.toLowerCase() === PREVIEW_COOKIE.toLowerCase() || /^cf_authorization$/i.test(name) + || /^__host-edgessh-session$/i.test(name); } -export function upstreamHeaders(request: Request, port: number, origin: string): Headers { +export function upstreamHeaders(request: Request, port: number, origin: string, base = ''): Headers { const headers = new Headers(request.headers); stripHopHeaders(headers); const headerNames: string[] = []; @@ -29,36 +32,40 @@ export function upstreamHeaders(request: Request, port: number, origin: string): headers.set('Connection', 'close'); // 不压缩便于 HTML 属性中的绝对 localhost 地址重写;二进制响应仍保持原始字节。 headers.set('Accept-Encoding', 'identity'); - const cookies = (headers.get('Cookie') ?? '').split(';').filter((part) => { + const cookies = base ? upstreamCookies(headers.get('Cookie') ?? '', base) : (headers.get('Cookie') ?? '').split(';').filter((part) => { const index = part.indexOf('='); return index > 0 && !isInternalCookie(part.slice(0, index).trim()); }).join(';').trim(); if (cookies) headers.set('Cookie', cookies); else headers.delete('Cookie'); if (headers.get('Origin') === origin) headers.set('Origin', `http://127.0.0.1:${port}`); const referer = headers.get('Referer'); - if (referer?.startsWith(`${origin}/`)) headers.set('Referer', referer.replace(origin, `http://127.0.0.1:${port}`)); + if (referer?.startsWith(`${origin}${base}/`)) headers.set('Referer', referer.replace(`${origin}${base}`, `http://127.0.0.1:${port}`)); else headers.delete('Referer'); + if (base) { + headers.set('X-Forwarded-Prefix', base); + headers.set('X-Forwarded-Host', new URL(origin).host); + headers.set('X-Forwarded-Proto', 'https'); + } return headers; } export function rewriteLocalURL(value: string, port: number, origin: string): string { - // 相对路径天然落到预览站根路径;只修正当前端口的回环绝对地址,不扩大代理范围。 - // 嵌套页面的相对链接和 #fragment 必须留给浏览器解析,不能错误地按 / 作为基准。 - if (!/^https?:\/\//i.test(value) && !value.startsWith('//')) return value; - try { - const parsed = new URL(value, `http://127.0.0.1:${port}`); - if (parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname) - && Number(parsed.port || 80) === port) return `${origin}${parsed.pathname}${parsed.search}${parsed.hash}`; - } catch { /* 非 URL 属性原样交给网站 */ } - return value; + return mountURL(value, port, origin); } -export function previewHeaders(source: Headers, port: number, origin: string): Headers { +export function previewHeaders(source: Headers, port: number, origin: string, base = '', requestPath = '/'): Headers { const cookies = source.getSetCookie(); const headers = new Headers(source); stripHopHeaders(headers); headers.delete('Set-Cookie'); for (const cookie of cookies) { + if (base) { + const name = cookie.slice(0, cookie.indexOf('=')).trim(); + if (isInternalCookie(name)) continue; + const scoped = mountCookie(cookie, base, requestPath); + if (scoped) headers.append('Set-Cookie', scoped); + continue; + } const parts = cookie.split(';'); const name = parts[0].split('=')[0].trim(); if (isInternalCookie(name)) continue; @@ -66,7 +73,11 @@ export function previewHeaders(source: Headers, port: number, origin: string): H headers.append('Set-Cookie', [parts[0], ...attrs, ' Secure'].join(';')); } const location = headers.get('Location'); - if (location) headers.set('Location', rewriteLocalURL(location, port, origin)); + if (location) { + // Location 可用 ../ 逃出挂载路径,先以远端请求路径解析,再映射回当前转发。 + const target = base ? new URL(location, `http://127.0.0.1:${port}${requestPath}`).href : location; + headers.set('Location', mountURL(target, port, origin, base)); + } const refresh = headers.get('Refresh'); if (refresh) headers.delete('Refresh'); // 不接受无法可靠解析的自动导航头,普通 Location 保留。 for (const name of ['clear-site-data', 'service-worker-allowed', 'alt-svc', 'report-to', 'nel', @@ -131,8 +142,8 @@ class HTTPReader { } } -async function sendRequest(channel: ForwardChannel, request: Request, path: string, port: number, origin: string): Promise { - const headers = upstreamHeaders(request, port, origin); +async function sendRequest(channel: ForwardChannel, request: Request, path: string, port: number, origin: string, base: string): Promise { + const headers = upstreamHeaders(request, port, origin, base); const length = headers.get('Content-Length'); if (length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_UPLOAD_BYTES)) throw new Error('Upload is too large'); const chunked = request.body !== null && length === null; @@ -159,7 +170,7 @@ async function sendRequest(channel: ForwardChannel, request: Request, path: stri } finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); } } -export async function proxyHTTP(channel: ForwardChannel, request: Request, port: number, origin: string): Promise { +export async function proxyHTTP(channel: ForwardChannel, request: Request, port: number, origin: string, base = ''): Promise { const path = request.headers.get('x-preview-path') ?? '/'; if (!path.startsWith('/') || /[\s\0]/.test(path) || path.startsWith(PREVIEW_PREFIX)) { await channel.close(); @@ -178,14 +189,14 @@ export async function proxyHTTP(channel: ForwardChannel, request: Request, port: reader.releaseLock(); await channel.close(); }; - const sending = sendRequest(channel, request, path, port, origin); + const sending = sendRequest(channel, request, path, port, origin, base); void sending.catch(() => channel.abort(new Error('Unable to send HTTP request'))); try { let parsed = await parser.headers(); for (let count = 0; parsed.status < 200 && parsed.status !== 101 && count < 4; count++) parsed = await parser.headers(); if (parsed.status < 200) throw new Error('HTTP upgrades are not supported'); const { status, headers: source } = parsed; - const headers = previewHeaders(source, port, origin); + const headers = previewHeaders(source, port, origin, base, path); if (request.method === 'HEAD' || [204, 205, 304].includes(status)) { await finish(); return new Response(null, { status, headers }); @@ -239,15 +250,28 @@ export async function proxyHTTP(channel: ForwardChannel, request: Request, port: }); let response = new Response(body, { status, headers, encodeBody: 'manual' }); if (source.get('Content-Type')?.toLowerCase().includes('text/html') && !source.has('Content-Encoding')) { - // 不注入脚本、不缓冲 HTML,常见绝对资源链接与表单 action 按属性逐一修正。 - response = new HTMLRewriter().on('*', { + const rewriter = new HTMLRewriter().on('*', { element(element) { for (const name of ['href', 'src', 'action', 'formaction', 'poster']) { const value = element.getAttribute(name); - if (value) element.setAttribute(name, rewriteLocalURL(value, port, origin)); + if (value) element.setAttribute(name, mountURL(value, port, origin, base)); + } + if (base) { + const style = element.getAttribute('style'); + if (style) element.setAttribute('style', mountCSS(style, port, origin, base)); + const srcset = element.getAttribute('srcset'); + if (srcset) element.setAttribute('srcset', mountSrcset(srcset, port, origin, base)); } }, - }).transform(response); + }); + // 只有可信同源模式注入兼容脚本;隔离模式保持原站页面,不扩大其权限。 + if (base) rewriter.on('head', { element: (element) => { + element.prepend(``, { html: true }); + } }); + response = rewriter.transform(response); + } else if (base && source.get('Content-Type')?.toLowerCase().includes('text/css') && !source.has('Content-Encoding')) { + const css = await readBoundedText(response, 2 * 1024 * 1024); + response = new Response(mountCSS(css, port, origin, base), { status, headers }); } return response; } catch (error) { await finish(); throw error; } diff --git a/src/forwarding/mount.ts b/src/forwarding/mount.ts new file mode 100644 index 0000000..bca6397 --- /dev/null +++ b/src/forwarding/mount.ts @@ -0,0 +1,86 @@ +export const TRUSTED_PREFIX = '/_forward/'; +export const RUNTIME_PATH = '/__edgessh/runtime.js'; + +export function trustedBase(session: string): string { return `${TRUSTED_PREFIX}${session}`; } +export function cookiePrefix(base: string): string { return `ef_${base.split('/').pop()}_`; } + +/** 路径映射是兼容处理,不是安全沙箱;主站与可信网站仍然完全同源。 */ +export function mountURL(value: string, port: number, origin: string, base = ''): string { + if (base && value.startsWith('/') && !value.startsWith('//')) { + const parsed = new URL(value, origin); + const path = parsed.pathname; + return `${path === base || path.startsWith(`${base}/`) ? path : `${base}${path}`}${parsed.search}${parsed.hash}`; + } + if (!/^https?:\/\//i.test(value) && !value.startsWith('//')) return value; + try { + const url = new URL(value, `http://127.0.0.1:${port}`); + const local = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + && Number(url.port || 80) === port; + if (local || (base && url.origin === origin)) { + const path = base && (url.pathname === base || url.pathname.startsWith(`${base}/`)) ? url.pathname : `${base}${url.pathname}`; + return `${origin}${path}${url.search}${url.hash}`; + } + } catch { /* 非 URL 属性原样保留 */ } + return value; +} + +export function mountCSS(css: string, port: number, origin: string, base: string): string { + return css.replace(/(url\(\s*|@import\s+)(["']?)([^"'()\s;]+)\2/gi, + (_match, start: string, quote: string, value: string) => `${start}${quote}${mountURL(value, port, origin, base)}${quote}`); +} + +export function mountSrcset(value: string, port: number, origin: string, base: string): string { + let result = ''; + let offset = 0; + // srcset 的 data URL 自带逗号,不能直接 split(','),否则会破坏内嵌图片。 + while (offset < value.length) { + const start = offset; + while (offset < value.length && /[\s,]/.test(value[offset])) offset++; + result += value.slice(start, offset); + const urlStart = offset; + while (offset < value.length && !/\s/.test(value[offset])) offset++; + const token = value.slice(urlStart, offset); + const url = token.replace(/,+$/, ''); + result += mountURL(url, port, origin, base) + token.slice(url.length); + if (url.length !== token.length) continue; + const descriptorStart = offset; + while (offset < value.length && value[offset] !== ',') offset++; + if (offset < value.length) offset++; + result += value.slice(descriptorStart, offset); + } + return result; +} + +export function upstreamCookies(value: string, base: string): string { + const prefix = cookiePrefix(base); + return value.split(';').map((part) => part.trim()).filter((part) => part.startsWith(prefix) && part.includes('=')) + .map((part) => part.slice(prefix.length)).join('; '); +} + +export function mountCookie(value: string, base: string, requestPath = '/'): string | null { + const parts = value.split(';'); + const equals = parts[0].indexOf('='); + if (equals < 1) return null; + const name = parts[0].slice(0, equals).trim(); + const pathname = new URL(requestPath, 'http://localhost').pathname; + let path = pathname.slice(0, pathname.lastIndexOf('/')) || '/'; + let maxAge: number | undefined; + let expiresAt: number | undefined; + const attrs: string[] = []; + for (const part of parts.slice(1)) { + const trimmed = part.trim(); + if (/^path=/i.test(trimmed)) path = trimmed.slice(5); + else if (/^max-age=/i.test(trimmed)) { + const seconds = Number(trimmed.slice(8)); + if (Number.isFinite(seconds)) maxAge = Math.floor(seconds); + } else if (/^expires=/i.test(trimmed)) { + const expires = Date.parse(trimmed.slice(8)); + if (Number.isFinite(expires)) expiresAt = expires; + } else if (!/^(domain=|secure$)/i.test(trimmed)) attrs.push(trimmed); + } + if (!path.startsWith('/')) path = '/'; + // 按 Cookie 语义让 Max-Age 优先于 Expires,再限制残留 Cookie 最长保留一小时。 + const lifetime = Math.max(0, Math.min(3600, maxAge ?? (expiresAt === undefined ? 3600 : Math.floor((expiresAt - Date.now()) / 1000)))); + // 只转发会话自己的 Cookie;不能让目标网站 Set-Cookie 覆盖主站登录 Cookie。 + return `${cookiePrefix(base)}${name}${parts[0].slice(equals)}; ${attrs.concat(`Path=${base}${path}`, 'Secure', `Max-Age=${lifetime}`).join('; ')}`; +} diff --git a/src/forwarding/routes.ts b/src/forwarding/routes.ts index 156ebfe..f3361ec 100644 --- a/src/forwarding/routes.ts +++ b/src/forwarding/routes.ts @@ -1,22 +1,27 @@ -import type { Env } from '../types'; -import { jsonError } from '../http-security'; -import { previewOrigin } from './security'; -import { readBoundedText } from './body'; +import type { Env } from '../types.ts'; +import { jsonError } from '../http-security.ts'; +import { previewOrigin } from './security.ts'; +import { readBoundedText } from './body.ts'; +import { TRUSTED_PREFIX } from './mount.ts'; /** 只有主站的认证 API 能创建或撤销转发;预览站没有 SSH 控制接口。 */ export async function forwardingRoute(request: Request, env: Env, accountId: string): Promise { + if (request.method === 'GET') { + let previewAvailable = false; + try { + previewOrigin(env.PREVIEW_ORIGIN, new URL(request.url).origin); + if (env.APP_ORIGIN) previewOrigin(env.PREVIEW_ORIGIN, env.APP_ORIGIN); + previewAvailable = true; + } catch { /* 未启用隔离预览不影响默认标准转发 */ } + return Response.json({ previewAvailable }, { headers: { 'Cache-Control': 'no-store' } }); + } if (request.method !== 'POST' && request.method !== 'DELETE') return jsonError('Method not allowed', 405); const url = new URL(request.url); const session = url.searchParams.get('session'); if (!session || !/^[a-f0-9]{64}$/.test(session)) return jsonError('Invalid session identifier', 400); - let origin: string; - try { - origin = previewOrigin(env.PREVIEW_ORIGIN, url.origin); - if (env.APP_ORIGIN) previewOrigin(origin, env.APP_ORIGIN); - } - catch (error) { return jsonError((error as Error).message, 503); } + let origin = url.origin; const headers = new Headers({ - 'x-account-id': accountId, 'x-preview-origin': origin, 'Content-Type': 'application/json', + 'x-account-id': accountId, 'Content-Type': 'application/json', }); let body: string | undefined; if (request.method === 'POST') { @@ -24,14 +29,41 @@ export async function forwardingRoute(request: Request, env: Env, accountId: str let text: string; try { text = await readBoundedText(request, 1024); } catch { return jsonError('Request body is too large', 413); } - let input: { port?: unknown }; + let input: { port?: unknown; mode?: unknown; trusted?: unknown }; try { input = JSON.parse(text); } catch { return jsonError('Invalid JSON body', 400); } if (!input || !Number.isInteger(input.port) || Number(input.port) < 1 || Number(input.port) > 65535) { return jsonError('端口必须是 1–65535 的整数。', 400); } - body = JSON.stringify({ port: input.port }); + const mode = input.mode ?? 'trusted'; + if (mode !== 'trusted' && mode !== 'isolated') return jsonError('Invalid forwarding mode', 400); + if (mode === 'trusted' && input.trusted !== true) return jsonError('标准转发仅适用于可信网站,请先确认风险。', 400); + if (mode === 'isolated') { + try { + origin = previewOrigin(env.PREVIEW_ORIGIN, url.origin); + if (env.APP_ORIGIN) previewOrigin(origin, env.APP_ORIGIN); + } catch { return jsonError('隔离预览尚未启用,请先运行“部署预览 Worker”工作流。', 503); } + } + body = JSON.stringify({ port: input.port, mode }); } + headers.set('x-preview-origin', origin); return env.SSH_SESSIONS.get(env.SSH_SESSIONS.idFromString(session)).fetch(new Request('https://session.internal/forward', { method: request.method, headers, body, })); } + +export async function trustedForwardRoute(request: Request, env: Env, accountId: string): Promise { + const url = new URL(request.url); + const match = /^\/_forward\/([a-f0-9]{64})(\/.*)?$/.exec(url.pathname); + if (!match) return jsonError('Not found', 404); + if (request.headers.has('Service-Worker')) return jsonError('Service workers are disabled in forwarding pages', 403); + if (!match[2]) return new Response(null, { status: 307, headers: { Location: `${TRUSTED_PREFIX}${match[1]}/${url.search}`, 'Cache-Control': 'no-store' } }); + const headers = new Headers(request.headers); + // 覆盖所有内部路由信息;不能相信网页发送的目标、模式、账户或 preview token。 + headers.set('x-account-id', accountId); + headers.set('x-preview-origin', url.origin); + headers.set('x-preview-path', match[2] + url.search); + headers.delete('x-preview-token'); + return env.SSH_SESSIONS.get(env.SSH_SESSIONS.idFromString(match[1])).fetch(new Request('https://session.internal/forward-http', { + method: request.method, headers, body: request.body, signal: request.signal, + })); +} diff --git a/src/forwarding/runtime.ts b/src/forwarding/runtime.ts new file mode 100644 index 0000000..e5dd126 --- /dev/null +++ b/src/forwarding/runtime.ts @@ -0,0 +1,33 @@ +import { cookiePrefix } from './mount.ts'; + +/** 只为可信路径模式补常用浏览器 API;不伪装成能运行任意网站的透明反向代理。 */ +export function pathRuntime(base: string, port: number): string { + return `(() => { +const base=${JSON.stringify(base)}, prefix=${JSON.stringify(cookiePrefix(base))}, port=${port}; +const map=value=>{ + const url=new URL(String(value),document.baseURI); + const local=url.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(url.hostname)&&Number(url.port||80)===port; + if(!local&&url.origin!==location.origin)return String(value); + if(url.pathname===base||url.pathname.startsWith(base+'/'))return url.href; + return location.origin+base+url.pathname+url.search+url.hash; +}; +const originalFetch=window.fetch; +window.fetch=function(input,init){return originalFetch.call(this,input instanceof Request?new Request(map(input.url),input):map(input),init);}; +const open=XMLHttpRequest.prototype.open; +XMLHttpRequest.prototype.open=function(method,url,...rest){return open.call(this,method,map(url),...rest);}; +if(window.EventSource){const Native=window.EventSource;window.EventSource=class extends Native{constructor(url,options){super(map(url),options);}};} +for(const name of ['pushState','replaceState']){const original=history[name];history[name]=function(data,unused,url){return original.call(this,data,unused,url==null?url:map(url));};} +const cookie=Object.getOwnPropertyDescriptor(Document.prototype,'cookie'); +if(cookie&&cookie.get&&cookie.set)Object.defineProperty(document,'cookie',{ + get(){return cookie.get.call(document).split(';').map(v=>v.trim()).filter(v=>v.startsWith(prefix)).map(v=>v.slice(prefix.length)).join('; ');}, + set(value){ + const parts=String(value).split(';'), first=parts.shift(), split=first.indexOf('='); + if(split<1)return; + let path=location.pathname.slice(base.length).replace(/[^/]*$/,'')||'/'; + const attrs=parts.filter(part=>{const v=part.trim();if(/^path=/i.test(v)){path=v.slice(5);return false;}return !/^(domain=|secure$)/i.test(v);}); + if(!path.startsWith('/'))path='/'; + cookie.set.call(document,prefix+first.trim()+'; '+attrs.join('; ')+'; Path='+base+path+'; Secure'); + } +}); +})();`; +} diff --git a/src/forwarding/state.ts b/src/forwarding/state.ts index 11aa2d8..7905dd4 100644 --- a/src/forwarding/state.ts +++ b/src/forwarding/state.ts @@ -1,6 +1,8 @@ import type { SSHSession } from '../backend/session.ts'; import { FORWARD_TTL_MS, LAUNCH_TTL_MS, PREVIEW_PREFIX, previewError } from './security.ts'; import { proxyHTTP } from './http.ts'; +import { trustedBase, RUNTIME_PATH } from './mount.ts'; +import { pathRuntime } from './runtime.ts'; interface Grant { session: SSHSession; @@ -10,6 +12,7 @@ interface Grant { launchExpires: number; token: string; expires: number; + base: string; } /** 授权仅存在 SSH 会话内存里;DO 重启、断线或显式停止都会让预览 Cookie 失效。 */ @@ -31,7 +34,7 @@ export class ForwardingState { session?.close(true); } - async create(session: SSHSession, port: number, origin: string, sessionId: string): Promise { + async create(session: SSHSession, port: number, origin: string, sessionId: string, mode: 'trusted' | 'isolated' = 'isolated'): Promise { this.clear(); const revision = this.revision; // 创建时先探测 direct-tcpip 权限和监听端口,避免给用户一个必定失败的链接。 @@ -41,18 +44,19 @@ export class ForwardingState { const grant: Grant = { session, port, origin, launchToken: crypto.randomUUID(), launchExpires: Date.now() + LAUNCH_TTL_MS, token: crypto.randomUUID(), expires: Date.now() + FORWARD_TTL_MS, + base: mode === 'trusted' ? trustedBase(sessionId) : '', }; this.grant = grant; this.timer = setTimeout(() => this.stop(), FORWARD_TTL_MS); return Response.json({ - url: `${origin}${PREVIEW_PREFIX}start#${sessionId}.${grant.launchToken}`, + url: grant.base ? `${origin}${grant.base}/` : `${origin}${PREVIEW_PREFIX}start#${sessionId}.${grant.launchToken}`, expiresAt: grant.expires, }, { headers: { 'Cache-Control': 'no-store' } }); } async preview(request: Request): Promise { const grant = this.grant; - if (!grant || grant.expires <= Date.now() || !grant.session.isForwardReady() + if (!grant || grant.base || grant.expires <= Date.now() || !grant.session.isForwardReady() || request.headers.get('x-preview-origin') !== grant.origin) return previewError('转发已断开或过期,请回到 EdgeSSH 重新连接。', 410); const token = request.headers.get('x-preview-token'); if (new URL(request.url).pathname === '/preview-launch') { @@ -64,6 +68,24 @@ export class ForwardingState { { headers: { 'Cache-Control': 'no-store' } }); } if (token !== grant.token) return previewError('预览授权无效。', 401); + return this.serve(request, grant); + } + + /** 主站已完成登录校验,DO 已核对 account-id;不能借此入口绕过隔离模式票据。 */ + async trusted(request: Request): Promise { + const grant = this.grant; + if (!grant?.base || grant.expires <= Date.now() || !grant.session.isForwardReady() + || request.headers.get('x-preview-origin') !== grant.origin) return previewError('转发已断开或模式不匹配。', 410); + if (request.headers.get('x-preview-path') === RUNTIME_PATH && request.method === 'GET') { + return new Response(pathRuntime(grant.base, grant.port), { headers: { + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "worker-src 'none'", + } }); + } + return this.serve(request, grant); + } + + private async serve(request: Request, grant: Grant): Promise { if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'].includes(request.method) || request.headers.has('Upgrade')) return previewError('此预览只支持 HTTP 请求,不支持 WebSocket 或 CONNECT。', 405); const origin = request.headers.get('Origin'); @@ -74,7 +96,7 @@ export class ForwardingState { try { const channel = await grant.session.openForward(grant.port); if (this.grant !== grant) { await channel.close(); return previewError('转发已停止。', 410); } - return await proxyHTTP(channel, request, grant.port, grant.origin); + return await proxyHTTP(channel, request, grant.port, grant.origin, grant.base); } catch { return previewError('无法访问远端 HTTP 服务,请检查端口、服务状态和 SSH 转发权限。', 502); } } } diff --git a/src/worker.ts b/src/worker.ts index 4b66c04..c2057e3 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -8,7 +8,7 @@ import { hostsRoute } from './accounts/hosts'; import { snippetsRoute } from './accounts/snippets'; import { apiFailure, json } from './accounts/http'; import { locateHost } from './accounts/location'; -import { forwardingRoute } from './forwarding/routes'; +import { forwardingRoute, trustedForwardRoute } from './forwarding/routes'; export { SSHSessionDO }; @@ -127,6 +127,7 @@ export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); const isApiRequest = url.pathname.startsWith('/api/'); + const isForwardRequest = url.pathname.startsWith('/_forward/'); try { if (isProductionHttp(request)) { if (isApiRequest) return corsResponse(jsonError('HTTPS is required', 403)); @@ -135,7 +136,7 @@ export default { } // Cookie 认证只接受同源请求;写操作必须有 Origin,阻断跨站表单与 CSRF。 const origin = request.headers.get('Origin'); - if (isApiRequest && ((origin && origin !== url.origin) + if ((isApiRequest || isForwardRequest) && ((origin && origin !== url.origin) || (!['GET', 'HEAD', 'OPTIONS'].includes(request.method) && origin !== url.origin) || request.headers.get('Sec-Fetch-Site') === 'cross-site')) return jsonError('不允许跨站请求。', 403); if (isApiRequest && request.method === 'OPTIONS') { @@ -151,8 +152,10 @@ export default { if (!location) return corsResponse(jsonError('位置服务暂时不可用。', 503)); return corsResponse(json({ status: 'ok', sample: '8.8.8.8', location })); } - const account = isApiRequest ? await currentAccount(request, env) : null; - if (isApiRequest && !account) return jsonError('请先登录。', 401); + const account = isApiRequest || isForwardRequest ? await currentAccount(request, env) : null; + if ((isApiRequest || isForwardRequest) && !account) return jsonError('请先登录。', 401); + // 转发返回目标网站自己的 CSP,不套主应用 CSP;此路径仍必须先通过管理员认证。 + if (isForwardRequest) return await trustedForwardRoute(request, env, account!.id); if (url.pathname === '/api/auth/me' && request.method === 'GET') return json({ account, provider: authProvider(env) }); if (url.pathname.startsWith('/api/hosts')) return await hostsRoute(request, env, account!.id, url.pathname); if (url.pathname.startsWith('/api/snippets')) return await snippetsRoute(request, env, account!.id, url.pathname); diff --git a/tests/browser/forwarding-runtime.spec.ts b/tests/browser/forwarding-runtime.spec.ts new file mode 100644 index 0000000..34f556a --- /dev/null +++ b/tests/browser/forwarding-runtime.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; +import { pathRuntime } from '../../src/forwarding/runtime.ts'; + +test('pathRuntime rewrites localhost fetch/XHR and isolates prefixed cookies', async ({ page }) => { + await page.route('**/_forward/**', async (route) => { + await route.fulfill({ status: 200, contentType: 'text/html', body: '' }); + }); + await page.goto('/_forward/' + 'a'.repeat(64) + '/foo/'); + await page.context().addCookies([ + { name: 'main', value: 'hidden', url: 'http://127.0.0.1:5173/' }, + { name: 'ef_' + 'a'.repeat(64) + '_target', value: 'visible', url: 'http://127.0.0.1:5173/' }, + ]); + const requests: string[] = []; + await page.route('**/*', async (route) => { + requests.push(new URL(route.request().url()).pathname + new URL(route.request().url()).search); + await route.fulfill({ status: 200, body: '{}' }); + }); + await page.addScriptTag({ content: pathRuntime('/_forward/' + 'a'.repeat(64), 8080) }); + const result = await page.evaluate(async () => { + await fetch('http://127.0.0.1:8080/api/data'); + await new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.addEventListener('loadend', () => resolve()); + xhr.open('GET', 'http://127.0.0.1:8080/api/xhr'); + xhr.send(); + }); + return document.cookie; + }); + await expect.poll(() => requests).toEqual(expect.arrayContaining([ + '/_forward/' + 'a'.repeat(64) + '/api/data', + '/_forward/' + 'a'.repeat(64) + '/api/xhr', + ])); + expect(result).toContain('target=visible'); + expect(result).not.toContain('main=hidden'); +}); diff --git a/tests/browser/forwarding.spec.ts b/tests/browser/forwarding.spec.ts index 7b5bc9b..db1b330 100644 --- a/tests/browser/forwarding.spec.ts +++ b/tests/browser/forwarding.spec.ts @@ -7,19 +7,26 @@ const host = { hasCredential: true, updatedAt: Date.now(), }; -async function forwardingFixture(page: Page) { +const sessionId = 'a'.repeat(64); +const trustedURL = `http://127.0.0.1:5173/_forward/${sessionId}/`; + +async function forwardingFixture(page: Page, previewAvailable = true, noHosts = false) { const calls: Array> = []; const sockets: WebSocketRoute[] = []; const closed: boolean[] = []; await page.route('**/api/**', async (route) => { const path = new URL(route.request().url()).pathname; if (path === '/api/auth/me') return route.fulfill({ json: { account: { username: 'Administrator' }, provider: 'cloudflare' } }); - if (path === '/api/hosts') return route.fulfill({ json: { hosts: [host] } }); + if (path === '/api/hosts') return route.fulfill({ json: { hosts: noHosts ? [] : [host] } }); if (path.endsWith('/credentials')) return route.fulfill({ json: { password: 'test-only-password' } }); - if (path === '/api/session') return route.fulfill({ json: { ticket: 'test-ticket', sessionId: 'session-forward' } }); - if (path === '/api/forwarding') { + if (path === '/api/session') return route.fulfill({ json: { ticket: 'test-ticket', sessionId } }); + if (path === '/api/forwarding' && route.request().method() === 'GET') { + return route.fulfill({ json: { previewAvailable } }); + } + if (path === '/api/forwarding' && route.request().method() === 'POST') { calls.push({ type: 'forwarding', body: route.request().postDataJSON() }); - return route.fulfill({ json: { url: 'https://preview.example.net/__edgessh/start#xxx', expiresAt: Date.now() + 3600000 } }); + const mode = route.request().postDataJSON().mode; + return route.fulfill({ json: { url: mode === 'isolated' ? 'https://isolated.example.net/start#xxx' : trustedURL, expiresAt: Date.now() + 3600000 } }); } return route.fulfill({ json: {} }); }); @@ -42,22 +49,74 @@ async function forwardingFixture(page: Page) { async function startForward(page: Page) { await page.locator('.forward-page select[name="host"]').selectOption('alpha'); await page.locator('.forward-page input[name="port"]').fill('8080'); + await page.locator('.forward-page input[name="trusted"]').check(); await page.locator('.forward-page button[type="submit"]').click(); } +async function chooseForwardMode(page: Page, mode: 'trusted' | 'isolated') { + await page.locator('.forward-page select[name="mode"]').selectOption(mode); +} + +test('默认标准模式显示风险警告,隔离模式未配置时仍禁用连接', async ({ page }, testInfo) => { + await forwardingFixture(page, false); + await expect(page.locator('[data-trust-warning]')).toBeVisible(); + await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled(); + await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); }); + await page.screenshot({ path: testInfo.outputPath('trusted-mode.png'), fullPage: true }); + await page.locator('.forward-page select[name="host"]').selectOption('alpha'); + await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled(); + await page.locator('.forward-page input[name="trusted"]').check(); + await expect(page.locator('.forward-page button[type="submit"]')).toBeEnabled(); + await chooseForwardMode(page, 'isolated'); + await expect(page.locator('[data-preview-setup]')).toBeVisible(); + await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled(); + await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); }); + await page.screenshot({ path: testInfo.outputPath('isolated-setup.png'), fullPage: true }); + await chooseForwardMode(page, 'trusted'); + await expect(page.locator('.forward-page input[name="trusted"]')).not.toBeChecked(); + await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled(); +}); + +test('已启用隔离预览无需信任勾选,提交隔离请求并在停止后解锁模式', async ({ page }) => { + const { calls } = await forwardingFixture(page, true); + await page.locator('.forward-page select[name="host"]').selectOption('alpha'); + await page.locator('.forward-page input[name="port"]').fill('8080'); + await chooseForwardMode(page, 'isolated'); + await expect(page.locator('.forward-page input[name="trusted"]')).toBeHidden(); + await expect(page.locator('.forward-page button[type="submit"]')).toBeEnabled(); + await page.locator('.forward-page button[type="submit"]').click(); + await expect.poll(() => calls.find((call) => call.type === 'forwarding')?.body).toEqual({ port: 8080, mode: 'isolated' }); + await expect(page.locator('[data-preview-link]')).toHaveAttribute('href', 'https://isolated.example.net/start#xxx'); + await expect(page.locator('.forward-page select[name="mode"]')).toBeDisabled(); + await page.locator('[data-stop]').click(); + await expect(page.locator('.forward-page select[name="mode"]')).toBeEnabled(); +}); + +test('切换转发方式会清空之前的信任确认', async ({ page }) => { + await forwardingFixture(page, true); + await page.locator('.forward-page select[name="host"]').selectOption('alpha'); + await page.locator('.forward-page input[name="trusted"]').check(); + await expect(page.locator('.forward-page input[name="trusted"]')).toBeChecked(); + await chooseForwardMode(page, 'isolated'); + await chooseForwardMode(page, 'trusted'); + await expect(page.locator('.forward-page input[name="trusted"]')).not.toBeChecked(); + await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled(); +}); + test('连接转发发送 forward 协议并提供弹窗拦截回退链接,停止后解锁主机', async ({ page }, testInfo) => { const { calls } = await forwardingFixture(page); await page.evaluate(() => { window.open = () => null; }); await startForward(page); await expect.poll(() => calls.find((call) => call.type === 'connect')?.mode).toBe('forward'); - await expect.poll(() => calls.find((call) => call.type === 'forwarding')?.body).toEqual({ port: 8080 }); - await expect.poll(() => page.locator('a[data-preview-link]').getAttribute('href')).toBe('https://preview.example.net/__edgessh/start#xxx'); + await expect.poll(() => calls.find((call) => call.type === 'forwarding')?.body).toEqual({ port: 8080, mode: 'trusted', trusted: true }); + await expect.poll(() => page.locator('a[data-preview-link]').getAttribute('href')).toBe(trustedURL); await expect(page.locator('a[data-preview-link]')).toBeVisible(); await expect(page.locator('a[data-preview-link]')).toHaveAttribute('rel', 'noopener noreferrer'); await expect(page.locator('a[data-preview-link]')).toHaveAttribute('target', '_blank'); await page.locator('[data-stop]').click(); await expect(page.locator('a[data-preview-link]')).toBeHidden(); await expect(page.locator('.forward-page select[name="host"]')).toBeEnabled(); + await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); }); await page.screenshot({ path: testInfo.outputPath('forwarding-desktop.png'), fullPage: true }); }); @@ -73,10 +132,13 @@ test('离开总览会关闭独立转发 WebSocket,返回页面保持未连接' }); test('空主机禁用连接按钮且桌面与 375px 手机布局没有横向溢出', async ({ page }, testInfo) => { - await page.route('**/api/hosts', (route) => route.fulfill({ json: { hosts: [] } })); - await page.goto('/'); - await page.locator('#rail-forward').click(); + await forwardingFixture(page, false, true); + await expect(page.locator('.forward-page')).toBeVisible(); + await expect(page.locator('.forward-page h1')).toHaveText('端口转发'); + await expect(page.locator('.forward-page select[name="mode"]')).toHaveValue('trusted'); + await expect(page.locator('[data-trust-warning]')).toBeVisible(); await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); }); await page.screenshot({ path: testInfo.outputPath('forwarding-mobile.png'), fullPage: true }); }); diff --git a/tests/deployment.test.ts b/tests/deployment.test.ts index 30a8d40..a44055c 100644 --- a/tests/deployment.test.ts +++ b/tests/deployment.test.ts @@ -13,6 +13,7 @@ const env = { ENCRYPTION_KEY: Buffer.alloc(32, 1).toString('base64'), ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com', ACCESS_AUD: 'test-audience', + DEPLOY_PREVIEW_WORKER: 'true', }; const settings = readDeploymentSettings(template, env); const database = { uuid: '00000000-0000-4000-8000-000000000001', name: 'edgessh-accounts' }; @@ -126,6 +127,17 @@ test('invalid account, resource, domain and runtime configuration fail validatio }, env), /Secret/); }); +test('preview validation and main-only config follow DEPLOY_PREVIEW_WORKER', () => { + const disabled = readDeploymentSettings(template, { ...env, DEPLOY_PREVIEW_WORKER: 'false', PREVIEW_DOMAIN: 'not a domain' }); + assert.equal(disabled.deployPreview, false); + assert.equal(disabled.previewDomain, undefined); + const config = createDeploymentConfig(template, disabled, database, { hostname: 'edgessh.example.com' }); + assert.equal((config.vars as Record).PREVIEW_ORIGIN, undefined); + const existing = createDeploymentConfig(template, disabled, database, { hostname: 'edgessh.example.com', previewOrigin: 'https://existing.example.com' }); + assert.equal((existing.vars as Record).PREVIEW_ORIGIN, 'https://existing.example.com'); + assert.throws(() => readDeploymentSettings(template, { ...env, DEPLOY_PREVIEW_WORKER: 'true', PREVIEW_DOMAIN: 'not a domain' }), /PREVIEW_DOMAIN/); +}); + test('database lookup reuses an existing database without any writes', async () => { const calls: string[] = []; const fetcher: typeof fetch = async (input, init) => { diff --git a/tests/forwarding-auth.test.ts b/tests/forwarding-auth.test.ts index 9a5f114..013e480 100644 --- a/tests/forwarding-auth.test.ts +++ b/tests/forwarding-auth.test.ts @@ -25,6 +25,16 @@ async function prepared() { return { state, session, launchToken: capability.token }; } +test('trusted grants use the main origin path and do not accept isolated preview tokens', async () => { + const session = fakeSession(); + const state = new ForwardingState(); + const response = await state.create(session as never, 8080, 'https://main.test', sessionId, 'trusted'); + const body = await response.json() as { url: string }; + assert.equal(body.url, `https://main.test/_forward/${sessionId}/`); + assert.equal((await state.preview(new Request('https://internal/preview'))).status, 410); + state.clear(); +}); + test('launch token is single-use and returns preview token', async () => { const { state, session, launchToken } = await prepared(); try { diff --git a/tests/forwarding-http.test.ts b/tests/forwarding-http.test.ts index 84fad13..9d2da33 100644 --- a/tests/forwarding-http.test.ts +++ b/tests/forwarding-http.test.ts @@ -42,3 +42,16 @@ test('stripHopHeaders removes declared connection tokens', () => { assert.equal(headers.has('upgrade'), false); assert.equal(headers.has('keepalive'), true); }); + +test('trusted cookies are namespaced and never forward host login cookies', () => { + const request = new Request('https://main.test/_forward/abc/', { headers: { + Cookie: 'ef_abc_sid=ok; __Host-edgessh-session=main; CF_Authorization=access; ef_other_no=bad', + }}); + const headers = upstreamHeaders(request, 8080, 'https://main.test', '/_forward/abc'); + assert.equal(headers.get('cookie'), 'sid=ok'); + const result = previewHeaders(new Headers([ + ['Set-Cookie', 'sid=abc; Path=/; Domain=internal.test'], + ['Set-Cookie', '__Host-edgessh-session=bad; Path=/'], + ]), 8080, 'https://main.test', '/_forward/abc'); + assert.deepEqual(result.getSetCookie(), ['ef_abc_sid=abc; Path=/_forward/abc/; Secure; Max-Age=3600']); +}); diff --git a/tests/forwarding-mounted.test.ts b/tests/forwarding-mounted.test.ts new file mode 100644 index 0000000..f1fc881 --- /dev/null +++ b/tests/forwarding-mounted.test.ts @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { build } from 'esbuild'; +import { Miniflare } from 'miniflare'; +import { mountCookie, mountCSS } from '../src/forwarding/mount.ts'; + +const root = 'https://preview.example'; +const base = '/_forward/64a'; + +test('mounted proxy rewrites HTML/CSS through real HTMLRewriter and scopes headers', async () => { + const dir = await mkdtemp(join(tmpdir(), 'edgessh-mounted-')); + const entry = join(dir, 'worker.ts'); + const output = join(dir, 'worker.js'); + await writeFile(entry, ` + import { proxyHTTP } from ${JSON.stringify(join(process.cwd(), 'src/forwarding/http.ts'))}; + const encoder = new TextEncoder(); + const html = 'xa
'; + class Channel { + readable = new ReadableStream({ start(c) { c.enqueue(encoder.encode('HTTP/1.1 302 Found\\r\\nContent-Type: text/html\\r\\nContent-Length: '+new TextEncoder().encode(html).length+'\\r\\nSet-Cookie: sid=abc; Path=/; Max-Age=10; Expires=Wed, 21 Oct 2030 07:28:00 GMT\\r\\nSet-Cookie: __Host-edgessh-session=bad; Path=/\\r\\nLocation: ../login\\r\\n\\r\\n'+html)); c.close(); } }); + async write() {} async close() {} abort() {} + } + export default { async fetch(request) { return proxyHTTP(new Channel(), request, 8080, '${root}', '${base}'); } }; + `); + let mf: Miniflare | undefined; + try { + await build({ entryPoints: [entry], outfile: output, bundle: true, format: 'esm', platform: 'neutral', sourcemap: false }); + const bundleJS = await (await import('node:fs/promises')).readFile(output, 'utf8'); + mf = new Miniflare({ workers: [{ config: { + type: 'worker', + name: 'test', + compatibilityDate: '2026-07-01', + manifest: { + mainModule: 'worker.js', + modulesRoot: dir, + modules: { 'worker.js': { type: 'esm', contents: bundleJS } }, + }, + } }] }); + const response = await mf.dispatchFetch(`${root}${base}/dir/page`, { + redirect: 'manual', + headers: { 'x-preview-path': '/dir/page' }, + }); + const text = await response.text(); + assert.equal(response.status, 302); + assert.match(text, /href="\/_forward\/64a\/next"/); + assert.match(text, /src="https:\/\/preview\.example\/_forward\/64a\/i\.png"/); + assert.match(text, /action="\/_forward\/64a\/go"/); + assert.match(text, /formaction="\/_forward\/64a\/alt"/); + assert.match(text, /srcset="\/_forward\/64a\/a\.png 1x, data:image\/png;base64,AA 2x"/); + assert.match(text, /style="background:url\(\/_forward\/64a\/bg\.png\)"/); + assert.equal((text.match(/__edgessh\/runtime\.js/g) ?? []).length, 1); + assert.equal(response.headers.get('location'), `${root}${base}/login`); + assert.deepEqual(response.headers.getSetCookie(), [`ef_64a_sid=abc; Path=${base}/; Secure; Max-Age=10`]); + } finally { + await mf?.dispose(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test('mounted CSS and cookie helpers preserve URL semantics', () => { + assert.equal(mountCSS('@import "/a.css";x{background:url(https://x.test/a)}', 8080, root, base), `@import "${base}/a.css";x{background:url(https://x.test/a)}`); + assert.match(mountCookie('sid=x; Expires=Wed, 21 Oct 2030 07:28:00 GMT', base, '/dir/page')!, new RegExp(`Path=${base}/dir;`)); + assert.match(mountCookie('sid=x; Max-Age=10; Expires=Wed, 21 Oct 2030 07:28:00 GMT', base)!, /Max-Age=10/); +}); diff --git a/tests/forwarding-routes.test.ts b/tests/forwarding-routes.test.ts new file mode 100644 index 0000000..5847c32 --- /dev/null +++ b/tests/forwarding-routes.test.ts @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { forwardingRoute, trustedForwardRoute } from '../src/forwarding/routes.ts'; +import { ForwardingState } from '../src/forwarding/state.ts'; + +const sessionId = 'a'.repeat(64); + +function makeEnv(previewOrigin?: string, appOrigin?: string) { + const calls: Array<{ request: Request; id: string }> = []; + const env = { + PREVIEW_ORIGIN: previewOrigin, + APP_ORIGIN: appOrigin, + SSH_SESSIONS: { + idFromString(id: string) { return id; }, + get(id: string) { + return { fetch(request: Request) { calls.push({ request, id }); return Promise.resolve(new Response('ok')); } }; + }, + }, + } as never; + return { env, calls }; +} + +test('GET reports preview availability only when configured', async () => { + assert.deepEqual(await (await forwardingRoute(new Request('https://main.test/api/forward'), makeEnv().env, 'acct')).json(), { previewAvailable: false }); + assert.deepEqual(await (await forwardingRoute(new Request('https://main.test/api/forward'), makeEnv('https://preview.test').env, 'acct')).json(), { previewAvailable: true }); +}); + +test('trusted POST requires explicit confirmation and forwards trusted mode', async () => { + const missing = makeEnv(); + const rejected = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 80 }), + }), missing.env, 'acct'); + assert.equal(rejected.status, 400); assert.equal(missing.calls.length, 0); + + const configured = makeEnv(); + const accepted = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 80, trusted: true }), + }), configured.env, 'acct'); + assert.equal(accepted.status, 200); assert.equal(configured.calls.length, 1); + assert.equal(await configured.calls[0].request.text(), JSON.stringify({ port: 80, mode: 'trusted' })); + assert.equal(configured.calls[0].request.headers.get('x-preview-origin'), 'https://main.test'); +}); + +test('isolated mode without preview configuration returns 503 and invalid mode returns 400', async () => { + const isolated = makeEnv(); + const result = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 80, mode: 'isolated' }), + }), isolated.env, 'acct'); + assert.equal(result.status, 503); assert.equal(isolated.calls.length, 0); + const invalid = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 80, mode: 'other', trusted: true }), + }), isolated.env, 'acct'); + assert.equal(invalid.status, 400); +}); + +test('DELETE works without preview configuration', async () => { + const state = makeEnv(); + const result = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, { method: 'DELETE' }), state.env, 'acct'); + assert.equal(result.status, 200); assert.equal(state.calls.length, 1); +}); + +test('trusted forwarding validates session, strips token, derives path, and preserves body', async () => { + const state = makeEnv(); + // 传入可复用的字符串 body,避免 Node 对转发 ReadableStream 强制要求 duplex: half。 + const request = { + method: 'POST', url: `https://main.test/_forward/${sessionId}/form?a=1`, + headers: new Headers({ 'x-account-id': 'forged', 'x-preview-token': 'secret' }), body: 'payload', + } as unknown as Request; + assert.equal((await trustedForwardRoute(request, state.env, 'real-account')).status, 200); + const forwarded = state.calls[0].request; + assert.equal(forwarded.headers.get('x-account-id'), 'real-account'); + assert.equal(forwarded.headers.get('x-preview-token'), null); + assert.equal(forwarded.headers.get('x-preview-path'), '/form?a=1'); + assert.equal(await forwarded.text(), 'payload'); + assert.equal((await trustedForwardRoute(new Request('https://main.test/_forward/not-hex/form'), state.env, 'acct')).status, 404); + assert.equal((await trustedForwardRoute(new Request(`https://main.test/_forward/${sessionId}/form`, { headers: { 'Service-Worker': 'script' } }), state.env, 'acct')).status, 403); +}); + +function fakeSession() { + return { isForwardReady: () => true, openForward: async () => ({ close: async () => {} }) } as never; +} + +test('ForwardingState rejects cross-mode grants and clear invalidates both paths', async () => { + const state = new ForwardingState(); + await state.create(fakeSession(), 80, 'https://main.test', sessionId, 'isolated'); + assert.equal((await state.trusted(new Request('https://main.test/'))).status, 410); + state.clear(); + await state.create(fakeSession(), 80, 'https://main.test', sessionId, 'trusted'); + assert.equal((await state.preview(new Request('https://main.test/'))).status, 410); + state.clear(); + assert.equal((await state.trusted(new Request('https://main.test/'))).status, 410); + assert.equal((await state.preview(new Request('https://main.test/'))).status, 410); +}); diff --git a/vite.config.ts b/vite.config.ts index db3fd3d..3898217 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ server: { port: 5173, proxy: { + '/_forward': { + target: 'http://127.0.0.1:8787', + }, '/auth': { target: 'http://127.0.0.1:8787', },