Skip to content

Commit f793848

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 <[email protected]>
1 parent e794f12 commit f793848

File tree

4 files changed

+147
-1
lines changed

4 files changed

+147
-1
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {defaultQuery, graphiqlTemplate} from './templates/graphiql.js'
22
import {unauthorizedTemplate} from './templates/unauthorized.js'
3+
import {filterCustomHeaders} from './utilities.js'
34
import express from 'express'
45
import bodyParser from 'body-parser'
56
import {performActionWithRetryAfterRecovery} from '@shopify/cli-kit/common/retry'
@@ -185,8 +186,12 @@ export function setupGraphiQLServer({
185186
try {
186187
const reqBody = JSON.stringify(req.body)
187188

189+
// Extract custom headers from the request, filtering out blocked headers
190+
const customHeaders = filterCustomHeaders(req.headers)
191+
188192
const runRequest = async () => {
189193
const headers = {
194+
...customHeaders,
190195
Accept: 'application/json',
191196
'Content-Type': 'application/json',
192197
'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
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {filterCustomHeaders} from './utilities.js'
2+
import {describe, expect, test} from 'vitest'
3+
4+
describe('filterCustomHeaders', () => {
5+
test('allows custom headers that are not blocked', () => {
6+
const headers = {
7+
'x-custom-header': 'custom-value',
8+
'x-another-header': 'another-value',
9+
}
10+
11+
const result = filterCustomHeaders(headers)
12+
13+
expect(result).toEqual({
14+
'x-custom-header': 'custom-value',
15+
'x-another-header': 'another-value',
16+
})
17+
})
18+
19+
test('blocks hop-by-hop headers', () => {
20+
const headers = {
21+
connection: 'keep-alive',
22+
'keep-alive': 'timeout=5',
23+
'transfer-encoding': 'chunked',
24+
'x-custom-header': 'custom-value',
25+
}
26+
27+
const result = filterCustomHeaders(headers)
28+
29+
expect(result).toEqual({
30+
'x-custom-header': 'custom-value',
31+
})
32+
})
33+
34+
test('blocks proxy-controlled headers', () => {
35+
const headers = {
36+
host: 'localhost:3000',
37+
'content-type': 'application/json',
38+
accept: 'application/json',
39+
'user-agent': 'Mozilla/5.0',
40+
authorization: 'Bearer token',
41+
cookie: 'session=abc',
42+
'x-shopify-access-token': 'secret-token',
43+
'x-custom-header': 'custom-value',
44+
}
45+
46+
const result = filterCustomHeaders(headers)
47+
48+
expect(result).toEqual({
49+
'x-custom-header': 'custom-value',
50+
})
51+
})
52+
53+
test('blocks headers case-insensitively', () => {
54+
const headers = {
55+
Connection: 'keep-alive',
56+
HOST: 'localhost',
57+
'Content-Type': 'application/json',
58+
'X-Custom-Header': 'custom-value',
59+
}
60+
61+
const result = filterCustomHeaders(headers)
62+
63+
expect(result).toEqual({
64+
'X-Custom-Header': 'custom-value',
65+
})
66+
})
67+
68+
test('filters out non-string header values', () => {
69+
const headers: {[key: string]: string | string[] | undefined} = {
70+
'x-custom-header': 'custom-value',
71+
'x-array-header': ['value1', 'value2'],
72+
'x-undefined-header': undefined,
73+
}
74+
75+
const result = filterCustomHeaders(headers)
76+
77+
expect(result).toEqual({
78+
'x-custom-header': 'custom-value',
79+
})
80+
})
81+
82+
test('returns empty object when all headers are blocked', () => {
83+
const headers = {
84+
host: 'localhost',
85+
connection: 'keep-alive',
86+
'content-type': 'application/json',
87+
}
88+
89+
const result = filterCustomHeaders(headers)
90+
91+
expect(result).toEqual({})
92+
})
93+
94+
test('returns empty object for empty input', () => {
95+
const result = filterCustomHeaders({})
96+
97+
expect(result).toEqual({})
98+
})
99+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Headers that should NOT be forwarded from the GraphiQL client to the Admin API.
3+
* These include:
4+
* - Hop-by-hop headers (RFC 7230) that are connection-specific
5+
* - Browser-specific headers that are not relevant to API requests
6+
* - Headers the proxy sets itself (auth, content-type, etc.)
7+
*/
8+
const BLOCKED_HEADERS = new Set([
9+
// Hop-by-hop headers (RFC 7230 Section 6.1)
10+
'connection',
11+
'keep-alive',
12+
'proxy-authenticate',
13+
'proxy-authorization',
14+
'te',
15+
'trailer',
16+
'transfer-encoding',
17+
'upgrade',
18+
19+
// Headers the proxy controls
20+
'host',
21+
'content-length',
22+
'content-type',
23+
'accept',
24+
'user-agent',
25+
'authorization',
26+
'cookie',
27+
'x-shopify-access-token',
28+
])
29+
30+
/**
31+
* Filters request headers to extract only custom headers that are safe to forward.
32+
* Blocked headers and non-string values are excluded.
33+
*/
34+
export function filterCustomHeaders(headers: {[key: string]: string | string[] | undefined}): {[key: string]: string} {
35+
const customHeaders: {[key: string]: string} = {}
36+
for (const [key, value] of Object.entries(headers)) {
37+
if (!BLOCKED_HEADERS.has(key.toLowerCase()) && typeof value === 'string') {
38+
customHeaders[key] = value
39+
}
40+
}
41+
return customHeaders
42+
}

0 commit comments

Comments
 (0)