diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 4c0127c58..d0f103fda 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -73,7 +73,7 @@ "exsolve": "^1.1.1", "fuzzysort": "^4.0.2", "get-port-please": "^3.2.0", - "my-bad": "^0.2.5", + "my-bad": "^0.2.6", "obug": "^3.0.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", diff --git a/packages/nuxt-cli/src/dev/error-channel.ts b/packages/nuxt-cli/src/dev/error-channel.ts index b6a440f82..03a50aa4b 100644 --- a/packages/nuxt-cli/src/dev/error-channel.ts +++ b/packages/nuxt-cli/src/dev/error-channel.ts @@ -109,10 +109,19 @@ export function isErrorChannelRequest(path: string, base: string): boolean { return path === base || path.startsWith(`${base}/`) } +export interface HandleErrorChannelOptions { + /** + * Whether the caller may see reports raised for other requests and use + * privileged actions. Untrusted callers are served the channel scoped to + * their own request. Default `true`. + */ + trusted?: boolean +} + /** Answer a request under the mounted channel path. */ -export async function handleErrorChannelRequest(req: IncomingMessage, res: ServerResponse, options: ErrorChannelOptions = {}): Promise { +export async function handleErrorChannelRequest(req: IncomingMessage, res: ServerResponse, options: ErrorChannelOptions = {}, caller: HandleErrorChannelOptions = {}): Promise { const instance = await useErrorChannel(options) - if (await instance.handler(req, res)) { + if (await instance.handler(req, res, caller)) { return } res.statusCode = 404 diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 7e818f74e..946c13daf 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -477,13 +477,16 @@ export class NuxtDevServer extends EventEmitter { // The default path answers alongside a configured one, for pages served // before the config was known. if (this.#ownsChannel && (isErrorChannelRequest(path, this.#errorChannel) || isErrorChannelRequest(path, DEFAULT_ERROR_CHANNEL))) { - if (this.#rejectRemotePeer(req, res) || this.#rejectDisallowedHost(req, res)) { + if (this.#rejectDisallowedHost(req, res)) { return } if (options.captureUIEvents) { this.#internalResponses.add(res) } - await handleErrorChannelRequest(req, res, this.#errorChannelOptions()).catch((error) => { + // A peer on another machine is served the channel scoped to its own + // request, since every header is forgeable over a direct connection. + const trusted = isLoopbackAddress(req.socket?.remoteAddress) + await handleErrorChannelRequest(req, res, this.#errorChannelOptions(), { trusted }).catch((error) => { debug('Could not answer an error channel request:', error) if (!res.writableEnded) { res.end() @@ -521,28 +524,6 @@ export class NuxtDevServer extends EventEmitter { } } - /** - * Answer a request for the error channel from another machine, keeping error - * reports, source snippets and open-in-editor on the loopback interface even - * when the server is bound wider. Judged on the peer address, since every - * header is forgeable over a direct connection. Returns `true` when the - * request was rejected. - */ - #rejectRemotePeer(req: IncomingMessage, res: ServerResponse): boolean { - if (isLoopbackAddress(req.socket?.remoteAddress)) { - return false - } - if (this.options.captureUIEvents) { - this.#internalResponses.add(res) - } - if (!res.headersSent) { - res.statusCode = 403 - res.setHeader('Content-Type', 'text/plain') - } - res.end('Forbidden: the dev error channel is only available on this machine.') - return true - } - /** * Answer a request whose `Host` header does not name this server, so pages * loaded from a rebinding hostname cannot read the CLI's own endpoints. diff --git a/packages/nuxt-cli/test/unit/error-channel.spec.ts b/packages/nuxt-cli/test/unit/error-channel.spec.ts index b68b137d5..36f6044ca 100644 --- a/packages/nuxt-cli/test/unit/error-channel.spec.ts +++ b/packages/nuxt-cli/test/unit/error-channel.spec.ts @@ -401,44 +401,71 @@ describe('the CLI-owned error channel', () => { expect(progress.mock.calls.map(([update]) => update.source)).toEqual(['cli', 'vite']) }) - it.each([ - `${DEFAULT_ERROR_CHANNEL}/events?path=/`, - `${DEFAULT_ERROR_CHANNEL}/history/abc`, - `${DEFAULT_ERROR_CHANNEL}/open`, - ])('should refuse %s to a peer on another machine', async (url) => { + it('should stream the channel to a peer on another machine', async () => { const server = createServer() const { res, statusOf, chunks } = createResponse() - const remote = Object.assign(request(url), { socket: { remoteAddress: '192.168.0.31' } }) + const remote = Object.assign(request(`${DEFAULT_ERROR_CHANNEL}/events?path=/`), { socket: { remoteAddress: '192.168.0.31' } }) await server.handler(remote, res) + expect(statusOf()).toBe(200) + expect(chunks.join('')).toContain('event: hello') + }) + + it('should refuse open-in-editor to a peer on another machine', async () => { + const server = createServer() + const { res, statusOf } = createResponse() + const remote = Object.assign(openRequest({}), { socket: { remoteAddress: '192.168.0.31' } }) + + await server.handler(remote as unknown as IncomingMessage, res) + expect(statusOf()).toBe(403) - expect(chunks.join('')).not.toContain('event: hello') }) - it('should keep a report away from a peer on another machine', async () => { + it('should keep another request\'s report away from a peer on another machine', async () => { const server = createServer() const report = await createCliReport(new Error('boom from a page'), { cwd: process.cwd() }) const instance = await useErrorChannel() - instance.setError(report) + instance.setError(report, '7', 'GET /admin') - const { res, statusOf, chunks } = createResponse() - const remote = Object.assign(request(`${DEFAULT_ERROR_CHANNEL}/history/${report.id}`), { socket: { remoteAddress: '192.168.0.31' } }) + const { res: streamRes, chunks } = createResponse() + const stream = Object.assign(request(`${DEFAULT_ERROR_CHANNEL}/events?path=/`), { socket: { remoteAddress: '192.168.0.31' } }) + await server.handler(stream, streamRes) + + const { res, statusOf, chunks: body } = createResponse() + const remote = Object.assign(request(`${DEFAULT_ERROR_CHANNEL}/history/${report.id}?path=/`), { socket: { remoteAddress: '192.168.0.31' } }) await server.handler(remote, res) - expect(statusOf()).toBe(403) expect(chunks.join('')).not.toContain('boom from a page') + expect(statusOf()).toBe(404) + expect(body.join('')).not.toContain('boom from a page') }) - it('should refuse a channel request with no peer address', async () => { + it('should serve a report to the peer whose request raised it', async () => { const server = createServer() - const { res, statusOf } = createResponse() - const anonymous = request(`${DEFAULT_ERROR_CHANNEL}/history/abc`) - delete (anonymous as { socket?: unknown }).socket + const report = await createCliReport(new Error('boom from a page'), { cwd: process.cwd() }) + const instance = await useErrorChannel() + instance.setError(report, '7', 'GET /admin') - await server.handler(anonymous, res) + const { res, statusOf, chunks } = createResponse() + const remote = Object.assign(request(`${DEFAULT_ERROR_CHANNEL}/history/${report.id}?requestId=7`), { socket: { remoteAddress: '192.168.0.31' } }) + await server.handler(remote, res) - expect(statusOf()).toBe(403) + expect(statusOf()).toBe(200) + expect(chunks.join('')).toContain('boom from a page') + }) + + it('should serve a loopback peer the whole channel', async () => { + const server = createServer() + const report = await createCliReport(new Error('boom from a page'), { cwd: process.cwd() }) + const instance = await useErrorChannel() + instance.setError(report, '7', 'GET /admin') + + const { res, statusOf, chunks } = createResponse() + await server.handler(request(`${DEFAULT_ERROR_CHANNEL}/history/${report.id}`), res) + + expect(statusOf()).toBe(200) + expect(chunks.join('')).toContain('boom from a page') }) it('should refuse a channel request another site made', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19ced81c8..a4c201bc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,8 +416,8 @@ importers: specifier: ^3.2.0 version: 3.2.0 my-bad: - specifier: ^0.2.5 - version: 0.2.5(vite@8.3.0) + specifier: ^0.2.6 + version: 0.2.6(vite@8.3.0) obug: specifier: ^3.0.0 version: 3.0.0 @@ -4991,8 +4991,8 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - my-bad@0.2.5: - resolution: {integrity: sha512-jS1CLPZSsHrzyTDZq+5s+uxG8OIjq5+RIxQHRQPedzX5NjjtYzyCVee22tw/zfN4t403S22fF3+t3wVqk7/c4Q==} + my-bad@0.2.6: + resolution: {integrity: sha512-afKIOyZQa3KlFBOqPeDBuBNNVdqX8Dr+lSUoduKgmVfzvpQ/bB4D2NZEGetqcprZYmmVnl0RYEygh3eWD9S26Q==} engines: {node: '>=22.12.0'} peerDependencies: vite: '>=6' @@ -12162,7 +12162,7 @@ snapshots: muggle-string@0.4.1: {} - my-bad@0.2.5(vite@8.3.0): + my-bad@0.2.6(vite@8.3.0): dependencies: clickable-path: 0.1.1 errx: 0.2.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3d5651dd7..354c903ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ minimumReleaseAgeExclude: - '@vue/*' - fuzzysort@4.0.1 - errx@0.2.1 || 0.2.2 - - my-bad@0.2.2 || 0.2.5 + - my-bad@0.2.2 || 0.2.5 || 0.2.6 verifyDepsBeforeRun: install