diff --git a/packages/graphiql-console/src/types/config.ts b/packages/graphiql-console/src/types/config.ts new file mode 100644 index 0000000000..0f45af12ce --- /dev/null +++ b/packages/graphiql-console/src/types/config.ts @@ -0,0 +1,33 @@ +export interface GraphiQLConfig { + // Initial server data + apiVersion: string + apiVersions: string[] + appName: string + appUrl: string + storeFqdn: string + // Optional auth key + key?: string + + // API endpoints + baseUrl: string + + // Optional initial query state + query?: string + variables?: string + + // Default queries for tabs + defaultQueries?: { + query: string + variables?: string + preface?: string + }[] +} + +// Global config interface +declare global { + interface Window { + __GRAPHIQL_CONFIG__?: GraphiQLConfig + } +} + +export {} diff --git a/packages/graphiql-console/src/utils/configValidation.test.ts b/packages/graphiql-console/src/utils/configValidation.test.ts new file mode 100644 index 0000000000..85faaa337d --- /dev/null +++ b/packages/graphiql-console/src/utils/configValidation.test.ts @@ -0,0 +1,313 @@ +import {validateConfig} from './configValidation.ts' +import {describe, test, expect, vi} from 'vitest' +import type {GraphiQLConfig} from '@/types/config.ts' + +describe('validateConfig', () => { + const fallbackConfig: GraphiQLConfig = { + baseUrl: 'http://localhost:3457', + apiVersion: '2024-10', + apiVersions: ['2024-01', '2024-04', '2024-07', '2024-10'], + appName: 'Test App', + appUrl: 'http://localhost:3000', + storeFqdn: 'test-store.myshopify.com', + } + + describe('URL validation', () => { + test('accepts valid localhost URLs', () => { + const config = { + ...fallbackConfig, + baseUrl: 'http://localhost:3457', + appUrl: 'http://127.0.0.1:3000', + } + const result = validateConfig(config, fallbackConfig) + expect(result.baseUrl).toBe('http://localhost:3457') + expect(result.appUrl).toBe('http://127.0.0.1:3000') + }) + + test('accepts valid Shopify domain URLs', () => { + const config = { + ...fallbackConfig, + baseUrl: 'https://my-store.myshopify.com', + appUrl: 'https://test-app.myshopify.com', + } + const result = validateConfig(config, fallbackConfig) + expect(result.baseUrl).toBe('https://my-store.myshopify.com') + expect(result.appUrl).toBe('https://test-app.myshopify.com') + }) + + test('rejects javascript: protocol URLs', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + baseUrl: 'javascript:alert("XSS")', + appUrl: 'javascript:void(0)', + } + const result = validateConfig(config, fallbackConfig) + expect(result.baseUrl).toBe(fallbackConfig.baseUrl) + expect(result.appUrl).toBe(fallbackConfig.appUrl) + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] Unsafe URL rejected')) + + consoleWarnSpy.mockRestore() + }) + + test('rejects data: protocol URLs', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + baseUrl: 'data:text/html,', + } + const result = validateConfig(config, fallbackConfig) + expect(result.baseUrl).toBe(fallbackConfig.baseUrl) + + consoleWarnSpy.mockRestore() + }) + + test('rejects URLs with embedded script tags', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + baseUrl: 'http://localhost:3457/', + } + const result = validateConfig(config, fallbackConfig) + expect(result.baseUrl).toBe(fallbackConfig.baseUrl) + + consoleWarnSpy.mockRestore() + }) + + test('rejects URLs with event handlers', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + appUrl: 'http://localhost" onerror="alert(1)', + } + const result = validateConfig(config, fallbackConfig) + expect(result.appUrl).toBe(fallbackConfig.appUrl) + + consoleWarnSpy.mockRestore() + }) + + test('rejects URLs not in allowlist', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + baseUrl: 'https://evil.com', + appUrl: 'http://malicious.site', + } + const result = validateConfig(config, fallbackConfig) + expect(result.baseUrl).toBe(fallbackConfig.baseUrl) + expect(result.appUrl).toBe(fallbackConfig.appUrl) + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] URL not in allowlist')) + + consoleWarnSpy.mockRestore() + }) + }) + + describe('string sanitization', () => { + test('accepts valid string values', () => { + const config = { + ...fallbackConfig, + apiVersion: '2024-10', + appName: 'My Test App', + storeFqdn: 'my-store.myshopify.com', + } + const result = validateConfig(config, fallbackConfig) + expect(result.apiVersion).toBe('2024-10') + expect(result.appName).toBe('My Test App') + expect(result.storeFqdn).toBe('my-store.myshopify.com') + }) + + test('sanitizes strings with script tags', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + appName: 'Malicious App', + } + const result = validateConfig(config, fallbackConfig) + expect(result.appName).toBe(fallbackConfig.appName) + + consoleWarnSpy.mockRestore() + }) + + test('sanitizes strings with event handlers', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + storeFqdn: 'test" onerror="alert(1)', + } + const result = validateConfig(config, fallbackConfig) + expect(result.storeFqdn).toBe(fallbackConfig.storeFqdn) + + consoleWarnSpy.mockRestore() + }) + + test('sanitizes strings with javascript: protocol', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + appName: 'javascript:alert("XSS")', + } + const result = validateConfig(config, fallbackConfig) + expect(result.appName).toBe(fallbackConfig.appName) + + consoleWarnSpy.mockRestore() + }) + }) + + describe('array validation', () => { + test('filters and sanitizes apiVersions array', () => { + const config = { + ...fallbackConfig, + apiVersions: ['2024-10', '', '2024-07', 123 as any], + } + const result = validateConfig(config, fallbackConfig) + expect(result.apiVersions).toHaveLength(2) + expect(result.apiVersions).toContain('2024-10') + expect(result.apiVersions).toContain('2024-07') + expect(result.apiVersions).not.toContain('') + }) + + test('uses fallback for invalid apiVersions', () => { + const config = { + ...fallbackConfig, + apiVersions: 'not-an-array' as any, + } + const result = validateConfig(config, fallbackConfig) + expect(result.apiVersions).toEqual(fallbackConfig.apiVersions) + }) + }) + + describe('optional fields', () => { + test('preserves valid optional fields', () => { + const config = { + ...fallbackConfig, + key: 'safe-key-123', + query: '{ shop { name } }', + variables: '{}', + } + const result = validateConfig(config, fallbackConfig) + expect(result.key).toBe('safe-key-123') + expect(result.query).toBe('{ shop { name } }') + expect(result.variables).toBe('{}') + }) + + test('sanitizes optional fields with dangerous content', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + query: '{ shop { name } }', + } + const result = validateConfig(config, fallbackConfig) + expect(result.query).toBe('') + + consoleWarnSpy.mockRestore() + }) + + test('omits optional fields when undefined', () => { + const config = { + ...fallbackConfig, + } + const result = validateConfig(config, fallbackConfig) + expect(result.key).toBeUndefined() + expect(result.query).toBeUndefined() + expect(result.variables).toBeUndefined() + }) + }) + + describe('defaultQueries validation', () => { + test('validates and sanitizes defaultQueries array', () => { + const config = { + ...fallbackConfig, + defaultQueries: [ + { + query: '{ shop { name } }', + variables: '{}', + preface: 'Get shop info', + }, + { + query: '', + variables: '{}', + }, + ], + } + const result = validateConfig(config, fallbackConfig) + expect(result.defaultQueries).toHaveLength(2) + expect(result.defaultQueries?.[0]?.query).toBe('{ shop { name } }') + expect(result.defaultQueries?.[1]?.query).toBe('') + }) + + test('uses fallback for invalid defaultQueries', () => { + const config = { + ...fallbackConfig, + defaultQueries: 'not-an-array' as any, + } + const result = validateConfig(config, fallbackConfig) + expect(result.defaultQueries).toBe(fallbackConfig.defaultQueries) + }) + }) + + describe('invalid input handling', () => { + test('returns fallback for undefined config', () => { + const result = validateConfig(undefined, fallbackConfig) + expect(result).toEqual(fallbackConfig) + }) + + test('returns fallback for null config', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = validateConfig(null as any, fallbackConfig) + expect(result).toEqual(fallbackConfig) + + consoleWarnSpy.mockRestore() + }) + + test('returns fallback for non-object config', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = validateConfig('not an object' as any, fallbackConfig) + expect(result).toEqual(fallbackConfig) + + consoleWarnSpy.mockRestore() + }) + }) + + describe('complex XSS scenarios', () => { + test('blocks polyglot XSS attempts', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + appName: + 'jaVasCript:/*-/*`/*\\`/*\'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//\\x3csVg/\\x3e', + } + const result = validateConfig(config, fallbackConfig) + expect(result.appName).toBe(fallbackConfig.appName) + + consoleWarnSpy.mockRestore() + }) + + test('allows localhost URL with query parameters', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const config = { + ...fallbackConfig, + appUrl: 'http://localhost:3000?query=%3Cscript%3Ealert(1)%3C/script%3E', + } + const result = validateConfig(config, fallbackConfig) + // Localhost URLs are allowed, query params are preserved by URL constructor + // The protection is at the protocol/domain level, not query string + expect(result.appUrl).toBe('http://localhost:3000?query=%3Cscript%3Ealert(1)%3C/script%3E') + expect(consoleWarnSpy).not.toHaveBeenCalled() + + consoleWarnSpy.mockRestore() + }) + }) +}) diff --git a/packages/graphiql-console/src/utils/configValidation.ts b/packages/graphiql-console/src/utils/configValidation.ts new file mode 100644 index 0000000000..d34a31e850 --- /dev/null +++ b/packages/graphiql-console/src/utils/configValidation.ts @@ -0,0 +1,153 @@ +import type {GraphiQLConfig} from '@/types/config.ts' + +/** + * Security: URL validation to prevent XSS and injection attacks + * + * This module validates and sanitizes URLs from window.__GRAPHIQL_CONFIG__ + * to prevent malicious scripts or data from being injected through config. + */ + +const SAFE_URL_PROTOCOLS = ['http:', 'https:'] +const ALLOWED_LOCALHOST_PATTERNS = [ + /^https?:\/\/localhost(:\d+)?(\/.*)?(\?.*)?$/, + /^https?:\/\/127\.0\.0\.1(:\d+)?(\/.*)?(\?.*)?$/, + /^https?:\/\/\[::1\](:\d+)?(\/.*)?(\?.*)?$/, +] +const ALLOWED_SHOPIFY_PATTERN = /^https:\/\/[a-zA-Z0-9-]+\.myshopify\.(com|io)(:\d+)?(\/.*)?(\?.*)?$/ + +/** + * Validates that a URL is safe to use (no javascript:, data:, or other dangerous protocols) + */ +function isUrlSafe(url: string): boolean { + try { + // eslint-disable-next-line node/no-unsupported-features/node-builtins + const parsed = new URL(url) + + // Only allow http/https protocols + if (!SAFE_URL_PROTOCOLS.includes(parsed.protocol)) { + return false + } + + // Check for suspicious patterns that could indicate XSS attempts + const suspiciousPatterns = [/javascript:/i, /data:/i, /vbscript:/i, /