Skip to content

Commit 64f79b7

Browse files
amcaplanclaude
andcommitted
Enable custom headers in CLI GraphiQL
Re-enables the header editor UI in GraphiQL and adds filtering to block problematic browser/hop-by-hop headers while allowing custom headers through. This allows users to pass headers like `Shopify-Search-Query-Debug=1` to the Admin API for debugging purposes. Changes: - Enable isHeadersEditorEnabled in GraphiQL component - Add BLOCKED_HEADERS set for hop-by-hop and proxy-controlled headers - Add filterCustomHeaders() to extract safe custom headers - Forward filtered custom headers to Admin API Fixes: shop/issues-develop#21688 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e794f12 commit 64f79b7

3 files changed

Lines changed: 183 additions & 1 deletion

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {BLOCKED_HEADERS, filterCustomHeaders} from './server.js'
2+
import {describe, expect, test} from 'vitest'
3+
4+
describe('BLOCKED_HEADERS', () => {
5+
test('includes hop-by-hop headers from RFC 7230', () => {
6+
const hopByHopHeaders = [
7+
'connection',
8+
'keep-alive',
9+
'proxy-authenticate',
10+
'proxy-authorization',
11+
'te',
12+
'trailer',
13+
'transfer-encoding',
14+
'upgrade',
15+
]
16+
17+
for (const header of hopByHopHeaders) {
18+
expect(BLOCKED_HEADERS.has(header)).toBe(true)
19+
}
20+
})
21+
22+
test('includes proxy-controlled headers', () => {
23+
const proxyControlledHeaders = [
24+
'host',
25+
'content-length',
26+
'content-type',
27+
'accept',
28+
'user-agent',
29+
'authorization',
30+
'cookie',
31+
'x-shopify-access-token',
32+
]
33+
34+
for (const header of proxyControlledHeaders) {
35+
expect(BLOCKED_HEADERS.has(header)).toBe(true)
36+
}
37+
})
38+
})
39+
40+
describe('filterCustomHeaders', () => {
41+
test('allows custom headers that are not blocked', () => {
42+
const headers = {
43+
'x-custom-header': 'custom-value',
44+
'x-another-header': 'another-value',
45+
}
46+
47+
const result = filterCustomHeaders(headers)
48+
49+
expect(result).toEqual({
50+
'x-custom-header': 'custom-value',
51+
'x-another-header': 'another-value',
52+
})
53+
})
54+
55+
test('blocks hop-by-hop headers', () => {
56+
const headers = {
57+
connection: 'keep-alive',
58+
'keep-alive': 'timeout=5',
59+
'transfer-encoding': 'chunked',
60+
'x-custom-header': 'custom-value',
61+
}
62+
63+
const result = filterCustomHeaders(headers)
64+
65+
expect(result).toEqual({
66+
'x-custom-header': 'custom-value',
67+
})
68+
})
69+
70+
test('blocks proxy-controlled headers', () => {
71+
const headers = {
72+
host: 'localhost:3000',
73+
'content-type': 'application/json',
74+
accept: 'application/json',
75+
'user-agent': 'Mozilla/5.0',
76+
authorization: 'Bearer token',
77+
cookie: 'session=abc',
78+
'x-shopify-access-token': 'secret-token',
79+
'x-custom-header': 'custom-value',
80+
}
81+
82+
const result = filterCustomHeaders(headers)
83+
84+
expect(result).toEqual({
85+
'x-custom-header': 'custom-value',
86+
})
87+
})
88+
89+
test('blocks headers case-insensitively', () => {
90+
const headers = {
91+
Connection: 'keep-alive',
92+
HOST: 'localhost',
93+
'Content-Type': 'application/json',
94+
'X-Custom-Header': 'custom-value',
95+
}
96+
97+
const result = filterCustomHeaders(headers)
98+
99+
expect(result).toEqual({
100+
'X-Custom-Header': 'custom-value',
101+
})
102+
})
103+
104+
test('filters out non-string header values', () => {
105+
const headers: {[key: string]: string | string[] | undefined} = {
106+
'x-custom-header': 'custom-value',
107+
'x-array-header': ['value1', 'value2'],
108+
'x-undefined-header': undefined,
109+
}
110+
111+
const result = filterCustomHeaders(headers)
112+
113+
expect(result).toEqual({
114+
'x-custom-header': 'custom-value',
115+
})
116+
})
117+
118+
test('returns empty object when all headers are blocked', () => {
119+
const headers = {
120+
host: 'localhost',
121+
connection: 'keep-alive',
122+
'content-type': 'application/json',
123+
}
124+
125+
const result = filterCustomHeaders(headers)
126+
127+
expect(result).toEqual({})
128+
})
129+
130+
test('returns empty object for empty input', () => {
131+
const result = filterCustomHeaders({})
132+
133+
expect(result).toEqual({})
134+
})
135+
})

