fix(web): keep browser authentication synchronous

This commit is contained in:
Tianyi Cui
2026-08-25 14:23:47 +08:00
parent 5595d593d1
commit 9c964848cd
19 changed files with 154 additions and 152 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
2026-08-24-browser-token-authentication.md: d04e655a9e4f25623e45f7c85922352db602da36
2026-08-24-browser-token-authentication.zh.md: ff66630e9fbb77f7cba2c0af16693af724a85c70
2026-08-24-browser-token-authentication.md: c75f561d9529296ff668791c29453e522f309cc3
2026-08-24-browser-token-authentication.zh.md: d4a5059619fefda9d9060e9879d10c0a2197f8f3
@@ -16,7 +16,7 @@ Each Host process generates a random launch token, retained by the root applicat
The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling.
The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is created when Connection starts; after runtime deletion, the next valid process-token exchange or process start recreates it. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker.
@@ -24,7 +24,7 @@ The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not im
## Verification
Unit coverage pins process-token retention across Connection reloads, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart.
Unit coverage pins process-token retention across Connection reloads, one secret load per activation, synchronous verification without credential-provider reads, cookie attributes, HMAC and payload validation, authority and lifetime checks, record deletion taking effect on the next activation, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart.
## Alternatives considered
@@ -36,12 +36,12 @@ Unit coverage pins process-token retention across Connection reloads, cookie att
**Rotate the signing secret on every restart.** This prevents an existing browser from reconnecting after an ordinary DSH restart. Persisting only the signing secret keeps that workflow while process-token rotation limits the startup URL to one process lifetime.
**Add logout, TLS-proxy, and forwarding-header configuration.** None is required by the loopback Web application or the reported authentication gap. Adding them would define deployment contracts without current consumers. Browser site-data controls and credential-record deletion provide the two revocation operations this decision needs.
**Add logout, TLS-proxy, and forwarding-header configuration.** None is required by the loopback Web application or the reported authentication gap. Adding them would define deployment contracts without current consumers. Browser site-data controls revoke one browser session; deleting the credential record and restarting the process revokes all sessions.
## Consequences
Possession of the browser cookie authorizes the complete tool-capable Host API, matching the authority the Web application exposes after Session creation. `Host` does not grant a higher method tier, and a method migration between API Proxy and Typert Remote cannot change its caller set.
The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime; deletion or rotation of the record is the global revocation mechanism. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it.
The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime. Deleting the record and restarting the process is the global revocation mechanism; the active Connection intentionally avoids credential-provider work on each request. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it.
The decision partially supersedes the authentication deferral and unauthenticated non-loopback consequences in the [browser trust note](2026-07-28-api-browser-trust-boundary.md). That note remains active authority for media-type, Host, Origin, Fetch-Metadata, and configured-authority validation. No active Agent Note is archived: the overlap is partial and both security rules retain future decision value.
@@ -16,7 +16,7 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/``HttpOnly``SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。
HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录在 Connection 启动时创建;运行期删除后,由下一次有效进程令牌交换或进程启动重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。
@@ -24,7 +24,7 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session`
## 验证
单元覆盖 Connection 重载时保留进程令牌、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。
单元覆盖 Connection 重载时保留进程令牌、每次激活只加载一次密钥、无需读取凭据提供方的同步校验、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、记录删除在下一次激活时生效、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。
## 曾考虑的替代方案
@@ -36,12 +36,12 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session`
**每次重启都轮换签名密钥。** 这会阻止既有浏览器在普通 DSH 重启后重连。只持久化签名密钥既保留该工作流,又由进程令牌轮换把启动 URL 限定在一个进程生命周期。
**增加 logout、TLS 代理和转发 header 配置。** loopback Web 应用与已报告认证缺口都不需要这些能力;加入它们会在没有当前 consumer 时定义部署约定。浏览器站点数据控制与凭据记录删除已经提供本决策所需的两种撤销操作
**增加 logout、TLS 代理和转发 header 配置。** loopback Web 应用与已报告认证缺口都不需要这些能力;加入它们会在没有当前 consumer 时定义部署约定。浏览器站点数据控制会撤销单个浏览器会话;删除凭据记录并重启进程会撤销全部会话
## 后果
持有浏览器 cookie 就能调用完整的工具型 Host API,这与 Web 应用在创建 Session 后暴露的 authority 一致。`Host` 不授予更高的方法层级,方法在 API Proxy 与 Typert Remote 之间迁移也不会改变调用者集合。
持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期删除或轮换记录是全局撤销机制。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。
持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期删除记录并重启进程是全局撤销机制;当前 Connection 刻意避免在每个请求上访问凭据提供方。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。
本决策部分取代[浏览器信任说明](2026-07-28-api-browser-trust-boundary.zh.md)中的认证延期与未认证非 loopback 后果。该说明仍是媒体类型、Host、Origin、Fetch-Metadata 和配置 authority 校验的有效权威。没有 active Agent Note 被归档:重叠只发生在局部,两条安全规则都保有未来决策价值。
+2 -2
View File
@@ -192,8 +192,8 @@ export class TypertGatewayService extends Service implements TypertGateway {
webCtx.effect(() => {
const route: WebUpgradeRoute = {
path: REMOTE_STREAM_MUX_PATH,
handler: async (req, socket, head) => {
const rejection = await webCtx.connection.requestRejection(req)
handler: (req, socket, head) => {
const rejection = webCtx.connection.requestRejection(req)
if (rejection !== undefined) {
rejectRemoteStreamUpgrade(socket, rejection)
return
@@ -33,31 +33,29 @@ vi.mock('node:crypto', async (importOriginal) => {
})
const randomUuid = vi.mocked(randomUUID)
const browserCookies = new WeakMap<Context, Promise<string>>()
const browserCookies = new WeakMap<Context, string>()
type AgentWireId = TypertContextWire<TypertContextMap['agent']>
const agentId = (value: string): AgentWireId => value as AgentWireId
/** Exchange this test Host's process token for its WebSocket/HTTP Cookie header. */
function browserCookie(ctx: Context): Promise<string> {
function browserCookie(ctx: Context): string {
const existing = browserCookies.get(ctx)
if (existing !== undefined) return existing
const exchange = (async () => {
const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
const target = new URL(ctx.connection.authenticatedUrl(origin))
let setCookie: string | undefined
await ctx.connection.authorizeIndex({
method: 'GET',
url: `${target.pathname}${target.search}`,
headers: { host: target.host },
}, {
writeHead(_status, headers) { setCookie = headers?.['set-cookie'] },
end() {},
})
if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie')
return setCookie.split(';', 1)[0]!
})()
browserCookies.set(ctx, exchange)
return exchange
const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
const target = new URL(ctx.connection.authenticatedUrl(origin))
let setCookie: string | undefined
ctx.connection.authorizeIndex({
method: 'GET',
url: `${target.pathname}${target.search}`,
headers: { host: target.host },
}, {
writeHead(_status, headers) { setCookie = headers?.['set-cookie'] },
end() {},
})
if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie')
const cookie = setCookie.split(';', 1)[0]!
browserCookies.set(ctx, cookie)
return cookie
}
class FeedService extends Service {
@@ -285,7 +283,7 @@ describe('Typert Remote streams', () => {
it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => {
const { ctx, service } = await setup(true)
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
headers: { cookie: await browserCookie(ctx) },
headers: { cookie: browserCookie(ctx) },
})
await once(socket, 'open')
const frames: Record<string, unknown>[] = []
@@ -369,7 +367,7 @@ describe('Typert Remote streams', () => {
.toThrow('forwarded Remote event source is already registered')
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
headers: { cookie: await browserCookie(ctx) },
headers: { cookie: browserCookie(ctx) },
})
await once(socket, 'open')
const frames: Record<string, unknown>[] = []
@@ -891,7 +889,7 @@ describe('Typert Remote streams', () => {
it('validates the internal Remote event request and reports an absent source', async () => {
const { ctx } = await setup(true)
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
headers: { cookie: await browserCookie(ctx) },
headers: { cookie: browserCookie(ctx) },
})
await once(socket, 'open')
const frames: Record<string, unknown>[] = []
@@ -1039,7 +1037,7 @@ interface RemoteEventTestClient {
async function openEventClient(ctx: Context, streamId: string): Promise<RemoteEventTestClient> {
const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
const cookie = await browserCookie(ctx)
const cookie = browserCookie(ctx)
const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`, {
headers: { cookie },
})
@@ -134,8 +134,8 @@ class FakeConnectionService extends Service {
}
}
requestRejection(): Promise<undefined> {
return Promise.resolve(undefined)
requestRejection(): undefined {
return undefined
}
}
@@ -171,10 +171,10 @@ async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; c
}
/** Exchange a Connection launch token without mounting the frontend fallback. */
async function browserCookie(connection: HostConnectionHandle, origin: string): Promise<string> {
function browserCookie(connection: HostConnectionHandle, origin: string): string {
const target = new URL(connection.authenticatedUrl(origin))
let setCookie: string | undefined
await connection.authorizeIndex({
connection.authorizeIndex({
method: 'GET',
url: `${target.pathname}${target.search}`,
headers: { host: target.host },
@@ -1182,7 +1182,7 @@ describe('TypertGatewayService', () => {
let strictActive = true
expect(routes).toHaveLength(1)
const server = await serveRoute(routes[0]!)
const cookie = await browserCookie(ctx.connection, server.origin)
const cookie = browserCookie(ctx.connection, server.origin)
try {
const response = await fetch(`${server.origin}/api/goals/create`, {
+4 -6
View File
@@ -113,12 +113,10 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
}
const server = createServer((request, response) => {
if ((request.url ?? '/').startsWith('/?')) {
void host.connection.authorizeIndex(request, response).then(authorized => {
if (authorized) {
response.writeHead(200, { 'content-type': 'text/html' })
response.end('<body>shell</body>')
}
})
if (host.connection.authorizeIndex(request, response)) {
response.writeHead(200, { 'content-type': 'text/html' })
response.end('<body>shell</body>')
}
return
}
void routes[0].handler(request, response)
@@ -81,8 +81,8 @@ describe('web app browser startup', () => {
__dshWebServer: typeof WebServer
__dshConnection: {
authenticatedUrl(baseUrl: string): string
authorizeIndex(): Promise<boolean>
requestRejection(): Promise<undefined>
authorizeIndex(): boolean
requestRejection(): undefined
rpc: object
}
}
@@ -94,8 +94,8 @@ describe('web app browser startup', () => {
url.searchParams.set('token', 'fixture-token')
return url.href
},
authorizeIndex: () => Promise.resolve(true),
requestRejection: () => Promise.resolve(undefined),
authorizeIndex: () => true,
requestRejection: () => undefined,
rpc: {},
}
@@ -92,8 +92,8 @@ function provideConnection(ctx: Context): void {
url.searchParams.set('token', 'test-token')
return url.href
},
authorizeIndex: () => Promise.resolve(true),
requestRejection: () => Promise.resolve(undefined),
authorizeIndex: () => true,
requestRejection: () => undefined,
rpc: {},
} as never)
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 293e9f9d6b158e325325f4f741a244031b1d2e02
README.zh.md: 1acc4e1748b8067bd18a626dfddfabc2cdcc242d
README.md: 4aa85765b009bd9231687976b43bc923ba41177a
README.zh.md: da784c1ce2d966fe8f650d9ebb3b9c6313271829
+2 -2
View File
@@ -10,7 +10,7 @@ The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gat
Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens the ordinary root URL with `?token=...`; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `/`. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token.
The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` reads the current record for every verification, so deletion or rotation revokes cookies without restarting the process. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP.
The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP.
Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md).
@@ -32,4 +32,4 @@ None; this package neither assembles nor sends a provider request.
- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits.
- **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so deployments that make the same authority reachable over plaintext networking can expose the bearer cookie in transit.
- **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record revokes every session and the next launch-token exchange creates a new signing secret.
- **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session, and the next Connection activation creates a new signing secret.
+2 -2
View File
@@ -10,7 +10,7 @@
每个 Host RPC 方法和 WebSocket stream 都要求同一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。
cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml``BrowserAuth` 每次校验都读取当前记录,因此删除或轮换记录无需重启进程即可撤销 cookie。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/``HttpOnly``SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`
cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml``BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/``HttpOnly``SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`
认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。
@@ -32,4 +32,4 @@ API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation
- **`/api` 桥把每个请求体整体缓冲在内存里**`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。
- **浏览器 cookie 不带 `Secure`**:随附载体是 loopback HTTP;若部署把同一 authority 经明文网络暴露,bearer cookie 可能在传输中泄露。
- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录会撤销全部会话,下一次启动令牌交换会创建新的签名密钥。
- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话,下一次 Connection 激活会创建新的签名密钥。
+30 -35
View File
@@ -158,11 +158,29 @@ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | und
return decoded as unknown as BrowserCookiePayload
}
async function initializeSecret(credentials: CredentialProvider): Promise<Buffer> {
const generated: StoredSecretPayload = {
version: STORED_SECRET_VERSION,
secret: encodeBase64Url(randomBytes(SECRET_BYTES)),
}
const record = await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => {
if (current !== undefined) {
storedSecret(current)
return Promise.resolve(undefined)
}
return Promise.resolve({ kind: 'grant', payload: generated })
})
const secret = storedSecret(record)
if (secret === undefined) {
throw new Error('client-connection: browser-session credential record was not created')
}
return secret
}
/**
* Process launch-token exchange and persistent signed-cookie verification.
* The credential provider owns the signing secret; this object reads it for
* each operation so deletion or rotation revokes existing cookies without a
* process restart.
* Connection loads the credential provider's signing secret during activation
* and retains it for synchronous request authentication.
*/
export class BrowserAuth {
private readonly launchToken: string
@@ -170,7 +188,7 @@ export class BrowserAuth {
private constructor(
processOwner: object,
private readonly credentials: CredentialProvider,
private readonly secret: Buffer,
maxAgeDays: number,
) {
this.launchToken = processLaunchToken(processOwner)
@@ -194,9 +212,7 @@ export class BrowserAuth {
credentials: CredentialProvider,
maxAgeDays: number,
): Promise<BrowserAuth> {
const auth = new BrowserAuth(processOwner, credentials, maxAgeDays)
await auth.ensureSecret()
return auth
return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays)
}
/**
@@ -221,7 +237,7 @@ export class BrowserAuth {
* @param res - response owned when this method returns false.
* @returns true only when the caller may serve index.html.
*/
async authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): Promise<boolean> {
authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean {
/* v8 ignore next -- node:http always supplies url on server requests. */
const url = new URL(req.url ?? '/', 'http://dsh.invalid')
const tokens = url.searchParams.getAll(TOKEN_QUERY)
@@ -236,7 +252,7 @@ export class BrowserAuth {
authority,
issuedAt,
expiresAt,
}, await this.ensureSecret())
}, this.secret)
res.writeHead(303, {
'cache-control': 'no-store',
'location': '/',
@@ -248,7 +264,7 @@ export class BrowserAuth {
res.end()
return false
}
if (req.method === 'GET' && url.pathname === '/' && await this.isAuthenticated(req)) {
if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req)) {
res.writeHead(303, {
'cache-control': 'no-store',
'location': '/',
@@ -260,7 +276,7 @@ export class BrowserAuth {
this.writeUnauthorized(req, res)
return false
}
if (await this.isAuthenticated(req)) return true
if (this.isAuthenticated(req)) return true
this.writeUnauthorized(req, res)
return false
}
@@ -268,17 +284,15 @@ export class BrowserAuth {
/**
* Verify the authority-bound browser cookie on a Host request.
* @param request - request headers carrying Host and Cookie.
* @returns true only for an unexpired cookie signed by the current durable secret.
* @returns true only for an unexpired cookie signed by this activation's loaded secret.
*/
async isAuthenticated(request: ConnectionTrustRequest): Promise<boolean> {
isAuthenticated(request: ConnectionTrustRequest): boolean {
const authority = requestAuthority(request.headers)
const rawCookie = header(request.headers, 'cookie')
if (authority === undefined || rawCookie === undefined) return false
const value = cookieValue(rawCookie, cookieName(authority))
if (value === undefined) return false
const secret = storedSecret(await this.credentials.readRecord(AUTH_RECORD_KEY))
if (secret === undefined) return false
const payload = decodeCookie(value, secret)
const payload = decodeCookie(value, this.secret)
if (payload === undefined || payload.authority !== authority) return false
const now = Date.now()
return payload.issuedAt <= now
@@ -287,25 +301,6 @@ export class BrowserAuth {
&& payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds
}
private async ensureSecret(): Promise<Buffer> {
const generated: StoredSecretPayload = {
version: STORED_SECRET_VERSION,
secret: encodeBase64Url(randomBytes(SECRET_BYTES)),
}
const record = await this.credentials.modifyRecord(AUTH_RECORD_KEY, (current) => {
if (current !== undefined) {
storedSecret(current)
return Promise.resolve(undefined)
}
return Promise.resolve({ kind: 'grant', payload: generated })
})
const secret = storedSecret(record)
if (secret === undefined) {
throw new Error('client-connection: browser-session credential record was not created')
}
return secret
}
private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void {
res.writeHead(401, {
'cache-control': 'no-store',
+1 -1
View File
@@ -106,7 +106,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
const rejection = await connection.requestRejection(req)
const rejection = connection.requestRejection(req)
if (rejection !== undefined) {
res.writeHead(rejection)
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
+4 -4
View File
@@ -77,13 +77,13 @@ export class HostConnectionService extends Service implements HostConnectionHand
}
/** Apply the configured Host/Origin fence, then browser authentication. */
async requestRejection(request: ConnectionTrustRequest): Promise<ConnectionRequestRejection> {
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection {
if (!isTrustedApiRequest(request, this.trustedHosts)) return 403
return await this.browserAuth.isAuthenticated(request) ? undefined : 401
return this.browserAuth.isAuthenticated(request) ? undefined : 401
}
/** Authenticate an index request through the process-token exchange or cookie. */
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): Promise<boolean> {
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean {
return this.browserAuth.authorizeIndex(request, response)
}
@@ -125,7 +125,7 @@ export class HostConnectionService extends Service implements HostConnectionHand
kind: 'prefix',
path: channel,
handler: async (req, res) => {
const rejection = await this.requestRejection(req)
const rejection = this.requestRejection(req)
if (rejection !== undefined) {
res.writeHead(rejection)
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
+2 -2
View File
@@ -81,7 +81,7 @@ export interface HostConnectionHandle {
* @param request - request headers from the HTTP or upgrade request.
* @returns rejection status, or undefined when the route may accept the request.
*/
requestRejection(request: ConnectionTrustRequest): Promise<ConnectionRequestRejection>
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection
/**
* Authenticate one frontend index request, owning a token redirect or 401.
@@ -89,7 +89,7 @@ export interface HostConnectionHandle {
* @param response - response owned when the result is false.
* @returns true only when the frontend may serve index.html.
*/
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): Promise<boolean>
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean
/**
* Add the fresh process token to an ordinary Web application URL.
@@ -9,8 +9,11 @@ import type { ConnectionIndexRequest, ConnectionIndexResponse } from '../src/rpc
class RecordCredentials {
record: CredentialRecord | undefined
discardWrites = false
reads = 0
modifies = 0
readRecord(): Promise<CredentialRecord | undefined> {
this.reads += 1
return Promise.resolve(this.record)
}
@@ -18,6 +21,7 @@ class RecordCredentials {
_key: unknown,
mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
): Promise<CredentialRecord | undefined> {
this.modifies += 1
const next = await mutate(this.record)
if (this.discardWrites) return undefined
if (next !== undefined) this.record = next
@@ -96,14 +100,14 @@ function request(url: string, authority = '127.0.0.1:3080', init?: {
}
}
async function exchange(
function exchange(
auth: BrowserAuth,
authority = '127.0.0.1:3080',
): Promise<{ cookie: string; launchUrl: string; state: ResponseState }> {
): { cookie: string; launchUrl: string; state: ResponseState } {
const launchUrl = auth.authenticatedUrl(`http://${authority}`)
const target = new URL(launchUrl)
const res = response()
expect(await auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false)
expect(auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false)
const setCookie = res.state.headers?.['set-cookie']
if (setCookie === undefined) throw new Error('token exchange did not set a cookie')
return { cookie: setCookie.split(';', 1)[0]!, launchUrl, state: res.state }
@@ -118,7 +122,7 @@ describe('BrowserAuth', () => {
const store = new RecordCredentials()
const processOwner = {}
const first = await createAuth(store, 30, processOwner)
const login = await exchange(first)
const login = exchange(first)
expect(login.state).toMatchObject({
status: 303,
@@ -130,25 +134,25 @@ describe('BrowserAuth', () => {
})
expect(login.state.headers?.['set-cookie']).toMatch(/; Max-Age=2592000; Path=\/; Expires=.*; HttpOnly; SameSite=Strict$/u)
expect(login.state.headers?.['set-cookie']).not.toContain('Secure')
expect(await first.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
expect(await first.isAuthenticated({
expect(first.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
expect(first.isAuthenticated({
headers: new Headers({ host: '127.0.0.1:3080', cookie: login.cookie }),
})).toBe(true)
expect(await first.isAuthenticated({ headers: new Headers() })).toBe(false)
expect(await first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false)
expect(await first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false)
expect(first.isAuthenticated({ headers: new Headers() })).toBe(false)
expect(first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false)
expect(first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false)
const reloaded = await createAuth(store, 30, processOwner)
expect(reloaded.authenticatedUrl('http://127.0.0.1:3080')).toBe(login.launchUrl)
expect(await reloaded.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
expect(reloaded.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
const restarted = await createAuth(store)
expect(new URL(restarted.authenticatedUrl('http://127.0.0.1:3080')).searchParams.get('token'))
.not.toBe(new URL(login.launchUrl).searchParams.get('token'))
expect(await restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
expect(restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
const staleUrl = new URL(login.launchUrl)
const redirected = response()
expect(await restarted.authorizeIndex(request(
expect(restarted.authorizeIndex(request(
`${staleUrl.pathname}${staleUrl.search}`,
'127.0.0.1:3080',
{ cookie: login.cookie },
@@ -165,9 +169,9 @@ describe('BrowserAuth', () => {
it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => {
const auth = await createAuth(new RecordCredentials())
const { cookie } = await exchange(auth)
const { cookie } = exchange(auth)
const allowed = response()
expect(await auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true)
expect(auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true)
expect(allowed.state).toEqual({})
for (const candidate of [
@@ -178,7 +182,7 @@ describe('BrowserAuth', () => {
request(auth.authenticatedUrl('http://127.0.0.1:3080'), '127.0.0.1:3080', { method: 'HEAD' }),
]) {
const denied = response()
expect(await auth.authorizeIndex(candidate, denied.value)).toBe(false)
expect(auth.authorizeIndex(candidate, denied.value)).toBe(false)
expect(denied.state.status).toBe(401)
expect(denied.state.headers).toEqual({
'cache-control': 'no-store',
@@ -195,18 +199,18 @@ describe('BrowserAuth', () => {
vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z'))
const store = new RecordCredentials()
const auth = await createAuth(store)
const { cookie } = await exchange(auth)
const { cookie } = exchange(auth)
const [name, value] = cookie.split('=') as [string, string]
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false)
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false)
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false)
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', {
expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false)
expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false)
expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false)
expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', {
cookie: signedBodyCookie(store, name, 'a'),
}))).toBe(false)
expect(await auth.isAuthenticated({ headers: {} })).toBe(false)
expect(await auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false)
expect(await auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false)
expect(auth.isAuthenticated({ headers: {} })).toBe(false)
expect(auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false)
expect(auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false)
const invalidPayloads: unknown[] = [
'not json',
@@ -217,30 +221,37 @@ describe('BrowserAuth', () => {
{ version: 1, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: 'later' },
]
for (const payload of invalidPayloads) {
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', {
expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', {
cookie: signedCookie(store, name, payload),
}))).toBe(false)
}
const shorter = await createAuth(store, 1)
expect(await shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
expect(shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
vi.setSystemTime(new Date('2026-09-24T00:00:00.000Z'))
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
vi.setSystemTime(new Date('2026-08-23T00:00:00.000Z'))
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
})
it('revokes on record deletion and creates a new secret on the next token exchange', async () => {
it('loads one secret per activation and replaces it after deletion on the next activation', async () => {
const store = new RecordCredentials()
const auth = await createAuth(store)
const first = await exchange(auth)
await store.deleteRecord()
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false)
const first = exchange(auth)
expect(store).toMatchObject({ reads: 0, modifies: 1 })
const second = await exchange(auth)
await store.deleteRecord()
expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(true)
const sameActivation = exchange(auth)
expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: sameActivation.cookie }))).toBe(true)
expect(store).toMatchObject({ reads: 0, modifies: 1 })
const reactivated = await createAuth(store)
const second = exchange(reactivated)
expect(second.cookie).not.toBe(first.cookie)
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false)
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: second.cookie }))).toBe(true)
expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false)
expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: second.cookie }))).toBe(true)
expect(store).toMatchObject({ reads: 0, modifies: 2 })
})
it('fails loud on an invalid owner record instead of replacing it', async () => {
@@ -106,10 +106,10 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
}
/** Exchange a service's process token for one authority-bound Cookie header. */
async function browserCookie(connection: HostConnectionHandle, authority: string): Promise<string> {
function browserCookie(connection: HostConnectionHandle, authority: string): string {
const url = new URL(connection.authenticatedUrl(`http://${authority}`))
const exchanged = fakeResponse()
await connection.authorizeIndex(
connection.authorizeIndex(
fakeRequest({ host: authority }, `${url.pathname}${url.search}`),
exchanged.response,
)
@@ -184,7 +184,7 @@ describe('connection node half', () => {
expect([method, denied.state.status, denied.state.body]).toEqual([method, 401, 'unauthorized'])
}
const cookie = await browserCookie(connection, 'harness.example')
const cookie = browserCookie(connection, 'harness.example')
for (const method of methods) {
const allowed = fakeResponse()
await routes[0]!.handler(
@@ -207,7 +207,7 @@ describe('connection node half', () => {
const loopback = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: '127.0.0.1:3080',
cookie: await browserCookie(connection, '127.0.0.1:3080'),
cookie: browserCookie(connection, '127.0.0.1:3080'),
}), loopback.response)
expect(loopback.state.status).toBe(404)
// An all-interfaces composition derives port-less LAN IP literals, which
@@ -215,7 +215,7 @@ describe('connection node half', () => {
const lan = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: '192.168.1.5:3080',
cookie: await browserCookie(connection, '192.168.1.5:3080'),
cookie: browserCookie(connection, '192.168.1.5:3080'),
}), lan.response)
expect(lan.state.status).toBe(404)
// Declared public authority, same-origin browser shape.
@@ -224,7 +224,7 @@ describe('connection node half', () => {
host: 'harness.example:3080',
origin: 'http://harness.example:3080',
'sec-fetch-site': 'same-origin',
cookie: await browserCookie(connection, 'harness.example:3080'),
cookie: browserCookie(connection, 'harness.example:3080'),
}), declared.response)
expect(declared.state.status).toBe(404)
await dispose()
@@ -235,11 +235,11 @@ describe('connection node half', () => {
const loopback = fakeRequest({ host: '127.0.0.1:3080' })
const declared = fakeRequest({ host: 'harness.example' })
expect(await connection.requestRejection(loopback)).toBe(401)
expect(await connection.requestRejection(declared)).toBe(401)
expect(await connection.requestRejection(fakeRequest({
expect(connection.requestRejection(loopback)).toBe(401)
expect(connection.requestRejection(declared)).toBe(401)
expect(connection.requestRejection(fakeRequest({
host: 'harness.example',
cookie: await browserCookie(connection, 'harness.example'),
cookie: browserCookie(connection, 'harness.example'),
}))).toBeUndefined()
await dispose()
})
@@ -272,7 +272,7 @@ describe('connection node half', () => {
const result = fakeResponse()
await route!.handler(fakePost({
host: '127.0.0.1:3080',
cookie: await browserCookie(connection, '127.0.0.1:3080'),
cookie: browserCookie(connection, '127.0.0.1:3080'),
}, '/rpc/goals/create', request), result.response)
expect(result.state.status).toBe(200)
expect(JSON.parse(String(result.state.body))).toEqual({
@@ -330,7 +330,7 @@ describe('connection node half', () => {
}
const claimed = fakeResponse()
const loopbackCookie = await browserCookie(connection, '127.0.0.1:3080')
const loopbackCookie = browserCookie(connection, '127.0.0.1:3080')
await route.handler(fakePost({
host: '127.0.0.1:3080', cookie: loopbackCookie,
}, '/api/goals/create', request), claimed.response)
@@ -371,7 +371,7 @@ describe('connection node half', () => {
const declared = fakeResponse()
await route.handler(fakePost({
host: 'harness.example',
cookie: await browserCookie(connection, 'harness.example'),
cookie: browserCookie(connection, 'harness.example'),
}, '/api/goals/create', request), declared.response)
expect(declared.state.status).toBe(200)
await removeAuthenticated()
@@ -393,7 +393,7 @@ describe('connection node half', () => {
const route = routes.find(candidate => candidate.path === '/rpc')!
const harnessHeaders = {
host: 'harness.example',
cookie: await browserCookie(connection, 'harness.example'),
cookie: browserCookie(connection, 'harness.example'),
}
const denied = fakeResponse()
@@ -514,7 +514,7 @@ describe('connection node half over a real HTTP server', () => {
}
expect(await call(port, 'settings.describe', 'other.example')).toBe(403)
const declaredCookie = await browserCookie(connection, 'harness.example')
const declaredCookie = browserCookie(connection, 'harness.example')
for (const method of methods) {
expect([method, await call(port, method, 'harness.example', declaredCookie)]).toEqual([method, 404])
}
@@ -523,7 +523,7 @@ describe('connection node half over a real HTTP server', () => {
port,
'settings.describe',
loopbackAuthority,
await browserCookie(connection, loopbackAuthority),
browserCookie(connection, loopbackAuthority),
)).toBe(404)
} finally {
await close()
+2 -2
View File
@@ -70,7 +70,7 @@ const STATIC_MISS_CODES: ReadonlySet<string | undefined> = new Set([
*/
export async function serveStatic(
pathname: string, res: ServerResponse, distRoot: string, distIndex: string,
authorizeIndex: () => Promise<boolean>,
authorizeIndex: () => boolean,
renderIndex: () => Promise<string>,
): Promise<void> {
const target = resolve(normalize(join(distRoot, pathname)))
@@ -86,7 +86,7 @@ export async function serveStatic(
let type: string
try {
if (target === distRoot || target === distIndex) {
if (!await authorizeIndex()) return
if (!authorizeIndex()) return
body = await renderIndex()
type = HTML_MIME
} else {