Skip to content

Commit 2fa98c3

Browse files
authored
fix: prevent infinite loop when bypassing sendBeacon() requests (#2353)
1 parent 8cb7b01 commit 2fa98c3

11 files changed

+224
-38
lines changed

src/core/bypass.test.ts

+25-14
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,21 @@ it('returns bypassed request given a request url string', async () => {
99
// Relative URLs are rebased against the current location.
1010
expect(request.method).toBe('GET')
1111
expect(request.url).toBe('https://api.example.com/resource')
12-
expect(Object.fromEntries(request.headers.entries())).toEqual({
13-
'x-msw-intention': 'bypass',
14-
})
12+
expect(Array.from(request.headers)).toEqual([['accept', 'msw/passthrough']])
1513
})
1614

1715
it('returns bypassed request given a request url', async () => {
1816
const request = bypass(new URL('/resource', 'https://api.example.com'))
1917

2018
expect(request.url).toBe('https://api.example.com/resource')
21-
expect(Object.fromEntries(request.headers)).toEqual({
22-
'x-msw-intention': 'bypass',
23-
})
19+
expect(Array.from(request.headers)).toEqual([['accept', 'msw/passthrough']])
2420
})
2521

2622
it('returns bypassed request given request instance', async () => {
2723
const original = new Request('http://localhost/resource', {
2824
method: 'POST',
2925
headers: {
26+
accept: '*/*',
3027
'X-My-Header': 'value',
3128
},
3229
body: 'hello world',
@@ -40,10 +37,11 @@ it('returns bypassed request given request instance', async () => {
4037
expect(original.bodyUsed).toBe(false)
4138

4239
expect(bypassedRequestBody).toEqual(await original.text())
43-
expect(Object.fromEntries(request.headers.entries())).toEqual({
44-
...Object.fromEntries(original.headers.entries()),
45-
'x-msw-intention': 'bypass',
46-
})
40+
expect(Array.from(request.headers)).toEqual([
41+
['accept', '*/*, msw/passthrough'],
42+
['content-type', 'text/plain;charset=UTF-8'],
43+
['x-my-header', 'value'],
44+
])
4745
})
4846

4947
it('allows modifying the bypassed request instance', async () => {
@@ -57,13 +55,26 @@ it('allows modifying the bypassed request instance', async () => {
5755
})
5856

5957
expect(request.method).toBe('PUT')
60-
expect(Object.fromEntries(request.headers.entries())).toEqual({
61-
'x-msw-intention': 'bypass',
62-
'x-modified-header': 'yes',
63-
})
58+
expect(Array.from(request.headers)).toEqual([
59+
['accept', 'msw/passthrough'],
60+
['x-modified-header', 'yes'],
61+
])
6462
expect(original.bodyUsed).toBe(false)
6563
expect(request.bodyUsed).toBe(false)
6664

6765
expect(await request.text()).toBe('hello world')
6866
expect(original.bodyUsed).toBe(false)
6967
})
68+
69+
it('supports bypassing "keepalive: true" requests', async () => {
70+
const original = new Request('http://localhost/resource', {
71+
method: 'POST',
72+
keepalive: true,
73+
})
74+
const request = bypass(original)
75+
76+
expect(request.method).toBe('POST')
77+
expect(request.url).toBe('http://localhost/resource')
78+
expect(request.body).toBeNull()
79+
expect(Array.from(request.headers)).toEqual([['accept', 'msw/passthrough']])
80+
})

src/core/bypass.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ export function bypass(input: BypassRequestInput, init?: RequestInit): Request {
3434

3535
const requestClone = request.clone()
3636

37-
// Set the internal header that would instruct MSW
38-
// to bypass this request from any further request matching.
39-
// Unlike "passthrough()", bypass is meant for performing
40-
// additional requests within pending request resolution.
41-
requestClone.headers.set('x-msw-intention', 'bypass')
37+
/**
38+
* Send the internal request header that would instruct MSW
39+
* to perform this request as-is, ignoring any matching handlers.
40+
* @note Use the `accept` header to support scenarios when the
41+
* request cannot have headers (e.g. `sendBeacon` requests).
42+
*/
43+
requestClone.headers.append('accept', 'msw/passthrough')
4244

4345
return requestClone
4446
}

src/core/passthrough.test.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
/**
2-
* @vitest-environment node
3-
*/
1+
// @vitest-environment node
42
import { passthrough } from './passthrough'
53

64
it('creates a 302 response with the intention header', () => {

src/core/utils/handleRequest.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ afterEach(() => {
4646
vi.resetAllMocks()
4747
})
4848

49-
test('returns undefined for a request with the "x-msw-intention" header equal to "bypass"', async () => {
49+
test('returns undefined for a request with the "accept: msw/passthrough" header equal to "bypass"', async () => {
5050
const { emitter, events } = setup()
5151

5252
const requestId = createRequestId()
5353
const request = new Request(new URL('http://localhost/user'), {
5454
headers: new Headers({
55-
'x-msw-intention': 'bypass',
55+
accept: 'msw/passthrough',
5656
}),
5757
})
5858
const handlers: Array<RequestHandler> = []
@@ -79,12 +79,12 @@ test('returns undefined for a request with the "x-msw-intention" header equal to
7979
expect(handleRequestOptions.onMockedResponse).not.toHaveBeenCalled()
8080
})
8181

82-
test('does not bypass a request with "x-msw-intention" header set to arbitrary value', async () => {
82+
test('does not bypass a request with "accept: msw/*" header set to arbitrary value', async () => {
8383
const { emitter } = setup()
8484

8585
const request = new Request(new URL('http://localhost/user'), {
8686
headers: new Headers({
87-
'x-msw-intention': 'invalid',
87+
acceot: 'msw/invalid',
8888
}),
8989
})
9090
const handlers: Array<RequestHandler> = [

src/core/utils/handleRequest.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export async function handleRequest(
4646
): Promise<Response | undefined> {
4747
emitter.emit('request:start', { request, requestId })
4848

49-
// Perform bypassed requests (i.e. wrapped in "bypass()") as-is.
50-
if (request.headers.get('x-msw-intention') === 'bypass') {
49+
// Perform requests wrapped in "bypass()" as-is.
50+
if (request.headers.get('accept')?.includes('msw/passthrough')) {
5151
emitter.emit('request:end', { request, requestId })
5252
handleRequestOptions?.onPassthroughResponse?.(request)
5353
return

src/mockServiceWorker.js

+8-6
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,14 @@ async function getResponse(event, client, requestId) {
192192
const requestClone = request.clone()
193193

194194
function passthrough() {
195-
const headers = Object.fromEntries(requestClone.headers.entries())
196-
197-
// Remove internal MSW request header so the passthrough request
198-
// complies with any potential CORS preflight checks on the server.
199-
// Some servers forbid unknown request headers.
200-
delete headers['x-msw-intention']
195+
// Cast the request headers to a new Headers instance
196+
// so the headers can be manipulated with.
197+
const headers = new Headers(requestClone.headers)
198+
199+
// Remove the "accept" header value that marked this request as passthrough.
200+
// This prevents request alteration and also keeps it compliant with the
201+
// user-defined CORS policies.
202+
headers.delete('accept', 'msw/passthrough')
201203

202204
return fetch(requestClone, { headers })
203205
}

src/node/SetupServerCommonApi.ts

+23
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,29 @@ export class SetupServerCommonApi
6767
.filter(isHandlerKind('RequestHandler')),
6868
this.resolvedOptions,
6969
this.emitter,
70+
{
71+
onPassthroughResponse(request) {
72+
const acceptHeader = request.headers.get('accept')
73+
74+
/**
75+
* @note Remove the internal bypass request header.
76+
* In the browser, this is done by the worker script.
77+
* In Node.js, it has to be done here.
78+
*/
79+
if (acceptHeader) {
80+
const nextAcceptHeader = acceptHeader.replace(
81+
/(,\s+)?msw\/passthrough/,
82+
'',
83+
)
84+
85+
if (nextAcceptHeader) {
86+
request.headers.set('accept', nextAcceptHeader)
87+
} else {
88+
request.headers.delete('accept')
89+
}
90+
}
91+
},
92+
},
7093
)
7194

7295
if (response) {
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { http, bypass } from 'msw'
2+
import { setupWorker } from 'msw/browser'
3+
4+
const worker = setupWorker(
5+
http.post('/analytics', ({ request }) => {
6+
return new Response(request.body)
7+
}),
8+
http.post('*/analytics-bypass', ({ request }) => {
9+
const nextRequest = bypass(request)
10+
return fetch(nextRequest)
11+
}),
12+
)
13+
14+
worker.start()
+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { test, expect } from '../playwright.extend'
2+
3+
test('supports mocking a response to a "sendBeacon" request', async ({
4+
loadExample,
5+
page,
6+
}) => {
7+
await loadExample(require.resolve('./send-beacon.mocks.ts'))
8+
9+
const isQueuedPromise = page.evaluate(() => {
10+
return navigator.sendBeacon(
11+
'/analytics',
12+
JSON.stringify({ event: 'pageview' }),
13+
)
14+
})
15+
16+
const response = await page.waitForResponse((response) => {
17+
return response.url().endsWith('/analytics')
18+
})
19+
20+
expect(response.status()).toBe(200)
21+
// Technically, "sendBeacon" responses don't send any body back.
22+
// We use this body only to verify that the request body was accessible
23+
// in the request handlers.
24+
await expect(response.text()).resolves.toBe('{"event":"pageview"}')
25+
26+
// Must return true, indicating that the server has queued the sent data.
27+
await expect(isQueuedPromise).resolves.toBe(true)
28+
})
29+
30+
test('supports bypassing "sendBeacon" requests', async ({
31+
loadExample,
32+
page,
33+
}) => {
34+
const { compilation } = await loadExample(
35+
require.resolve('./send-beacon.mocks.ts'),
36+
{
37+
beforeNavigation(compilation) {
38+
compilation.use((router) => {
39+
router.post('/analytics-bypass', (_req, res) => {
40+
res.status(200).end()
41+
})
42+
})
43+
},
44+
},
45+
)
46+
47+
const url = new URL('./analytics-bypass', compilation.previewUrl).href
48+
const isQueuedPromise = page.evaluate((url) => {
49+
return navigator.sendBeacon(url, JSON.stringify({ event: 'pageview' }))
50+
}, url)
51+
52+
const response = await page.waitForResponse(url)
53+
expect(response.status()).toBe(200)
54+
55+
await expect(isQueuedPromise).resolves.toBe(true)
56+
})

test/node/graphql-api/response-patching.node.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
/**
2-
* @vitest-environment node
3-
*/
1+
// @vitest-environment node
42
import { bypass, graphql, HttpResponse } from 'msw'
53
import { setupServer } from 'msw/node'
64
import { graphql as executeGraphql, buildSchema } from 'graphql'
@@ -29,6 +27,8 @@ const server = setupServer(
2927

3028
const httpServer = new HttpServer((app) => {
3129
app.post('/graphql', async (req, res) => {
30+
console.log('pass:', req.headers)
31+
3232
const result = await executeGraphql({
3333
schema: buildSchema(gql`
3434
type User {
@@ -112,7 +112,7 @@ test('patches a GraphQL response', async () => {
112112
firstName: 'Christian',
113113
lastName: 'Maverick',
114114
})
115-
expect(res.data?.requestHeaders).toHaveProperty('x-msw-intention', 'bypass')
115+
expect(res.data?.requestHeaders).toHaveProperty('accept', '*/*')
116116
expect(res.data?.requestHeaders).not.toHaveProperty('_headers')
117117
expect(res.data?.requestHeaders).not.toHaveProperty('_names')
118118
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// @vitest-environment node
2+
import { http, bypass } from 'msw'
3+
import { setupServer } from 'msw/node'
4+
import express from 'express'
5+
import { HttpServer } from '@open-draft/test-server/http'
6+
7+
const httpServer = new HttpServer((app) => {
8+
app.use('/resource', (_req, res, next) => {
9+
res.setHeader('access-control-allow-headers', '*')
10+
next()
11+
})
12+
app.post('/resource', express.text(), (req, res) => {
13+
res.json({
14+
text: req.body,
15+
requestHeaders: req.headers,
16+
})
17+
})
18+
})
19+
20+
const server = setupServer()
21+
22+
beforeAll(async () => {
23+
server.listen()
24+
await httpServer.listen()
25+
})
26+
27+
afterEach(() => {
28+
server.resetHandlers()
29+
})
30+
31+
afterAll(async () => {
32+
server.close()
33+
await httpServer.close()
34+
})
35+
36+
it('supports patching an original HTTP response', async () => {
37+
server.use(
38+
http.post(httpServer.http.url('/resource'), async ({ request }) => {
39+
const originalResponse = await fetch(bypass(request))
40+
const { text, requestHeaders } = await originalResponse.json()
41+
return new Response(text.toUpperCase(), { headers: requestHeaders })
42+
}),
43+
)
44+
45+
const response = await fetch(httpServer.http.url('/resource'), {
46+
method: 'POST',
47+
body: 'world',
48+
})
49+
50+
await expect(response.text()).resolves.toBe('WORLD')
51+
52+
// Must not contain the internal bypass request header.
53+
expect(Object.fromEntries(response.headers)).toHaveProperty('accept', '*/*')
54+
})
55+
56+
it('preserves request "accept" header when patching a response', async () => {
57+
server.use(
58+
http.post(httpServer.http.url('/resource'), async ({ request }) => {
59+
const originalResponse = await fetch(bypass(request))
60+
const { text, requestHeaders } = await originalResponse.json()
61+
return new Response(text.toUpperCase(), { headers: requestHeaders })
62+
}),
63+
)
64+
65+
const response = await fetch(httpServer.http.url('/resource'), {
66+
method: 'POST',
67+
headers: {
68+
accept: 'application/json',
69+
},
70+
body: 'world',
71+
})
72+
73+
await expect(response.text()).resolves.toBe('WORLD')
74+
75+
// Must not contain the internal bypass request header.
76+
expect(Object.fromEntries(response.headers)).toHaveProperty(
77+
'accept',
78+
'application/json',
79+
)
80+
})

0 commit comments

Comments
 (0)