Skip to content

Commit d4cb031

Browse files
committed
fix(app): delegate CORS preflight policy to target
1 parent 4a2435a commit d4cb031

3 files changed

Lines changed: 57 additions & 33 deletions

File tree

‎.changeset/cors-proxy-credentials.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@shopify/app': patch
33
---
44

5-
Include Access-Control-Allow-Credentials on app-dev reverse-proxy CORS preflights so credentialed cross-origin fetches succeed.
5+
Forward app-dev preflight requests so target servers can safely authorize credentialed cross-origin fetches.

‎packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts‎

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import https from 'https'
88
import net from 'net'
99

1010
const each = ['http', 'https'] as const
11+
const authorizedOrigin = 'https://extensions.shopifycdn.com'
1112

1213
describe.sequential.each(each)('http-reverse-proxy for %s', (protocol) => {
1314
const test = getTestReverseProxy(protocol)
@@ -55,34 +56,58 @@ describe.sequential.each(each)('http-reverse-proxy for %s', (protocol) => {
5556
})
5657
})
5758

58-
test('responds to CORS preflight OPTIONS with default headers', {retry: 2}, async ({setup}) => {
59+
test('forwards an authorized credentialed CORS preflight to the target', {retry: 2}, async ({setup}) => {
5960
const response = await fetch(`${protocol}://localhost:${setup.proxyPort}/path1/test`, {
6061
method: 'OPTIONS',
6162
headers: {
62-
Origin: 'https://extensions.shopifycdn.com',
63-
'Access-Control-Request-Method': 'GET',
64-
'Access-Control-Request-Headers': 'Authorization',
63+
Origin: authorizedOrigin,
64+
'Access-Control-Request-Method': 'POST',
65+
'Access-Control-Request-Headers': 'Content-Type',
6566
},
6667
agent,
6768
})
6869
expect(response.status).toBe(204)
69-
expect(response.headers.get('access-control-allow-origin')).toBe('https://extensions.shopifycdn.com')
70-
expect(response.headers.get('access-control-allow-methods')).toBe('GET')
71-
expect(response.headers.get('access-control-allow-headers')).toBe('Authorization')
72-
expect(response.headers.get('access-control-max-age')).toBe('86400')
70+
expect(response.headers.get('x-preflight-handled-by')).toBe('target')
71+
expect(response.headers.get('access-control-allow-origin')).toBe(authorizedOrigin)
72+
expect(response.headers.get('access-control-allow-methods')).toBe('POST')
73+
expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type')
7374
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
7475
})
7576

76-
test('responds to CORS preflight OPTIONS with defaults when no request headers', {retry: 2}, async ({setup}) => {
77-
const response = await fetch(`${protocol}://localhost:${setup.proxyPort}/path1/test`, {
78-
method: 'OPTIONS',
79-
agent,
80-
})
77+
test('does not authorize credentials for untrusted origins', {retry: 2}, async ({setup}) => {
78+
const untrustedOrigins = [
79+
'https://evil.example',
80+
'null',
81+
'not a valid origin',
82+
'http://extensions.shopifycdn.com',
83+
'https://extensions.shopifycdn.com:3000',
84+
]
85+
86+
await Promise.all(
87+
untrustedOrigins.map(async (origin) => {
88+
const response = await fetch(`${protocol}://localhost:${setup.proxyPort}/path1/test`, {
89+
method: 'OPTIONS',
90+
headers: {
91+
Origin: origin,
92+
'Access-Control-Request-Method': 'POST',
93+
'Access-Control-Request-Headers': 'Content-Type',
94+
},
95+
agent,
96+
})
97+
expect(response.status).toBe(204)
98+
expect(response.headers.get('access-control-allow-origin')).toBeNull()
99+
expect(response.headers.get('access-control-allow-credentials')).toBeNull()
100+
expect(response.headers.get('x-preflight-handled-by')).toBe('target')
101+
}),
102+
)
103+
})
104+
105+
test('forwards OPTIONS without an Origin without authorizing credentials', {retry: 2}, async ({setup}) => {
106+
const response = await fetch(`${protocol}://localhost:${setup.proxyPort}/path1/test`, {method: 'OPTIONS', agent})
81107
expect(response.status).toBe(204)
82-
expect(response.headers.get('access-control-allow-origin')).toBe('*')
83-
expect(response.headers.get('access-control-allow-methods')).toBe('GET, POST, PUT, DELETE, PATCH, OPTIONS')
84-
expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization')
108+
expect(response.headers.get('access-control-allow-origin')).toBeNull()
85109
expect(response.headers.get('access-control-allow-credentials')).toBeNull()
110+
expect(response.headers.get('x-preflight-handled-by')).toBe('target')
86111
})
87112

88113
test('closes the server when aborted', async ({setup}) => {
@@ -115,6 +140,21 @@ function getTestReverseProxy(protocol: 'http' | 'https') {
115140
// eslint-disable-next-line no-empty-pattern
116141
setup: async ({}, use) => {
117142
const targetServer1 = http.createServer((req, res) => {
143+
if (req.method === 'OPTIONS') {
144+
const origin = req.headers.origin
145+
res.writeHead(204, {
146+
'X-Preflight-Handled-By': 'target',
147+
...(origin === authorizedOrigin
148+
? {
149+
'Access-Control-Allow-Origin': origin,
150+
'Access-Control-Allow-Credentials': 'true',
151+
'Access-Control-Allow-Methods': req.headers['access-control-request-method'] ?? '',
152+
'Access-Control-Allow-Headers': req.headers['access-control-request-headers'] ?? '',
153+
}
154+
: {}),
155+
})
156+
return res.end()
157+
}
118158
res.writeHead(200, {'Content-Type': 'text/plain'})
119159
res.end('Response from target server 1')
120160
})

‎packages/app/src/cli/utilities/app/http-reverse-proxy.ts‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,22 +70,6 @@ function getProxyServerRequestListener(
7070
return function (req, res) {
7171
const target = match(rules, req)
7272
if (target) {
73-
// Handle CORS preflight requests directly
74-
// The proxy does not forward OPTIONS reliably, so we respond here
75-
// using the headers requested by the client.
76-
if (req.method === 'OPTIONS') {
77-
const origin = req.headers.origin
78-
res.writeHead(204, {
79-
'Access-Control-Allow-Origin': origin ?? '*',
80-
'Access-Control-Allow-Methods':
81-
req.headers['access-control-request-method'] ?? 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
82-
'Access-Control-Allow-Headers':
83-
req.headers['access-control-request-headers'] ?? 'Content-Type, Authorization',
84-
'Access-Control-Max-Age': '86400',
85-
...(origin ? {'Access-Control-Allow-Credentials': 'true'} : {}),
86-
})
87-
return res.end()
88-
}
8973
return proxy.web(req, res, {target}, (err) => {
9074
useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => {
9175
const lastError = isAggregateError(err) ? err.errors[err.errors.length - 1] : undefined

0 commit comments

Comments
 (0)