mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-25 01:49:06 -05:00
chore: scope graphql-ws origin enforcement to launchpad server (#33874)
This commit is contained in:
@@ -190,6 +190,7 @@ export async function handleGraphQLSocketRequest (uid: string, payload: string,
|
||||
*
|
||||
* @param httpServer The http server we are utilizing for the websocket
|
||||
* @param targetRoute Route to target in the server upgrade event
|
||||
* @param options.enforceOrigin Defaults to true: reject upgrades whose Origin port does not match the server's listen port. Set to false only when the server receives proxied upgrades whose Origin reflects an upstream host (i.e. the test-runner server) and another allowlist gates inbound connections.
|
||||
* @returns WebSocket server and graphql-ws dispose — call `dispose()` before destroying the HTTP server.
|
||||
*/
|
||||
export interface GraphqlWsHandle {
|
||||
@@ -197,12 +198,13 @@ export interface GraphqlWsHandle {
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
export const graphqlWS = (httpServer: Server, targetRoute: string): GraphqlWsHandle => {
|
||||
export const graphqlWS = (httpServer: Server, targetRoute: string, options: { enforceOrigin?: boolean } = {}): GraphqlWsHandle => {
|
||||
const { enforceOrigin = true } = options
|
||||
const graphqlWs = new WebSocketServer({ noServer: true })
|
||||
|
||||
httpServer.on('upgrade', (req: Request, socket: Socket, head) => {
|
||||
if (req.url?.startsWith(targetRoute)) {
|
||||
if (!isOriginAllowed(req.headers.origin, req.socket.localPort)) {
|
||||
if (enforceOrigin && !isOriginAllowed(req.headers.origin, req.socket.localPort)) {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
|
||||
socket.destroy()
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals'
|
||||
import { createServer } from 'http'
|
||||
import type { ClientRequest, IncomingMessage, Server } from 'http'
|
||||
import type { AddressInfo } from 'net'
|
||||
import fetch from 'cross-fetch'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
import { setCtx } from '../../../src'
|
||||
import type { DataContext } from '../../../src'
|
||||
import { makeGraphQLServer } from '../../../graphql/makeGraphQLServer'
|
||||
import { graphqlWS, makeGraphQLServer } from '../../../graphql/makeGraphQLServer'
|
||||
import { createTestDataContext } from '../helper'
|
||||
|
||||
const EVIL_ORIGIN = 'https://evil.example.com'
|
||||
@@ -246,3 +249,128 @@ describe('makeGraphQLServer (integration)', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('graphqlWS helper', () => {
|
||||
const servers: Array<{ server: Server, dispose: () => Promise<void> }> = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (servers.length > 0) {
|
||||
const { server, dispose } = servers.pop()!
|
||||
|
||||
await dispose()
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
async function startServerWithGraphqlWS (options: { enforceOrigin?: boolean }) {
|
||||
const server = createServer()
|
||||
const handle = graphqlWS(server, '/__socket-graphql', options)
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
|
||||
const port = (server.address() as AddressInfo).port
|
||||
|
||||
servers.push({ server, dispose: handle.dispose })
|
||||
|
||||
return { port }
|
||||
}
|
||||
|
||||
function openWs (port: number, origin: string | undefined): Promise<{ opened: boolean, statusCode?: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (origin !== undefined) {
|
||||
headers.Origin = origin
|
||||
}
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/__socket-graphql`, 'graphql-transport-ws', { headers })
|
||||
let opened = false
|
||||
let statusCode: number | undefined
|
||||
let done = false
|
||||
|
||||
const finish = () => {
|
||||
if (done) return
|
||||
|
||||
done = true
|
||||
resolve({ opened, statusCode })
|
||||
}
|
||||
|
||||
ws.once('open', () => {
|
||||
opened = true
|
||||
ws.close()
|
||||
})
|
||||
|
||||
ws.once('unexpected-response', (_req: ClientRequest, res: IncomingMessage) => {
|
||||
statusCode = res.statusCode
|
||||
ws.terminate()
|
||||
finish()
|
||||
})
|
||||
|
||||
ws.once('close', () => finish())
|
||||
// Swallow socket-level errors (e.g. ECONNRESET on the server's 403 close); 'unexpected-response' and 'close' are what we read.
|
||||
ws.once('error', () => {})
|
||||
})
|
||||
}
|
||||
|
||||
describe('with default options (enforceOrigin: true)', () => {
|
||||
it('rejects upgrade when Origin port does not match the server listen port', async () => {
|
||||
const { port } = await startServerWithGraphqlWS({})
|
||||
|
||||
const result = await openWs(port, 'http://localhost:3000')
|
||||
|
||||
expect(result.opened).toBe(false)
|
||||
expect(result.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects upgrade with a non-localhost Origin', async () => {
|
||||
const { port } = await startServerWithGraphqlWS({})
|
||||
|
||||
const result = await openWs(port, EVIL_ORIGIN)
|
||||
|
||||
expect(result.opened).toBe(false)
|
||||
expect(result.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it('accepts upgrade with the server\'s own origin', async () => {
|
||||
const { port } = await startServerWithGraphqlWS({})
|
||||
|
||||
const result = await openWs(port, `http://localhost:${port}`)
|
||||
|
||||
expect(result.opened).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts upgrade with no Origin header', async () => {
|
||||
const { port } = await startServerWithGraphqlWS({})
|
||||
|
||||
const result = await openWs(port, undefined)
|
||||
|
||||
expect(result.opened).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with enforceOrigin: false (used by the test-runner server)', () => {
|
||||
it('accepts upgrade when Origin port does not match the server listen port', async () => {
|
||||
const { port } = await startServerWithGraphqlWS({ enforceOrigin: false })
|
||||
|
||||
const result = await openWs(port, 'http://localhost:3000')
|
||||
|
||||
expect(result.opened).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts upgrade with a non-localhost Origin', async () => {
|
||||
const { port } = await startServerWithGraphqlWS({ enforceOrigin: false })
|
||||
|
||||
const result = await openWs(port, EVIL_ORIGIN)
|
||||
|
||||
expect(result.opened).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts upgrade with no Origin header', async () => {
|
||||
const { port } = await startServerWithGraphqlWS({ enforceOrigin: false })
|
||||
|
||||
const result = await openWs(port, undefined)
|
||||
|
||||
expect(result.opened).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -259,7 +259,8 @@ export class ServerBase<TSocket extends SocketE2E | SocketCt> {
|
||||
this.server.on('connect', this.onConnect.bind(this))
|
||||
this.server.on('upgrade', (req, socket, head) => this.onUpgrade(req, socket, head, socketIoRoute))
|
||||
|
||||
this._graphqlWS = graphqlWS(this.server, `${socketIoRoute}-graphql`)
|
||||
// enforceOrigin is disabled here because upgrades arrive via the cypress proxy with Origin reflecting the AUT host — never the runner port. Inbound connections are gated by socketAllowed.isRequestAllowed in proxyWebsockets.
|
||||
this._graphqlWS = graphqlWS(this.server, `${socketIoRoute}-graphql`, { enforceOrigin: false })
|
||||
|
||||
// Start the file server first so its port is known before we begin
|
||||
// listening for proxied requests on the main server. The primary
|
||||
|
||||
@@ -327,4 +327,74 @@ describe('Web Sockets', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('graphql-ws handling on __socket-graphql', () => {
|
||||
beforeEach(function () {
|
||||
const automation = new Automation({
|
||||
cyNamespace: this.cfg.namespace,
|
||||
cookieNamespace: this.cfg.socketIoCookie,
|
||||
screenshotsFolder: this.cfg.screenshotsFolder,
|
||||
})
|
||||
|
||||
return this.server.startWebsockets(automation, this.cfg, {})
|
||||
})
|
||||
|
||||
const openGraphqlWs = (origin) => {
|
||||
const agent = new httpsProxyAgent(`http://localhost:${cyPort}`)
|
||||
const headers = {}
|
||||
|
||||
if (origin !== undefined) {
|
||||
headers.Origin = origin
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const client = new ws(`ws://localhost:${cyPort}/__socket-graphql`, 'graphql-transport-ws', {
|
||||
agent,
|
||||
headers,
|
||||
})
|
||||
let opened = false
|
||||
let statusCode
|
||||
let done = false
|
||||
|
||||
const finish = () => {
|
||||
if (done) return
|
||||
|
||||
done = true
|
||||
resolve({ opened, statusCode })
|
||||
}
|
||||
|
||||
client.once('open', () => {
|
||||
opened = true
|
||||
client.close()
|
||||
})
|
||||
|
||||
client.once('unexpected-response', (_req, res) => {
|
||||
statusCode = res.statusCode
|
||||
client.terminate()
|
||||
finish()
|
||||
})
|
||||
|
||||
client.once('close', () => finish())
|
||||
client.once('error', () => {})
|
||||
})
|
||||
}
|
||||
|
||||
it('accepts upgrade when Origin port differs from cypress server port', async () => {
|
||||
const result = await openGraphqlWs(`http://localhost:${otherPort}`)
|
||||
|
||||
expect(result.opened, `expected upgrade to succeed; got statusCode=${result.statusCode}`).to.be.true
|
||||
})
|
||||
|
||||
it('accepts upgrade when Origin hostname is not localhost', async () => {
|
||||
const result = await openGraphqlWs('http://foobar.com:4455')
|
||||
|
||||
expect(result.opened, `expected upgrade to succeed; got statusCode=${result.statusCode}`).to.be.true
|
||||
})
|
||||
|
||||
it('accepts upgrade with no Origin header', async () => {
|
||||
const result = await openGraphqlWs(undefined)
|
||||
|
||||
expect(result.opened, `expected upgrade to succeed; got statusCode=${result.statusCode}`).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user