From 9eaf5da20111d2cd107a11229e5113780520ccab Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 1 Sep 2026 05:41:43 +0000 Subject: [PATCH] feat(core): harden build-mode auth with a per-process capability token --- docs/errors/DTK0008.md | 20 ++-- .../src/node/__tests__/auth-handler.test.ts | 53 ++++++++- .../src/node/__tests__/context-auth.test.ts | 13 ++- .../server-build-capability-token.test.ts | 101 ++++++++++++++++++ .../server-client-module-resolution.test.ts | 2 + packages/core/src/node/auth-handler.ts | 74 ++++++++++--- packages/core/src/node/context.ts | 15 +-- packages/core/src/node/server.ts | 51 +++++++-- 8 files changed, 282 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/node/__tests__/server-build-capability-token.test.ts diff --git a/docs/errors/DTK0008.md b/docs/errors/DTK0008.md index 1d4f7dd27..8d7e7366e 100644 --- a/docs/errors/DTK0008.md +++ b/docs/errors/DTK0008.md @@ -10,14 +10,15 @@ outline: deep ## Cause -This warning is emitted by `createWsServer()` when the WebSocket server starts and client authentication has been disabled. Authentication is disabled when any of the following conditions is true: +This warning is emitted when the DevTools hub starts and client authentication has been fully disabled. Authentication is disabled when either of the following is true: -1. The DevTools context is running in **build mode** (`context.mode === 'build'`). -2. The Vite config sets `devtools.config.clientAuth` to `false`. -3. The environment variable `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` is set to `'true'`. +1. The Vite config sets `devtools.config.clientAuth` to `false`. +2. The environment variable `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` is set to `'true'`. When authentication is disabled, every connecting WebSocket client is automatically marked as trusted (`meta.isTrusted = true`), bypassing the token-based auth flow entirely. +Build mode does **not** disable authentication: the standalone build viewer keeps the auth gate installed and trusts clients via an unguessable per-process capability token baked into the locally-served connection metadata. The zero-prompt UX is preserved without trusting arbitrary clients. + ## Example ```ts @@ -43,22 +44,15 @@ Or via environment variable: VITE_DEVTOOLS_DISABLE_CLIENT_AUTH=true vite dev ``` -Build mode also disables auth automatically: - -```sh -vite build # DTK0008 is logged during build -``` - ## Fix -This is an informational warning. No action is required if you intentionally disabled authentication (e.g., in a trusted local environment or during builds). +This is an informational warning. No action is required if you intentionally disabled authentication (e.g., in a trusted local environment). If this warning is unexpected: - Remove `clientAuth: false` from your `devtools.config` in `vite.config.ts`. - Unset the `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` environment variable. -- If running in build mode, the warning is expected and harmless. ## Source -- [`packages/core/src/node/ws.ts`](https://github.com/vitejs/devtools/blob/main/packages/core/src/node/ws.ts) — `createWsServer()` logs this on startup when client authentication is bypassed (build mode, `clientAuth: false`, or `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH=true`). +- [`packages/core/src/node/auth-handler.ts`](https://github.com/vitejs/devtools/blob/main/packages/core/src/node/auth-handler.ts) — `isClientAuthDisabled()` reports when the auth gate is bypassed (`clientAuth: false` or `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH=true`). diff --git a/packages/core/src/node/__tests__/auth-handler.test.ts b/packages/core/src/node/__tests__/auth-handler.test.ts index 2f6867d8b..e80bce6ad 100644 --- a/packages/core/src/node/__tests__/auth-handler.test.ts +++ b/packages/core/src/node/__tests__/auth-handler.test.ts @@ -2,14 +2,14 @@ import type { ResolvedConfig } from 'vite' import type { DevToolsConfig } from '../config' import process from 'node:process' import { describe, expect, it, vi } from 'vitest' -import { getAuthHandler } from '../auth-handler' +import { getAuthHandler, getBuildCapabilityToken, isBuildCapabilityAuth, isClientAuthDisabled } from '../auth-handler' import { createDevToolsContext } from '../context' import '@vitejs/devtools-kit' -function createConfig(config?: Partial): ResolvedConfig { +function createConfig(config?: Partial, command: 'serve' | 'build' = 'serve'): ResolvedConfig { return { root: process.cwd(), - command: 'serve', + command, plugins: [], server: { port: 5173 }, devtools: config === undefined ? undefined : { config }, @@ -41,4 +41,51 @@ describe('getAuthHandler banner', () => { log.mockRestore() } }) + + it('suppresses the OTP banner in implicit build mode (trust is token-based)', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const ctx = await createDevToolsContext(createConfig(undefined, 'build')) + + try { + getAuthHandler(ctx).printBanner() + expect(log).not.toHaveBeenCalled() + } + finally { + log.mockRestore() + } + }) +}) + +describe('build-mode capability token', () => { + it('flags implicit build mode as capability-token auth, not disabled', async () => { + const ctx = await createDevToolsContext(createConfig(undefined, 'build')) + + expect(isBuildCapabilityAuth(ctx)).toBe(true) + expect(isClientAuthDisabled(ctx)).toBe(false) + }) + + it('is not capability-token auth in dev mode', async () => { + const ctx = await createDevToolsContext(createConfig()) + + expect(isBuildCapabilityAuth(ctx)).toBe(false) + }) + + it('leaves an explicit clientAuth:false opt-out fully disabled in build mode', async () => { + const ctx = await createDevToolsContext(createConfig({ clientAuth: false }, 'build')) + + expect(isClientAuthDisabled(ctx)).toBe(true) + expect(isBuildCapabilityAuth(ctx)).toBe(false) + }) + + it('mints a stable, unguessable token per context', async () => { + const ctx = await createDevToolsContext(createConfig(undefined, 'build')) + + const token = getBuildCapabilityToken(ctx) + expect(token).toMatch(/^[\w-]{20,}$/) + // Memoized: the same context always yields the same token. + expect(getBuildCapabilityToken(ctx)).toBe(token) + + const other = await createDevToolsContext(createConfig(undefined, 'build')) + expect(getBuildCapabilityToken(other)).not.toBe(token) + }) }) diff --git a/packages/core/src/node/__tests__/context-auth.test.ts b/packages/core/src/node/__tests__/context-auth.test.ts index d2882a14e..568d0c4b3 100644 --- a/packages/core/src/node/__tests__/context-auth.test.ts +++ b/packages/core/src/node/__tests__/context-auth.test.ts @@ -29,12 +29,17 @@ describe('createDevToolsContext auth registration', () => { expect(ctx.rpc.definitions.has('anonymous:devframe:auth')).toBe(true) }) - it('skips the interactive-auth handshake in build mode (regression #539)', async () => { + it('registers the interactive-auth handshake in build mode for capability-token trust (#552)', async () => { const ctx = await createDevToolsContext(createConfig({ command: 'build' })) - // Left unregistered so devframe's `auth: false` auto-trust shim (armed - // by `createDevToolsHub`) can install its own noop handler and mark the - // session trusted — see `isClientAuthDisabled`. + // Build mode keeps the gate installed and trusts via a per-process + // capability token rather than a prompt — see `isBuildCapabilityAuth`. + expect(ctx.rpc.definitions.has('anonymous:devframe:auth')).toBe(true) + }) + + it('skips the interactive-auth handshake in build mode when clientAuth is explicitly false', async () => { + const ctx = await createDevToolsContext(createConfig({ command: 'build', clientAuth: false })) + expect(ctx.rpc.definitions.has('anonymous:devframe:auth')).toBe(false) }) diff --git a/packages/core/src/node/__tests__/server-build-capability-token.test.ts b/packages/core/src/node/__tests__/server-build-capability-token.test.ts new file mode 100644 index 000000000..725887c99 --- /dev/null +++ b/packages/core/src/node/__tests__/server-build-capability-token.test.ts @@ -0,0 +1,101 @@ +import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createDevToolsHub } from '../server' + +const initHub = vi.hoisted(() => vi.fn()) +const hubMiddleware = vi.hoisted(() => vi.fn()) + +vi.mock('@devframes/hub/initiate', () => ({ + initHub, +})) + +vi.mock('@devframes/json-render-ui/hub', () => ({ + jsonRenderUiRenderer: () => ({ type: 'json-render', file: '/builtin-json-render.mjs' }), +})) + +vi.mock('../ui', () => ({ + createViteDevToolsUi: () => ({}), +})) + +const CAPABILITY_TOKEN = 'build-capability-token' + +vi.mock('../auth-handler', () => ({ + getAuthHandler: () => ({ rpcFunctions: [] }), + isClientAuthDisabled: () => false, + isBuildCapabilityAuth: () => true, + getBuildCapabilityToken: () => CAPABILITY_TOKEN, +})) + +function fakeContext(): ViteDevToolsNodeContext { + return { + mode: 'build', + viteConfig: { devtools: undefined }, + viteServer: undefined, + host: { provideConnectionMeta: vi.fn() }, + } as unknown as ViteDevToolsNodeContext +} + +function fakeRes(): ServerResponse & { body?: string, headers: Record } { + const headers: Record = {} + return { + headers, + setHeader: vi.fn((name: string, value: string) => { + headers[name.toLowerCase()] = value + }), + end: vi.fn(function (this: any, chunk?: string) { + this.body = chunk + }), + } as unknown as ServerResponse & { body?: string, headers: Record } +} + +describe('createDevToolsHub build-mode capability token', () => { + beforeEach(() => { + vi.clearAllMocks() + initHub.mockReturnValue({ + ready: Promise.resolve(), + connectionMeta: () => ({ backend: 'websocket', websocket: { path: '__ws' } }), + nodeMiddleware: hubMiddleware, + close: vi.fn(), + }) + }) + + it('installs the real auth handler rather than the auto-trust shim', async () => { + await createDevToolsHub({ context: fakeContext() }) + + expect(initHub.mock.calls[0]![0].auth).not.toBe(false) + }) + + it('bakes the capability token into the emitted connection meta', async () => { + const { getConnectionMeta } = await createDevToolsHub({ context: fakeContext() }) + + expect(getConnectionMeta()).toMatchObject({ + backend: 'websocket', + authToken: CAPABILITY_TOKEN, + }) + }) + + it('intercepts the top-level connection meta route with the token-augmented meta', async () => { + const { middleware } = await createDevToolsHub({ context: fakeContext() }) + + const res = fakeRes() + const next = vi.fn() + middleware({ url: '/__devtools/__connection.json' } as IncomingMessage, res, next) + + expect(next).not.toHaveBeenCalled() + expect(hubMiddleware).not.toHaveBeenCalled() + expect(res.headers['content-type']).toBe('application/json') + expect(JSON.parse(res.body!)).toMatchObject({ authToken: CAPABILITY_TOKEN }) + }) + + it('delegates every other route to the hub middleware', async () => { + const { middleware } = await createDevToolsHub({ context: fakeContext() }) + + const res = fakeRes() + const next = vi.fn() + middleware({ url: '/__devtools/index.html' } as IncomingMessage, res, next) + + expect(hubMiddleware).toHaveBeenCalledOnce() + expect(res.end).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/node/__tests__/server-client-module-resolution.test.ts b/packages/core/src/node/__tests__/server-client-module-resolution.test.ts index faa0b3404..afbc9a0fe 100644 --- a/packages/core/src/node/__tests__/server-client-module-resolution.test.ts +++ b/packages/core/src/node/__tests__/server-client-module-resolution.test.ts @@ -20,6 +20,8 @@ vi.mock('../ui', () => ({ vi.mock('../auth-handler', () => ({ getAuthHandler: () => ({ rpcFunctions: [] }), isClientAuthDisabled: () => false, + isBuildCapabilityAuth: () => false, + getBuildCapabilityToken: () => 'test-capability-token', })) function fakeContext(opts: { viteServer?: boolean } = {}): ViteDevToolsNodeContext { diff --git a/packages/core/src/node/auth-handler.ts b/packages/core/src/node/auth-handler.ts index c67f1303d..272143db4 100644 --- a/packages/core/src/node/auth-handler.ts +++ b/packages/core/src/node/auth-handler.ts @@ -1,11 +1,32 @@ import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit' import type { DevToolsConfig } from './config' +import { randomBytes } from 'node:crypto' import process from 'node:process' import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' export type DevToolsAuthHandler = ReturnType const handlers = new WeakMap() +const capabilityTokens = new WeakMap() + +/** + * The per-process capability token minted for an implicit build-mode context + * (see {@link isBuildCapabilityAuth}). Created lazily and memoized per context, + * so `getAuthHandler` (which registers it as an always-trusted + * `clientAuthTokens` entry) and `createDevToolsHub` (which bakes it into the + * locally-served connection metadata's `authToken`) hand out the exact same + * value. Unguessable and never printed — only a same-origin client able to read + * the served `__connection.json` learns it, so a cross-origin loopback page or + * an `Origin`-less local process stays untrusted. + */ +export function getBuildCapabilityToken(context: ViteDevToolsNodeContext): string { + let token = capabilityTokens.get(context) + if (!token) { + token = randomBytes(32).toString('base64url') + capabilityTokens.set(context, token) + } + return token +} /** * The interactive OTP auth handler for a context — created once and shared @@ -14,14 +35,25 @@ const handlers = new WeakMap() * one-time-code banner). Backed by devframe's `createInteractiveAuth` recipe, * so the `anonymous:devframe:auth*` handlers, `devframe:auth:revoke`, and the * banner all come from upstream rather than being hand-rolled here. + * + * In implicit build mode ({@link isBuildCapabilityAuth}) the handler additionally + * trusts the per-process {@link getBuildCapabilityToken} — the build viewer + * presents it automatically from the served connection meta — and its OTP + * banner is suppressed, since trust comes purely from that token. */ export function getAuthHandler(context: ViteDevToolsNodeContext): DevToolsAuthHandler { let handler = handlers.get(context) if (!handler) { const config = context.viteConfig.devtools?.config as DevToolsConfig | undefined + const buildCapability = isBuildCapabilityAuth(context) + const clientAuthTokens = config?.clientAuthTokens ? [...config.clientAuthTokens] : [] + if (buildCapability) + clientAuthTokens.push(getBuildCapabilityToken(context)) handler = createInteractiveAuth(context, { - clientAuthTokens: config?.clientAuthTokens, - banner: config?.banner, + clientAuthTokens, + // Build mode trusts purely via the per-process capability token baked + // into the served connection meta, so silence the OTP console banner. + banner: buildCapability ? () => {} : config?.banner, }) handlers.set(context, handler) } @@ -29,18 +61,34 @@ export function getAuthHandler(context: ViteDevToolsNodeContext): DevToolsAuthHa } /** - * Whether the interactive OTP gate should stay off for this context — a - * build snapshot (nothing live to authorize against), an explicit - * `devtools: { clientAuth: false }`, or the `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` - * escape-hatch env var. Shared between `createDevToolsContext` (which must - * skip registering the interactive-auth RPC functions so devframe's - * `auth: false` auto-trust shim can register `anonymous:devframe:auth` - * itself) and `createDevToolsHub` (which feeds the same intent to - * `initHub`'s transport-level `auth` option) — both need to agree, or the - * client's session never gets marked trusted. + * Whether the interactive OTP gate stays fully off for this context — an + * explicit `devtools: { clientAuth: false }` or the + * `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` escape-hatch env var. Both are deliberate + * user opt-outs that trust every accepted client. Shared between + * `createDevToolsContext` (which then skips registering the interactive-auth + * RPC functions so devframe's `auth: false` auto-trust shim can register + * `anonymous:devframe:auth` itself) and `createDevToolsHub` (which feeds the + * same intent to `initHub`'s transport-level `auth` option) — both need to + * agree, or the client's session never gets marked trusted. + * + * Implicit build mode is deliberately absent: it keeps the auth gate installed + * but trusts via a capability token instead of a prompt — see + * {@link isBuildCapabilityAuth}. */ export function isClientAuthDisabled(context: ViteDevToolsNodeContext): boolean { - return context.mode === 'build' - || context.viteConfig.devtools?.config?.clientAuth === false + return context.viteConfig.devtools?.config?.clientAuth === false || process.env.VITE_DEVTOOLS_DISABLE_CLIENT_AUTH === 'true' } + +/** + * Whether this context uses the implicit build-mode capability-token posture: + * a build snapshot served by a live server (the standalone viewer) that keeps + * the zero-prompt UX but, instead of trusting all comers, requires the + * per-process {@link getBuildCapabilityToken}. Only the implicit `build` branch + * qualifies — the explicit `clientAuth: false` and + * `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` opt-outs ({@link isClientAuthDisabled}) + * still disable the gate entirely. + */ +export function isBuildCapabilityAuth(context: ViteDevToolsNodeContext): boolean { + return context.mode === 'build' && !isClientAuthDisabled(context) +} diff --git a/packages/core/src/node/context.ts b/packages/core/src/node/context.ts index 44e17928f..0b0bcaf09 100644 --- a/packages/core/src/node/context.ts +++ b/packages/core/src/node/context.ts @@ -71,12 +71,15 @@ export async function createDevToolsContext( // recipe: registers the `anonymous:devframe:auth` / `:exchange` handshake // and the `devframe:auth:revoke` self-revoke. The resolver gate and the // one-time-code banner are wired up by `initHub`'s `auth` option (same - // handler) in `createDevToolsHub`. Skipped entirely when the client-auth - // gate is disabled — leaving `anonymous:devframe:auth` unregistered lets - // devframe's `auth: false` auto-trust shim (armed by `createDevToolsHub` - // passing `auth: false` to `initHub`) register its own noop handler and - // mark sessions trusted, instead of the interactive handler winning the - // race and leaving every session stuck untrusted. + // handler) in `createDevToolsHub`. This also covers implicit build mode, + // where the same handler additionally trusts the per-process capability + // token (its banner suppressed) — see `getAuthHandler` / + // `isBuildCapabilityAuth`. Skipped only when the gate is fully disabled + // (`isClientAuthDisabled`) — leaving `anonymous:devframe:auth` unregistered + // lets devframe's `auth: false` auto-trust shim (armed by `createDevToolsHub` + // passing `auth: false` to `initHub`) register its own noop handler and mark + // sessions trusted, instead of the interactive handler winning the race and + // leaving every session stuck untrusted. if (!isClientAuthDisabled(context)) { for (const fn of getAuthHandler(context).rpcFunctions) rpcHost.register(fn) diff --git a/packages/core/src/node/server.ts b/packages/core/src/node/server.ts index 200d4e083..03d9a82f9 100644 --- a/packages/core/src/node/server.ts +++ b/packages/core/src/node/server.ts @@ -5,8 +5,8 @@ import type { Server as NodeHttpServer } from 'node:http' import type { DevToolsConfig } from './config' import type { ViteDevToolsUiOptions } from './ui' import { initHub } from '@devframes/hub/initiate' -import { DEVTOOLS_MOUNT_PATH } from '@vitejs/devtools-kit/constants' -import { getAuthHandler, isClientAuthDisabled } from './auth-handler' +import { DEVTOOLS_CONNECTION_META_FILENAME, DEVTOOLS_MOUNT_PATH } from '@vitejs/devtools-kit/constants' +import { getAuthHandler, getBuildCapabilityToken, isBuildCapabilityAuth, isClientAuthDisabled } from './auth-handler' import { resolveDockRendererRegistrations } from './renderers' import { createViteDevToolsUi } from './ui' @@ -31,6 +31,9 @@ export interface CreateDevToolsHubOptions { wsPort?: number } +/** Absolute path of the hub's top-level connection meta (`/__devtools/__connection.json`). */ +const CONNECTION_META_PATH = `${DEVTOOLS_MOUNT_PATH}${DEVTOOLS_CONNECTION_META_FILENAME}` + export interface DevToolsHub { hub: HubInstance /** Connect/Express-style middleware over the whole DevTools surface. */ @@ -52,12 +55,21 @@ export interface DevToolsHub { export async function createDevToolsHub(options: CreateDevToolsHubOptions): Promise { const { context } = options - // Mirror the WS trust posture the bespoke transport used: skip the OTP gate - // in build snapshots, when the user opts out, or via the escape-hatch env. - // Must agree with `createDevToolsContext`'s registration guard (same - // helper) — see `isClientAuthDisabled` for why. + // Mirror the WS trust posture the context uses: fully skip the auth gate + // only on an explicit opt-out or the escape-hatch env. Must agree with + // `createDevToolsContext`'s registration guard (same helper) — see + // `isClientAuthDisabled` for why. const authDisabled = isClientAuthDisabled(context) + // Implicit build mode keeps the gate installed but trusts via an unguessable + // per-process capability token instead of a prompt. The token is baked into + // the served connection metadata's `authToken` so the same-origin build + // viewer presents it automatically; a cross-origin page can't read that + // metadata, so it never learns the token. See `isBuildCapabilityAuth`. + const capabilityToken = isBuildCapabilityAuth(context) + ? getBuildCapabilityToken(context) + : undefined + // Vite's published types bundle a frozen `DevToolsConfig` snapshot, so a // field added here isn't visible through `config` until Vite re-vendors it. const allowedOrigins = (context.viteConfig.devtools?.config as DevToolsConfig | undefined)?.allowedOrigins @@ -89,15 +101,38 @@ export async function createDevToolsHub(options: CreateDevToolsHubOptions): Prom await hub.ready - const getConnectionMeta = (): ConnectionMeta => hub.connectionMeta() + // Bake the capability token into the emitted connection metadata so the + // build viewer trusts the server on connect with no prompt. + const getConnectionMeta = (): ConnectionMeta => ( + capabilityToken + ? { ...hub.connectionMeta(), authToken: capabilityToken } + : hub.connectionMeta() + ) // Hand the host the live connection-meta getter so each mounted devframe's // `mountConnectionMeta` middleware serves it at the devframe's own base. ;(context.host as ViteDevToolsHost).provideConnectionMeta?.(getConnectionMeta) + // The hub serves its own top-level `__connection.json` (the viewer's meta) + // from `hub.connectionMeta()`, which carries no `authToken`. In build-token + // mode, intercept that one route and answer with the token-augmented meta so + // the top-level viewer picks the token up too — everything else falls + // through to the hub middleware untouched. + const middleware: HubInstance['nodeMiddleware'] = capabilityToken + ? (req, res, next) => { + const path = req.url?.split('?', 1)[0] + if (path === CONNECTION_META_PATH) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(getConnectionMeta())) + return + } + hub.nodeMiddleware(req, res, next) + } + : hub.nodeMiddleware + return { hub, - middleware: hub.nodeMiddleware, + middleware, getConnectionMeta, close: hub.close, }