import { type PlatformBrowserSurface } from '../platform/binding.js'; /** 提交控制类请求时携带 CSRF 票据的头名。`
` 设不了自定义头,这是关键。 */ export declare const CONTROL_CSRF_HEADER = "x-botmux-csrf"; /** 页面注入票据用的 meta 名(SPA 与 preview guard 壳都读它)。 */ export declare const CONTROL_CSRF_META_NAME = "botmux-csrf"; export type ControlRequestOriginState = 'same-origin' | 'foreign' | 'unknown'; export interface ControlRequestHeadersLike { origin?: string | string[]; host?: string | string[]; 'sec-fetch-site'?: string | string[]; [key: string]: string | string[] | undefined; } /** * `Origin` 是否与本请求自己的 authority 同源。两边都归一化成 hostname + 有效端 * 口后比较,不比 scheme:中心化平台在 TLS 终止后把明文转给本进程,浏览器发的是 * `https://…` 而本地看到的是明文连接,比 scheme 会把正常访问全判成跨站。 * hostname + 端口相等已经足以区分兄弟子域与其它端口——那正是本条要防的。 * * 端口必须两边对称归一化:`new URL('https://dash.example').host` 会被 URL 规范 * 剥成 `dash.example`,而 `Host` 头是原始字符串,反代常写成 `dash.example:443`。 * 早先直接比字符串,于是 `proxy_set_header Host $host:$server_port;` 这类广为流 * 传的配置会把自己人的控制请求全判成跨站。 * * `host` 可以给多个候选:反代/平台隧道下 `Host` 可能已被改写成回环地址,浏览器 * 真正看到的域名在 `X-Forwarded-Host` 里。这不削弱防线——浏览器不允许页面设置 * `Origin`,而 `X-Forwarded-Host` 是自定义头,跨站 `` 设不了、跨站 fetch * 会先触发预检(本服务不答 CORS,预检直接失败),所以攻击页两个头都伪造不了。 */ export declare function originMatchesHost(origin: string | undefined, host: string | undefined | ReadonlyArray): boolean; /** * 综合 `Sec-Fetch-Site` 与 `Origin` 判定来源。任一信号明确说「不是同源」就是 * `foreign`;两个信号都缺席是 `unknown`(非浏览器客户端),由调用方决定 fail * closed 还是放行。 */ export declare function controlRequestOriginState(headers: ControlRequestHeadersLike): ControlRequestOriginState; /** * `Referer` 兜底的同源判定:请求既没带 `Origin` 也没带 `Sec-Fetch-Site` 时,用 * Referer 的 origin 与本请求的候选 authority 比一次(复用上面那套归一化,不另写 * 一份比较逻辑)。 * * 为什么需要它:同源 **GET** 在浏览器里通常两个信号都没有——`Origin` 只在跨源和 * 非 GET 请求上发,`Sec-Fetch-*` 是较新的浏览器才有(Safari 16.4 之前没有)。对 * 「有副作用的 POST」两信号缺席一律 fail closed 是对的(那条路径没有需要放行的非 * 浏览器调用方),但一条**发放凭证的 GET** 若照搬那条规则,会把老浏览器上的真 * owner 一起挡在门外。Referer 在默认的 `strict-origin-when-cross-origin` 策略下 * 对同源请求带完整 URL,正好补上这一格。 * * 这不放宽防线:Referer 同样是浏览器写、页面改不了的头,而**明确判为跨站** * (`controlRequestOriginState` 已经返回 `foreign`)的请求绝不会走到这里——调用方 * 只在 `unknown` 时才用它兜底。 */ export declare function refererMatchesHost(headers: ControlRequestHeadersLike): boolean; /** 本进程会受理的两类管理类 WS 升级,外加「都不是」。 */ export type ManagementUpgradeRoute = 'session-terminal' | 'debug-terminal' | 'unknown'; export interface ManagementUpgradeClassification { route: ManagementUpgradeRoute; /** 该路径允许把哪一档平台子域算作同源(见 {@link platformBrowserAuthorities})。 */ surface: PlatformBrowserSurface; } /** * 按升级请求的 **path** 决定「这条 WS 的可信来源有多宽」。 * * 两条路径的另一头根本不是一个东西,信任面不该共用: * - `/s/`:会话终端。页面被平台分享出去时住在 `t-` 子域, * 它的握手 Origin 就是 `t-`,所以这一档必须认 `m-` + `t-`(#933/#960 修的正是 * 这条,缺了它平台浏览器终端整片 disconnected)。写权限另有 `?token=` 与 worker * 侧的 write-auth 把关,认这条 Origin 不等于给写。 * - `/debug-terminal//ws`:**宿主裸 bash**,只有本机管理壳页会开它。它的可信 * 来源就该跟 `/api/debug-terminal` 的 HTTP 门禁一样窄——management 档(`m-` / * 本机 Host / `BOTMUX_PUBLIC_URL`),`t-` 一律不算。 * * 未识别的路径落窄档并标 `unknown`:调用方本来就会把它 destroy,这里只是保证 * 「漏加一条前缀」的失败方向是「连不上」,而不是静默把 `t-` 认成同源。 */ export declare function classifyManagementUpgrade(rawUrl: string): ManagementUpgradeClassification; /** * 管理类 WebSocket 升级的 Origin 判定(terminal / debug-terminal)。 * 带 Origin 就必须同源(`null` 也算带了,直接拒);不带 Origin 视为非浏览器客 * 户端放行。Preview 自身的 WS 不走这里,见文件头注释。 * * `surface` 由调用方按 path 分流后给({@link classifyManagementUpgrade})。缺省是 * **窄档**:漏传参数只会把平台终端子域挡在门外(响、一眼可辨),不会反过来把裸 * bash 那条 WS 的可信来源悄悄放宽。 */ export declare function managementUpgradeOrigin(headers: ControlRequestHeadersLike, surface?: PlatformBrowserSurface): { ok: true; } | { ok: false; error: 'upgrade_origin_forbidden'; }; export interface ControlCsrfTokensOptions { /** 闲置寿命(每次成功校验续期)。 */ ttlMs?: number; maxTokens?: number; now?: () => number; randomToken?: () => string; } /** * 一次性签发、绑定认证会话的 CSRF 票据表。 * * - **一次性签发**:每次页面加载现签一枚随机票据,不是从长期密钥推导的稳定值, * 页面刷新即换新票,旧票随 TTL / 认证结束消失。 * - **绑定认证会话**:校验时必须与当前请求解析出的 authSessionId 一致,所以一 * 枚票据换不到别人的身份,登出后也立刻作废(`revokeAuthSession`)。 * - 只存 SHA-256 摘要,明文票据只在注入页面和请求头里存在。 * * 不做「用一次即焚」:工作台一次交互会连打多条控制请求(unlock 之后每 20s 一条 * activity),单次即焚会退化成每个操作都要先取票,多一次往返还会在弱网下把解锁 * 变成必然失败。这里的安全性来自「跨站拿不到票 + 同源证明」,不来自使用次数。 */ export declare class ControlCsrfTokens { private readonly byHash; private readonly hashesByAuthSession; private readonly ttlMs; private readonly maxTokens; private readonly now; private readonly randomToken; constructor(opts?: ControlCsrfTokensOptions); mint(authSessionId: string): string; verify(token: string | undefined, authSessionId: string): boolean; /** 认证结束(logout / 到期 / rotate / 解绑)时作废该会话的全部票据。 */ revokeAuthSession(authSessionId: string): number; size(): number; private forget; private sweep; } /** * 把本次页面加载现签的票据注入 HTML 壳(SPA index.html / preview guard 壳)。 * 票据只进 ``:跨站页面读不到本源 DOM,`` 也设不了自定义头,所以 * 「无 body 表单触发接管/解锁」这条路就此断掉。注入后的壳必须 `no-store`, * 否则浏览器会把已作废的票据缓存下来复用。 */ export declare function injectControlCsrfMeta(html: string, token: string): string; export type ControlRequestGuardResult = { ok: true; } | { ok: false; status: 403; error: 'control_origin_forbidden' | 'control_csrf_invalid'; }; /** * 控制类请求(takeover/release、preview-interaction unlock/activity/lock、 * locate 等有副作用且可被无 body 表单触发的 POST)的统一门禁。 * 调用点必须已经解析出身份——匿名请求在路由层就该 401,这里只管跨站伪造。 */ export declare function guardControlRequest(input: { headers: ControlRequestHeadersLike; authSessionId: string; tokens: ControlCsrfTokens; }): ControlRequestGuardResult; //# sourceMappingURL=control-csrf.d.ts.map