Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/nuxt-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 11 additions & 2 deletions packages/nuxt-cli/src/dev/error-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
export async function handleErrorChannelRequest(req: IncomingMessage, res: ServerResponse, options: ErrorChannelOptions = {}, caller: HandleErrorChannelOptions = {}): Promise<void> {
const instance = await useErrorChannel(options)
if (await instance.handler(req, res)) {
if (await instance.handler(req, res, caller)) {
return
}
res.statusCode = 404
Expand Down
29 changes: 5 additions & 24 deletions packages/nuxt-cli/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,13 +477,16 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
// 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()
Expand Down Expand Up @@ -521,28 +524,6 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
}
}

/**
* 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.
Expand Down
63 changes: 45 additions & 18 deletions packages/nuxt-cli/test/unit/error-channel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading