From f7938486524fd15b7b3a0bb3f9f05577f4c4982c Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Wed, 10 Dec 2025 15:11:19 +0200 Subject: [PATCH 1/2] Enable custom headers in CLI GraphiQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://github.com/shop/issues-develop/issues/21688 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/cli/services/dev/graphiql/server.ts | 5 + .../dev/graphiql/templates/graphiql.tsx | 2 +- .../services/dev/graphiql/utilities.test.ts | 99 +++++++++++++++++++ .../cli/services/dev/graphiql/utilities.ts | 42 ++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/cli/services/dev/graphiql/utilities.test.ts create mode 100644 packages/app/src/cli/services/dev/graphiql/utilities.ts diff --git a/packages/app/src/cli/services/dev/graphiql/server.ts b/packages/app/src/cli/services/dev/graphiql/server.ts index 30bda113e6e..266d62c8347 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 a767c707433..1379e7d3a79 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 00000000000..473260fd051 --- /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 00000000000..d900e443093 --- /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 +} From 73a38334675bf791870014979319aac150f00294 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Wed, 10 Dec 2025 17:13:13 +0200 Subject: [PATCH 2/2] Add changeset --- .changeset/stale-wolves-follow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/stale-wolves-follow.md diff --git a/.changeset/stale-wolves-follow.md b/.changeset/stale-wolves-follow.md new file mode 100644 index 00000000000..791e5e37109 --- /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.