Skip to content

Commit f14423d

Browse files
committed
feat(graphiql): add shared types, constants, and validation
Adds type definitions, configuration validation with XSS prevention, and default GraphQL query constants. - Add GraphiQL config type definitions - Add default welcome message and shop query constants - Add config validation with security checks (313 lines of tests) - Prevent XSS attacks through URL validation and string sanitization All validation tests pass
1 parent 6ad1b53 commit f14423d

File tree

5 files changed

+541
-0
lines changed

5 files changed

+541
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Determine control key based on platform (browser-based detection)
2+
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
3+
const controlKey = isMac ? '⌘' : 'Ctrl'
4+
5+
export const WELCOME_MESSAGE = `# Welcome to GraphiQL for the Shopify Admin API! If you've used
6+
# GraphiQL before, you can jump to the next tab.
7+
#
8+
# GraphiQL is an in-browser tool for writing, validating, and
9+
# testing GraphQL queries.
10+
#
11+
# Type queries into this side of the screen, and you will see intelligent
12+
# typeaheads aware of the current GraphQL type schema and live syntax and
13+
# validation errors highlighted within the text.
14+
#
15+
# GraphQL queries typically start with a "{" character. Lines that start
16+
# with a # are ignored.
17+
#
18+
# Keyboard shortcuts:
19+
#
20+
# Prettify query: Shift-${controlKey}-P (or press the prettify button)
21+
#
22+
# Merge fragments: Shift-${controlKey}-M (or press the merge button)
23+
#
24+
# Run Query: ${controlKey}-Enter (or press the play button)
25+
#
26+
# Auto Complete: ${controlKey}-Space (or just start typing)
27+
#
28+
`
29+
30+
export const DEFAULT_SHOP_QUERY = `query shopInfo {
31+
shop {
32+
name
33+
url
34+
myshopifyDomain
35+
plan {
36+
displayName
37+
partnerDevelopment
38+
shopifyPlus
39+
}
40+
}
41+
}`
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export interface GraphiQLConfig {
2+
// Initial server data
3+
apiVersion: string
4+
apiVersions: string[]
5+
appName: string
6+
appUrl: string
7+
storeFqdn: string
8+
// Optional auth key
9+
key?: string
10+
11+
// API endpoints
12+
baseUrl: string
13+
14+
// Optional initial query state
15+
query?: string
16+
variables?: string
17+
18+
// Default queries for tabs
19+
defaultQueries?: {
20+
query: string
21+
variables?: string
22+
preface?: string
23+
}[]
24+
}
25+
26+
// Global config interface
27+
declare global {
28+
interface Window {
29+
__GRAPHIQL_CONFIG__?: GraphiQLConfig
30+
}
31+
}
32+
33+
export {}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './config.ts'
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import {validateConfig} from './configValidation.ts'
2+
import {describe, test, expect, vi} from 'vitest'
3+
import type {GraphiQLConfig} from '@/types/config.ts'
4+
5+
describe('validateConfig', () => {
6+
const fallbackConfig: GraphiQLConfig = {
7+
baseUrl: 'http://localhost:3457',
8+
apiVersion: '2024-10',
9+
apiVersions: ['2024-01', '2024-04', '2024-07', '2024-10'],
10+
appName: 'Test App',
11+
appUrl: 'http://localhost:3000',
12+
storeFqdn: 'test-store.myshopify.com',
13+
}
14+
15+
describe('URL validation', () => {
16+
test('accepts valid localhost URLs', () => {
17+
const config = {
18+
...fallbackConfig,
19+
baseUrl: 'http://localhost:3457',
20+
appUrl: 'http://127.0.0.1:3000',
21+
}
22+
const result = validateConfig(config, fallbackConfig)
23+
expect(result.baseUrl).toBe('http://localhost:3457')
24+
expect(result.appUrl).toBe('http://127.0.0.1:3000')
25+
})
26+
27+
test('accepts valid Shopify domain URLs', () => {
28+
const config = {
29+
...fallbackConfig,
30+
baseUrl: 'https://my-store.myshopify.com',
31+
appUrl: 'https://test-app.myshopify.com',
32+
}
33+
const result = validateConfig(config, fallbackConfig)
34+
expect(result.baseUrl).toBe('https://my-store.myshopify.com')
35+
expect(result.appUrl).toBe('https://test-app.myshopify.com')
36+
})
37+
38+
test('rejects javascript: protocol URLs', () => {
39+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
40+
41+
const config = {
42+
...fallbackConfig,
43+
baseUrl: 'javascript:alert("XSS")',
44+
appUrl: 'javascript:void(0)',
45+
}
46+
const result = validateConfig(config, fallbackConfig)
47+
expect(result.baseUrl).toBe(fallbackConfig.baseUrl)
48+
expect(result.appUrl).toBe(fallbackConfig.appUrl)
49+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] Unsafe URL rejected'))
50+
51+
consoleWarnSpy.mockRestore()
52+
})
53+
54+
test('rejects data: protocol URLs', () => {
55+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
56+
57+
const config = {
58+
...fallbackConfig,
59+
baseUrl: 'data:text/html,<script>alert("XSS")</script>',
60+
}
61+
const result = validateConfig(config, fallbackConfig)
62+
expect(result.baseUrl).toBe(fallbackConfig.baseUrl)
63+
64+
consoleWarnSpy.mockRestore()
65+
})
66+
67+
test('rejects URLs with embedded script tags', () => {
68+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
69+
70+
const config = {
71+
...fallbackConfig,
72+
baseUrl: 'http://localhost:3457/<script>alert("XSS")</script>',
73+
}
74+
const result = validateConfig(config, fallbackConfig)
75+
expect(result.baseUrl).toBe(fallbackConfig.baseUrl)
76+
77+
consoleWarnSpy.mockRestore()
78+
})
79+
80+
test('rejects URLs with event handlers', () => {
81+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
82+
83+
const config = {
84+
...fallbackConfig,
85+
appUrl: 'http://localhost" onerror="alert(1)',
86+
}
87+
const result = validateConfig(config, fallbackConfig)
88+
expect(result.appUrl).toBe(fallbackConfig.appUrl)
89+
90+
consoleWarnSpy.mockRestore()
91+
})
92+
93+
test('rejects URLs not in allowlist', () => {
94+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
95+
96+
const config = {
97+
...fallbackConfig,
98+
baseUrl: 'https://evil.com',
99+
appUrl: 'http://malicious.site',
100+
}
101+
const result = validateConfig(config, fallbackConfig)
102+
expect(result.baseUrl).toBe(fallbackConfig.baseUrl)
103+
expect(result.appUrl).toBe(fallbackConfig.appUrl)
104+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] URL not in allowlist'))
105+
106+
consoleWarnSpy.mockRestore()
107+
})
108+
})
109+
110+
describe('string sanitization', () => {
111+
test('accepts valid string values', () => {
112+
const config = {
113+
...fallbackConfig,
114+
apiVersion: '2024-10',
115+
appName: 'My Test App',
116+
storeFqdn: 'my-store.myshopify.com',
117+
}
118+
const result = validateConfig(config, fallbackConfig)
119+
expect(result.apiVersion).toBe('2024-10')
120+
expect(result.appName).toBe('My Test App')
121+
expect(result.storeFqdn).toBe('my-store.myshopify.com')
122+
})
123+
124+
test('sanitizes strings with script tags', () => {
125+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
126+
127+
const config = {
128+
...fallbackConfig,
129+
appName: '<script>alert("XSS")</script>Malicious App',
130+
}
131+
const result = validateConfig(config, fallbackConfig)
132+
expect(result.appName).toBe(fallbackConfig.appName)
133+
134+
consoleWarnSpy.mockRestore()
135+
})
136+
137+
test('sanitizes strings with event handlers', () => {
138+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
139+
140+
const config = {
141+
...fallbackConfig,
142+
storeFqdn: 'test" onerror="alert(1)',
143+
}
144+
const result = validateConfig(config, fallbackConfig)
145+
expect(result.storeFqdn).toBe(fallbackConfig.storeFqdn)
146+
147+
consoleWarnSpy.mockRestore()
148+
})
149+
150+
test('sanitizes strings with javascript: protocol', () => {
151+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
152+
153+
const config = {
154+
...fallbackConfig,
155+
appName: 'javascript:alert("XSS")',
156+
}
157+
const result = validateConfig(config, fallbackConfig)
158+
expect(result.appName).toBe(fallbackConfig.appName)
159+
160+
consoleWarnSpy.mockRestore()
161+
})
162+
})
163+
164+
describe('array validation', () => {
165+
test('filters and sanitizes apiVersions array', () => {
166+
const config = {
167+
...fallbackConfig,
168+
apiVersions: ['2024-10', '<script>alert("XSS")</script>', '2024-07', 123 as any],
169+
}
170+
const result = validateConfig(config, fallbackConfig)
171+
expect(result.apiVersions).toHaveLength(2)
172+
expect(result.apiVersions).toContain('2024-10')
173+
expect(result.apiVersions).toContain('2024-07')
174+
expect(result.apiVersions).not.toContain('<script>alert("XSS")</script>')
175+
})
176+
177+
test('uses fallback for invalid apiVersions', () => {
178+
const config = {
179+
...fallbackConfig,
180+
apiVersions: 'not-an-array' as any,
181+
}
182+
const result = validateConfig(config, fallbackConfig)
183+
expect(result.apiVersions).toEqual(fallbackConfig.apiVersions)
184+
})
185+
})
186+
187+
describe('optional fields', () => {
188+
test('preserves valid optional fields', () => {
189+
const config = {
190+
...fallbackConfig,
191+
key: 'safe-key-123',
192+
query: '{ shop { name } }',
193+
variables: '{}',
194+
}
195+
const result = validateConfig(config, fallbackConfig)
196+
expect(result.key).toBe('safe-key-123')
197+
expect(result.query).toBe('{ shop { name } }')
198+
expect(result.variables).toBe('{}')
199+
})
200+
201+
test('sanitizes optional fields with dangerous content', () => {
202+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
203+
204+
const config = {
205+
...fallbackConfig,
206+
query: '<script>alert("XSS")</script>{ shop { name } }',
207+
}
208+
const result = validateConfig(config, fallbackConfig)
209+
expect(result.query).toBe('')
210+
211+
consoleWarnSpy.mockRestore()
212+
})
213+
214+
test('omits optional fields when undefined', () => {
215+
const config = {
216+
...fallbackConfig,
217+
}
218+
const result = validateConfig(config, fallbackConfig)
219+
expect(result.key).toBeUndefined()
220+
expect(result.query).toBeUndefined()
221+
expect(result.variables).toBeUndefined()
222+
})
223+
})
224+
225+
describe('defaultQueries validation', () => {
226+
test('validates and sanitizes defaultQueries array', () => {
227+
const config = {
228+
...fallbackConfig,
229+
defaultQueries: [
230+
{
231+
query: '{ shop { name } }',
232+
variables: '{}',
233+
preface: 'Get shop info',
234+
},
235+
{
236+
query: '<script>alert("XSS")</script>',
237+
variables: '{}',
238+
},
239+
],
240+
}
241+
const result = validateConfig(config, fallbackConfig)
242+
expect(result.defaultQueries).toHaveLength(2)
243+
expect(result.defaultQueries?.[0]?.query).toBe('{ shop { name } }')
244+
expect(result.defaultQueries?.[1]?.query).toBe('')
245+
})
246+
247+
test('uses fallback for invalid defaultQueries', () => {
248+
const config = {
249+
...fallbackConfig,
250+
defaultQueries: 'not-an-array' as any,
251+
}
252+
const result = validateConfig(config, fallbackConfig)
253+
expect(result.defaultQueries).toBe(fallbackConfig.defaultQueries)
254+
})
255+
})
256+
257+
describe('invalid input handling', () => {
258+
test('returns fallback for undefined config', () => {
259+
const result = validateConfig(undefined, fallbackConfig)
260+
expect(result).toEqual(fallbackConfig)
261+
})
262+
263+
test('returns fallback for null config', () => {
264+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
265+
266+
const result = validateConfig(null as any, fallbackConfig)
267+
expect(result).toEqual(fallbackConfig)
268+
269+
consoleWarnSpy.mockRestore()
270+
})
271+
272+
test('returns fallback for non-object config', () => {
273+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
274+
275+
const result = validateConfig('not an object' as any, fallbackConfig)
276+
expect(result).toEqual(fallbackConfig)
277+
278+
consoleWarnSpy.mockRestore()
279+
})
280+
})
281+
282+
describe('complex XSS scenarios', () => {
283+
test('blocks polyglot XSS attempts', () => {
284+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
285+
286+
const config = {
287+
...fallbackConfig,
288+
appName:
289+
'jaVasCript:/*-/*`/*\\`/*\'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\\x3csVg/<sVg/oNloAd=alert()//>\\x3e',
290+
}
291+
const result = validateConfig(config, fallbackConfig)
292+
expect(result.appName).toBe(fallbackConfig.appName)
293+
294+
consoleWarnSpy.mockRestore()
295+
})
296+
297+
test('allows localhost URL with query parameters', () => {
298+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
299+
300+
const config = {
301+
...fallbackConfig,
302+
appUrl: 'http://localhost:3000?query=%3Cscript%3Ealert(1)%3C/script%3E',
303+
}
304+
const result = validateConfig(config, fallbackConfig)
305+
// Localhost URLs are allowed, query params are preserved by URL constructor
306+
// The protection is at the protocol/domain level, not query string
307+
expect(result.appUrl).toBe('http://localhost:3000?query=%3Cscript%3Ealert(1)%3C/script%3E')
308+
expect(consoleWarnSpy).not.toHaveBeenCalled()
309+
310+
consoleWarnSpy.mockRestore()
311+
})
312+
})
313+
})

0 commit comments

Comments
 (0)