packages/app/src/cli/services/dev/graphiql/server.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,49 @@ import {createRequire} from 'module'
1515

1616
const require = createRequire(import.meta.url)
1717

18+
/**
19+
* Headers that should NOT be forwarded from the GraphiQL client to the Admin API.
20+
* These include:
21+
* - Hop-by-hop headers (RFC 7230) that are connection-specific
22+
* - Browser-specific headers that are not relevant to API requests
23+
* - Headers the proxy sets itself (auth, content-type, etc.)
24+
*/
25+
export const BLOCKED_HEADERS = new Set([
26+
// Hop-by-hop headers (RFC 7230 Section 6.1)
27+
'connection',
28+
'keep-alive',
29+
'proxy-authenticate',
30+
'proxy-authorization',
31+
'te',
32+
'trailer',
33+
'transfer-encoding',
34+
'upgrade',
35+
36+
// Headers the proxy controls
37+
'host',
38+
'content-length',
39+
'content-type',
40+
'accept',
41+
'user-agent',
42+
'authorization',
43+
'cookie',
44+
'x-shopify-access-token',
45+
])
46+
47+
/**
48+
* Filters request headers to extract only custom headers that are safe to forward.
49+
* Blocked headers and non-string values are excluded.
50+
*/
51+
export function filterCustomHeaders(headers: {[key: string]: string | string[] | undefined}): {[key: string]: string} {
52+
const customHeaders: {[key: string]: string} = {}
53+
for (const [key, value] of Object.entries(headers)) {
54+
if (!BLOCKED_HEADERS.has(key.toLowerCase()) && typeof value === 'string') {
55+
customHeaders[key] = value
56+
}
57+
}
58+
return customHeaders
59+
}
60+
1861
class TokenRefreshError extends AbortError {
1962
constructor() {
2063
super('Failed to refresh credentials. Check that your app is installed, and try again.')
@@ -185,8 +228,12 @@ export function setupGraphiQLServer({
185228
try {
186229
const reqBody = JSON.stringify(req.body)
187230

231+
// Extract custom headers from the request, filtering out blocked headers
232+
const customHeaders = filterCustomHeaders(req.headers)
233+
188234
const runRequest = async () => {
189235
const headers = {
236+
...customHeaders,
190237
Accept: 'application/json',
191238
'Content-Type': 'application/json',
192239
'X-Shopify-Access-Token': await token(),

packages/app/src/cli/services/dev/graphiql/templates/graphiql.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ export function graphiqlTemplate({
265265
{query: "{%if query.preface %}{{query.preface}}\\n{% endif %}{{query.query}}", variables: "{{query.variables}}"},
266266
{%endfor%}
267267
],
268-
isHeadersEditorEnabled: false,
268+
isHeadersEditorEnabled: true,
269269
}),
270270
document.getElementById('graphiql-explorer'),
271271
)

0 commit comments

Comments
 (0)