diff --git a/.changeset/stale-wolves-follow.md b/.changeset/stale-wolves-follow.md new file mode 100644 index 0000000000..791e5e3710 --- /dev/null +++ b/.changeset/stale-wolves-follow.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Enable custom headers in CLI GraphiQL. Users can now set custom headers like `Shopify-Search-Query-Debug=1` in the GraphiQL interface to pass debugging headers to the Admin API. diff --git a/packages/app/src/cli/services/dev/graphiql/server.ts b/packages/app/src/cli/services/dev/graphiql/server.ts index 30bda113e6..266d62c834 100644 --- a/packages/app/src/cli/services/dev/graphiql/server.ts +++ b/packages/app/src/cli/services/dev/graphiql/server.ts @@ -1,5 +1,6 @@ import {defaultQuery, graphiqlTemplate} from './templates/graphiql.js' import {unauthorizedTemplate} from './templates/unauthorized.js' +import {filterCustomHeaders} from './utilities.js' import express from 'express' import bodyParser from 'body-parser' import {performActionWithRetryAfterRecovery} from '@shopify/cli-kit/common/retry' @@ -185,8 +186,12 @@ export function setupGraphiQLServer({ try { const reqBody = JSON.stringify(req.body) + // Extract custom headers from the request, filtering out blocked headers + const customHeaders = filterCustomHeaders(req.headers) + const runRequest = async () => { const headers = { + ...customHeaders, Accept: 'application/json', 'Content-Type': 'application/json', 'X-Shopify-Access-Token': await token(), diff --git a/packages/app/src/cli/services/dev/graphiql/templates/graphiql.tsx b/packages/app/src/cli/services/dev/graphiql/templates/graphiql.tsx index a767c70743..1379e7d3a7 100644 --- a/packages/app/src/cli/services/dev/graphiql/templates/graphiql.tsx +++ b/packages/app/src/cli/services/dev/graphiql/templates/graphiql.tsx @@ -265,7 +265,7 @@ export function graphiqlTemplate({ {query: "{%if query.preface %}{{query.preface}}\\n{% endif %}{{query.query}}", variables: "{{query.variables}}"}, {%endfor%} ], - isHeadersEditorEnabled: false, + isHeadersEditorEnabled: true, }), document.getElementById('graphiql-explorer'), ) diff --git a/packages/app/src/cli/services/dev/graphiql/utilities.test.ts b/packages/app/src/cli/services/dev/graphiql/utilities.test.ts new file mode 100644 index 0000000000..473260fd05 --- /dev/null +++ b/packages/app/src/cli/services/dev/graphiql/utilities.test.ts @@ -0,0 +1,99 @@ +import {filterCustomHeaders} from './utilities.js' +import {describe, expect, test} from 'vitest' + +describe('filterCustomHeaders', () => { + test('allows custom headers that are not blocked', () => { + const headers = { + 'x-custom-header': 'custom-value', + 'x-another-header': 'another-value', + } + + const result = filterCustomHeaders(headers) + + expect(result).toEqual({ + 'x-custom-header': 'custom-value', + 'x-another-header': 'another-value', + }) + }) + + test('blocks hop-by-hop headers', () => { + const headers = { + connection: 'keep-alive', + 'keep-alive': 'timeout=5', + 'transfer-encoding': 'chunked', + 'x-custom-header': 'custom-value', + } + + const result = filterCustomHeaders(headers) + + expect(result).toEqual({ + 'x-custom-header': 'custom-value', + }) + }) + + test('blocks proxy-controlled headers', () => { + const headers = { + host: 'localhost:3000', + 'content-type': 'application/json', + accept: 'application/json', + 'user-agent': 'Mozilla/5.0', + authorization: 'Bearer token', + cookie: 'session=abc', + 'x-shopify-access-token': 'secret-token', + 'x-custom-header': 'custom-value', + } + + const result = filterCustomHeaders(headers) + + expect(result).toEqual({ + 'x-custom-header': 'custom-value', + }) + }) + + test('blocks headers case-insensitively', () => { + const headers = { + Connection: 'keep-alive', + HOST: 'localhost', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + } + + const result = filterCustomHeaders(headers) + + expect(result).toEqual({ + 'X-Custom-Header': 'custom-value', + }) + }) + + test('filters out non-string header values', () => { + const headers: {[key: string]: string | string[] | undefined} = { + 'x-custom-header': 'custom-value', + 'x-array-header': ['value1', 'value2'], + 'x-undefined-header': undefined, + } + + const result = filterCustomHeaders(headers) + + expect(result).toEqual({ + 'x-custom-header': 'custom-value', + }) + }) + + test('returns empty object when all headers are blocked', () => { + const headers = { + host: 'localhost', + connection: 'keep-alive', + 'content-type': 'application/json', + } + + const result = filterCustomHeaders(headers) + + expect(result).toEqual({}) + }) + + test('returns empty object for empty input', () => { + const result = filterCustomHeaders({}) + + expect(result).toEqual({}) + }) +}) diff --git a/packages/app/src/cli/services/dev/graphiql/utilities.ts b/packages/app/src/cli/services/dev/graphiql/utilities.ts new file mode 100644 index 0000000000..d900e44309 --- /dev/null +++ b/packages/app/src/cli/services/dev/graphiql/utilities.ts @@ -0,0 +1,42 @@ +/** + * Headers that should NOT be forwarded from the GraphiQL client to the Admin API. + * These include: + * - Hop-by-hop headers (RFC 7230) that are connection-specific + * - Browser-specific headers that are not relevant to API requests + * - Headers the proxy sets itself (auth, content-type, etc.) + */ +const BLOCKED_HEADERS = new Set([ + // Hop-by-hop headers (RFC 7230 Section 6.1) + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + + // Headers the proxy controls + 'host', + 'content-length', + 'content-type', + 'accept', + 'user-agent', + 'authorization', + 'cookie', + 'x-shopify-access-token', +]) + +/** + * Filters request headers to extract only custom headers that are safe to forward. + * Blocked headers and non-string values are excluded. + */ +export function filterCustomHeaders(headers: {[key: string]: string | string[] | undefined}): {[key: string]: string} { + const customHeaders: {[key: string]: string} = {} + for (const [key, value] of Object.entries(headers)) { + if (!BLOCKED_HEADERS.has(key.toLowerCase()) && typeof value === 'string') { + customHeaders[key] = value + } + } + return customHeaders +}