Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stale-wolves-follow.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/app/src/cli/services/dev/graphiql/server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
)
Expand Down
99 changes: 99 additions & 0 deletions packages/app/src/cli/services/dev/graphiql/utilities.test.ts
Original file line number Diff line number Diff line change
@@ -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({})
})
})
42 changes: 42 additions & 0 deletions packages/app/src/cli/services/dev/graphiql/utilities.ts
Original file line number Diff line number Diff line change
@@ -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
}