From bb8bb66b24b11d2d2e7abb0fa4aa721d24a96145 Mon Sep 17 00:00:00 2001 From: ajithakdev Date: Sun, 6 Sep 2026 20:50:50 +0530 Subject: [PATCH] fix(api-tester): sanitize auth credentials and prevent plaintext localStorage persistence - Exclude bearer tokens, basic auth credentials, and Authorization headers from localStorage persistence - Add automatic legacy unencrypted credentials migration and storage scrubbing on mount - Add prominent security & privacy alert banner to Save Workflow modal with optional tab-only sessionStorage toggle - Add 'Forget Credentials' action in builder Auth tab and 'Forget Stored Credentials' in Workflows tab - Add 'Clear History' action in Request History tab - Mark saved workflows requiring credentials with an Auth Required badge - Sanitize workflow export JSON and import inputs - Add comprehensive unit test suite in curlSecurity.test.ts Closes #72 --- src/tools/curl-to-fetch/CurlTool.tsx | 384 +++++++++++++++---- src/tools/curl-to-fetch/curlSecurity.test.ts | 286 ++++++++++++++ src/tools/curl-to-fetch/curlSecurity.ts | 252 ++++++++++++ 3 files changed, 842 insertions(+), 80 deletions(-) create mode 100644 src/tools/curl-to-fetch/curlSecurity.test.ts create mode 100644 src/tools/curl-to-fetch/curlSecurity.ts diff --git a/src/tools/curl-to-fetch/CurlTool.tsx b/src/tools/curl-to-fetch/CurlTool.tsx index 0f87164..44fd4d9 100644 --- a/src/tools/curl-to-fetch/CurlTool.tsx +++ b/src/tools/curl-to-fetch/CurlTool.tsx @@ -6,11 +6,25 @@ import { Button } from '../../components/ui/Button'; import { Play, Clipboard, Plus, Trash2, KeyRound, Globe, FileJson, Check, Copy, Sparkles, Save, Download, Upload, - History as HistoryIcon, Edit2 + History as HistoryIcon, Edit2, ShieldAlert, ShieldCheck, Lock } from 'lucide-react'; import { useParams } from 'react-router-dom'; import { useToolStats } from '../../hooks/useToolStats'; import { useToast } from '../../components/ui/Toast'; +import { + HeaderParam, + SavedRequest, + HistoryItem, + isSensitiveHeader, + sanitizeHeaders, + hasAuthCredentials, + sanitizeSavedRequest, + sanitizeHistoryItem, + saveSessionAuth, + getSessionAuth, + clearSessionAuth, + scrubStorageAuth +} from './curlSecurity'; interface QueryParam { key: string; @@ -19,45 +33,6 @@ interface QueryParam { file?: File; } -interface HeaderParam { - key: string; - value: string; -} - -interface SavedRequest { - id: string; - name: string; - url: string; - method: string; - headers: HeaderParam[]; - queryParams: { key: string; value: string; type: 'text' }[]; - authType: 'none' | 'bearer' | 'basic'; - bearerToken: string; - basicUser: string; - basicPass: string; - bodyText: string; - autoCopyPath?: string; - runCount?: number; - avgTime?: number; - description?: string; - lastStatus?: number; - lastStatusText?: string; - lastRunTimestamp?: number; -} - -interface HistoryItem { - id: string; - timestamp: number; - url: string; - method: string; - headers: HeaderParam[]; - queryParams: { key: string; value: string; type: 'text' }[]; - authType: 'none' | 'bearer' | 'basic'; - bearerToken: string; - basicUser: string; - basicPass: string; - bodyText: string; -} function parseCurl(curlString: string) { const str = curlString.replace(/\\\n/g, ' ').trim(); @@ -209,11 +184,13 @@ export default function CurlTool() { const [error, setError] = useState(''); const [lineWrap, setLineWrap] = useState(false); - // Saved Workflows & History State + // Saved Workflows & History State (100% Privacy sanitized from localStorage) const [savedRequests, setSavedRequests] = useState(() => { try { const stored = localStorage.getItem('toolglass_saved_requests'); - return stored ? JSON.parse(stored) : []; + if (!stored) return []; + const parsed: SavedRequest[] = JSON.parse(stored); + return Array.isArray(parsed) ? parsed.map(sanitizeSavedRequest) : []; } catch { return []; } @@ -222,7 +199,9 @@ export default function CurlTool() { const [requestHistory, setRequestHistory] = useState(() => { try { const stored = localStorage.getItem('toolglass_request_history'); - return stored ? JSON.parse(stored) : []; + if (!stored) return []; + const parsed: HistoryItem[] = JSON.parse(stored); + return Array.isArray(parsed) ? parsed.map(sanitizeHistoryItem) : []; } catch { return []; } @@ -232,8 +211,22 @@ export default function CurlTool() { const [saveName, setSaveName] = useState(''); const [saveDescription, setSaveDescription] = useState(''); const [autoCopyPath, setAutoCopyPath] = useState(''); + const [saveToSession, setSaveToSession] = useState(false); const [editingWorkflowId, setEditingWorkflowId] = useState(null); + // Privacy migration: Scrub any legacy unencrypted tokens from localStorage on mount + useEffect(() => { + const { scrubbedSaved, scrubbedHistory } = scrubStorageAuth(); + if (scrubbedSaved > 0 || scrubbedHistory > 0) { + toast.push( + `Privacy Protection: Cleared legacy unencrypted credentials from ${scrubbedSaved + scrubbedHistory} stored items.`, + 'info' + ); + setSavedRequests((prev) => prev.map(sanitizeSavedRequest)); + setRequestHistory((prev) => prev.map(sanitizeHistoryItem)); + } + }, [toast]); + // Keyboard shortcut Alt+Z to toggle word wrap useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -310,38 +303,83 @@ export default function CurlTool() { setMethod(config.method || 'GET'); setQueryParams(config.queryParams && config.queryParams.length ? config.queryParams : [{ key: '', value: '', type: 'text' }]); setAuthType(config.authType || 'none'); - setBearerToken(config.bearerToken || ''); - setBasicUser(config.basicUser || ''); - setBasicPass(config.basicPass || ''); + + // Restore credentials from tab's sessionStorage if available for this workflow ID + const sessionAuth = config.id ? getSessionAuth(config.id) : null; + const token = sessionAuth?.bearerToken || config.bearerToken || ''; + const user = sessionAuth?.basicUser || config.basicUser || ''; + const pass = sessionAuth?.basicPass || config.basicPass || ''; + + setBearerToken(token); + setBasicUser(user); + setBasicPass(pass); setHeaders(config.headers && config.headers.length ? config.headers : [{ key: '', value: '' }]); setBodyText(config.bodyText || ''); setMainTab('testing'); // Switch back to editor - toast.push('Request loaded into builder', 'info'); + + if (config.authType && config.authType !== 'none' && !token && !pass) { + toast.push(`Request loaded. Please enter credentials for ${config.authType.toUpperCase()} authentication.`, 'info'); + } else { + toast.push('Request loaded into builder', 'info'); + } }; const addHistoryItem = (config: any) => { - const newItem: HistoryItem = { + const rawItem: HistoryItem = { id: Date.now().toString(), timestamp: Date.now(), url: config.url, method: config.method, queryParams: config.queryParams || [], authType: config.authType || 'none', - bearerToken: config.bearerToken || '', - basicUser: config.basicUser || '', - basicPass: config.basicPass || '', - headers: config.headers || [], + bearerToken: '', + basicUser: '', + basicPass: '', + headers: sanitizeHeaders(config.headers || []), bodyText: config.bodyText || '', }; + const newItem = sanitizeHistoryItem(rawItem); + setRequestHistory((prev) => { const filtered = prev.filter(item => item.url !== config.url || item.method !== config.method); const updated = [newItem, ...filtered].slice(0, 15); // Limit to last 15 requests - localStorage.setItem('toolglass_request_history', JSON.stringify(updated)); + localStorage.setItem('toolglass_request_history', JSON.stringify(updated.map(sanitizeHistoryItem))); return updated; }); }; + const handleClearHistory = () => { + setRequestHistory([]); + localStorage.removeItem('toolglass_request_history'); + toast.push('Request history cleared', 'info'); + }; + + const handleForgetActiveCredentials = () => { + setBearerToken(''); + setBasicUser(''); + setBasicPass(''); + setHeaders(prev => { + const filtered = prev.filter(h => !isSensitiveHeader(h.key)); + return filtered.length > 0 ? filtered : [{ key: '', value: '' }]; + }); + clearSessionAuth(); + toast.push('Active credentials forgotten and purged from memory.', 'success'); + }; + + const handleForgetAllSavedCredentials = () => { + clearSessionAuth(); + const { scrubbedSaved, scrubbedHistory } = scrubStorageAuth(); + setSavedRequests(prev => prev.map(sanitizeSavedRequest)); + setRequestHistory(prev => prev.map(sanitizeHistoryItem)); + toast.push( + scrubbedSaved > 0 || scrubbedHistory > 0 + ? `Purged all credentials! Scrubbed ${scrubbedSaved} workflows and ${scrubbedHistory} history items.` + : 'All session credentials and stored auth data purged.', + 'success' + ); + }; + const getDetectedKeys = () => { if (!response || !response.rawData) return { status: 'no_response' }; if (response.rawData.length > 1024 * 1024) return { status: 'too_large' }; @@ -360,6 +398,9 @@ export default function CurlTool() { return; } + const currentConfig = getCurrentRequestConfig(); + const hasCreds = hasAuthCredentials(currentConfig); + if (editingWorkflowId) { setSavedRequests((prev) => { const updated = prev.map((r) => { @@ -373,29 +414,44 @@ export default function CurlTool() { } return r; }); - localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated)); + localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated.map(sanitizeSavedRequest))); return updated; }); + if (saveToSession && hasCreds) { + saveSessionAuth(editingWorkflowId, { + bearerToken: currentConfig.bearerToken, + basicUser: currentConfig.basicUser, + basicPass: currentConfig.basicPass + }); + } toast.push(`Workflow "${saveName}" updated!`, 'success'); } else { if (savedRequests.length >= 50) { toast.push('Maximum limit of 50 workflows reached. Please delete some before saving new ones.', 'error'); return; } - const currentConfig = getCurrentRequestConfig(); - const newWorkflow: SavedRequest = { - id: Date.now().toString(), + const newId = Date.now().toString(); + const sanitizedConfig = sanitizeSavedRequest({ + id: newId, name: saveName.trim(), description: saveDescription.trim() || undefined, autoCopyPath: autoCopyPath.trim() || undefined, runCount: 0, avgTime: 0, ...currentConfig - }; + }); + + if (saveToSession && hasCreds) { + saveSessionAuth(newId, { + bearerToken: currentConfig.bearerToken, + basicUser: currentConfig.basicUser, + basicPass: currentConfig.basicPass + }); + } setSavedRequests((prev) => { - const updated = [newWorkflow, ...prev]; - localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated)); + const updated = [sanitizedConfig, ...prev]; + localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated.map(sanitizeSavedRequest))); return updated; }); toast.push(`Workflow "${saveName}" saved!`, 'success'); @@ -405,6 +461,7 @@ export default function CurlTool() { setSaveName(''); setSaveDescription(''); setAutoCopyPath(''); + setSaveToSession(false); setEditingWorkflowId(null); }; @@ -417,9 +474,10 @@ export default function CurlTool() { }; const handleDeleteRequest = (id: string, name: string) => { + clearSessionAuth(id); setSavedRequests((prev) => { const updated = prev.filter(r => r.id !== id); - localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated)); + localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated.map(sanitizeSavedRequest))); return updated; }); toast.push(`Workflow "${name}" deleted`, 'info'); @@ -457,17 +515,37 @@ export default function CurlTool() { return; } + // Resolve authentication: check in-memory or sessionStorage + const sessionAuth = getSessionAuth(req.id); + const token = req.bearerToken?.trim() || sessionAuth?.bearerToken?.trim() || ''; + const user = req.basicUser?.trim() || sessionAuth?.basicUser?.trim() || ''; + const pass = req.basicPass?.trim() || sessionAuth?.basicPass?.trim() || ''; + + if (req.authType === 'bearer' && !token) { + loadRequestConfig(req); + setActiveTab('auth'); + toast.push(`Bearer token required: Please enter token to run "${req.name}"`, 'error', toastId); + return; + } + + if (req.authType === 'basic' && !user && !pass) { + loadRequestConfig(req); + setActiveTab('auth'); + toast.push(`Basic auth credentials required: Please enter credentials to run "${req.name}"`, 'error', toastId); + return; + } + const requestHeaders: Record = {}; req.headers.forEach((h) => { - if (h.key.trim()) { + if (h.key.trim() && !isSensitiveHeader(h.key)) { requestHeaders[h.key.trim()] = h.value.trim(); } }); - if (req.authType === 'bearer' && req.bearerToken?.trim()) { - requestHeaders['Authorization'] = `Bearer ${req.bearerToken.trim().replace(/^bearer\s+/i, '')}`; - } else if (req.authType === 'basic' && (req.basicUser?.trim() || req.basicPass?.trim())) { - requestHeaders['Authorization'] = `Basic ${btoa(`${req.basicUser}:${req.basicPass}`)}`; + if (req.authType === 'bearer' && token) { + requestHeaders['Authorization'] = `Bearer ${token.replace(/^bearer\s+/i, '')}`; + } else if (req.authType === 'basic' && (user || pass)) { + requestHeaders['Authorization'] = `Basic ${btoa(`${user}:${pass}`)}`; } let requestBody: any = undefined; @@ -532,7 +610,7 @@ export default function CurlTool() { } return r; }); - localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated)); + localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated.map(sanitizeSavedRequest))); return updated; }); @@ -551,21 +629,22 @@ export default function CurlTool() { } return r; }); - localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated)); + localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated.map(sanitizeSavedRequest))); return updated; }); } }; const handleExportWorkflows = () => { - const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(savedRequests, null, 2)); + const sanitized = savedRequests.map(sanitizeSavedRequest); + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(sanitized, null, 2)); const downloadAnchor = document.createElement('a'); downloadAnchor.setAttribute("href", dataStr); downloadAnchor.setAttribute("download", "toolglass_workflows.json"); document.body.appendChild(downloadAnchor); downloadAnchor.click(); downloadAnchor.remove(); - toast.push('Workflows exported successfully', 'success'); + toast.push('Workflows exported successfully (credentials excluded for privacy)', 'success'); }; const handleImportWorkflows = (e: React.ChangeEvent) => { @@ -576,12 +655,13 @@ export default function CurlTool() { try { const parsed = JSON.parse(event.target?.result as string); if (Array.isArray(parsed)) { + const sanitized = parsed.map(sanitizeSavedRequest); setSavedRequests((prev) => { - const updated = [...parsed, ...prev]; - localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated)); + const updated = [...sanitized, ...prev]; + localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated.map(sanitizeSavedRequest))); return updated; }); - toast.push('Workflows imported successfully', 'success'); + toast.push('Workflows imported securely (credentials stripped)', 'success'); } else { toast.push('Invalid JSON file format', 'error'); } @@ -1090,6 +1170,47 @@ export default function CurlTool() { {/* Auth tab */} {activeTab === 'auth' && (
+
+ + + 100% Client Privacy: Credentials are kept in memory only and never saved to localStorage. + + {(bearerToken.trim() || basicUser.trim() || basicPass.trim()) && ( + + )} +
+
@@ -1177,6 +1298,26 @@ export default function CurlTool() { placeholder="Header Value" style={{ fontSize: 13, fontFamily: 'var(--font-mono)' }} /> + {isSensitiveHeader(header.key) && ( + + Sensitive + + )} + + )} +
@@ -1683,6 +1863,42 @@ export default function CurlTool() { )}
+ {(() => { + const currentConfig = getCurrentRequestConfig(); + const hasCreds = hasAuthCredentials(currentConfig); + if (!hasCreds) return null; + return ( +
+ +
+ + Client Privacy & Security Protection Active + + + Credentials (Bearer tokens, Basic Auth passwords, and Authorization headers) are never stored in persistent localStorage to protect your security. + + +
+
+ ); + })()} + { const currentConfig = getCurrentRequestConfig(); + const hasCreds = hasAuthCredentials(currentConfig); + if (saveToSession && hasCreds) { + saveSessionAuth(editingWorkflowId, { + bearerToken: currentConfig.bearerToken, + basicUser: currentConfig.basicUser, + basicPass: currentConfig.basicPass + }); + } setSavedRequests((prev) => { const updated = prev.map((r) => { if (r.id === editingWorkflowId) { - return { + return sanitizeSavedRequest({ ...r, ...currentConfig - }; + }); } return r; }); - localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated)); + localStorage.setItem('toolglass_saved_requests', JSON.stringify(updated.map(sanitizeSavedRequest))); return updated; }); - toast.push('Request parameters updated from builder!', 'success'); + toast.push('Request parameters updated securely from builder!', 'success'); }} style={{ padding: '4px 10px', diff --git a/src/tools/curl-to-fetch/curlSecurity.test.ts b/src/tools/curl-to-fetch/curlSecurity.test.ts new file mode 100644 index 0000000..735970c --- /dev/null +++ b/src/tools/curl-to-fetch/curlSecurity.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + isSensitiveHeader, + sanitizeHeaders, + hasAuthCredentials, + sanitizeSavedRequest, + sanitizeHistoryItem, + saveSessionAuth, + getSessionAuth, + clearSessionAuth, + scrubStorageAuth, + SavedRequest, + HistoryItem +} from './curlSecurity'; + +describe('curlSecurity utilities', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + describe('isSensitiveHeader', () => { + it('identifies Authorization and variations as sensitive', () => { + expect(isSensitiveHeader('Authorization')).toBe(true); + expect(isSensitiveHeader('authorization')).toBe(true); + expect(isSensitiveHeader('AUTHORIZATION')).toBe(true); + expect(isSensitiveHeader(' authorization ')).toBe(true); + }); + + it('identifies Proxy-Authorization, x-api-key, and cookies as sensitive', () => { + expect(isSensitiveHeader('Proxy-Authorization')).toBe(true); + expect(isSensitiveHeader('x-api-key')).toBe(true); + expect(isSensitiveHeader('Cookie')).toBe(true); + expect(isSensitiveHeader('Set-Cookie')).toBe(true); + }); + + it('does not flag non-sensitive headers', () => { + expect(isSensitiveHeader('Content-Type')).toBe(false); + expect(isSensitiveHeader('Accept')).toBe(false); + expect(isSensitiveHeader('User-Agent')).toBe(false); + expect(isSensitiveHeader('X-Custom-Header')).toBe(false); + expect(isSensitiveHeader('')).toBe(false); + }); + }); + + describe('sanitizeHeaders', () => { + it('strips all sensitive headers while preserving normal headers', () => { + const headers = [ + { key: 'Content-Type', value: 'application/json' }, + { key: 'Authorization', value: 'Bearer secret_token_123' }, + { key: 'Accept', value: '*/*' }, + { key: 'X-API-KEY', value: 'my-api-key' }, + ]; + + const sanitized = sanitizeHeaders(headers); + expect(sanitized).toHaveLength(2); + expect(sanitized).toEqual([ + { key: 'Content-Type', value: 'application/json' }, + { key: 'Accept', value: '*/*' }, + ]); + }); + + it('handles empty or malformed arrays gracefully', () => { + expect(sanitizeHeaders([])).toEqual([]); + // @ts-expect-error test non-array safety + expect(sanitizeHeaders(null)).toEqual([]); + }); + }); + + describe('hasAuthCredentials', () => { + it('detects bearer token credentials', () => { + expect(hasAuthCredentials({ + authType: 'bearer', + bearerToken: 'secret123' + })).toBe(true); + + expect(hasAuthCredentials({ + authType: 'bearer', + bearerToken: ' ' + })).toBe(false); + }); + + it('detects basic auth credentials', () => { + expect(hasAuthCredentials({ + authType: 'basic', + basicUser: 'admin', + basicPass: '' + })).toBe(true); + + expect(hasAuthCredentials({ + authType: 'basic', + basicUser: '', + basicPass: 'hunter2' + })).toBe(true); + + expect(hasAuthCredentials({ + authType: 'basic', + basicUser: '', + basicPass: '' + })).toBe(false); + }); + + it('detects sensitive Authorization header with value', () => { + expect(hasAuthCredentials({ + authType: 'none', + headers: [ + { key: 'Authorization', value: 'Bearer xyz' } + ] + })).toBe(true); + + expect(hasAuthCredentials({ + authType: 'none', + headers: [ + { key: 'Authorization', value: ' ' }, + { key: 'Content-Type', value: 'application/json' } + ] + })).toBe(false); + }); + }); + + describe('sanitizeSavedRequest & sanitizeHistoryItem', () => { + const mockSavedReq: SavedRequest = { + id: 'req-1', + name: 'Test Workflow', + url: 'https://api.example.com/data', + method: 'GET', + headers: [ + { key: 'Content-Type', value: 'application/json' }, + { key: 'Authorization', value: 'Bearer token-to-strip' } + ], + queryParams: [], + authType: 'bearer', + bearerToken: 'token-to-strip', + basicUser: 'admin', + basicPass: 'secret', + bodyText: '{}', + }; + + it('purges sensitive tokens and headers from SavedRequest', () => { + const sanitized = sanitizeSavedRequest(mockSavedReq); + + expect(sanitized.bearerToken).toBe(''); + expect(sanitized.basicUser).toBe(''); + expect(sanitized.basicPass).toBe(''); + expect(sanitized.headers).toEqual([{ key: 'Content-Type', value: 'application/json' }]); + expect(sanitized.authType).toBe('bearer'); // keeps configuration type + expect(sanitized.requiresAuth).toBe(true); // marks that re-entry is required + }); + + it('purges sensitive tokens and headers from HistoryItem', () => { + const mockHistory: HistoryItem = { + id: 'hist-1', + timestamp: Date.now(), + url: 'https://api.example.com/test', + method: 'POST', + headers: [ + { key: 'Authorization', value: 'Bearer 123' }, + { key: 'X-App', value: 'toolglass' } + ], + queryParams: [], + authType: 'basic', + bearerToken: '123', + basicUser: 'test', + basicPass: 'pass', + bodyText: '', + }; + + const sanitized = sanitizeHistoryItem(mockHistory); + expect(sanitized.bearerToken).toBe(''); + expect(sanitized.basicUser).toBe(''); + expect(sanitized.basicPass).toBe(''); + expect(sanitized.headers).toEqual([{ key: 'X-App', value: 'toolglass' }]); + }); + }); + + describe('sessionStorage credential caching', () => { + it('saves, retrieves, and clears session auth', () => { + saveSessionAuth('wf-100', { bearerToken: 'session-token-xyz' }); + expect(getSessionAuth('wf-100')).toEqual({ bearerToken: 'session-token-xyz' }); + expect(getSessionAuth('wf-non-existent')).toBeNull(); + + // Clear specific + clearSessionAuth('wf-100'); + expect(getSessionAuth('wf-100')).toBeNull(); + + // Clear all + saveSessionAuth('wf-1', { basicUser: 'a', basicPass: 'b' }); + saveSessionAuth('wf-2', { bearerToken: 'c' }); + clearSessionAuth(); + expect(getSessionAuth('wf-1')).toBeNull(); + expect(getSessionAuth('wf-2')).toBeNull(); + }); + }); + + describe('scrubStorageAuth migration', () => { + it('scrubs unencrypted legacy credentials and authorization headers from localStorage', () => { + const dirtySaved: SavedRequest[] = [ + { + id: '1', + name: 'Dirty 1', + url: 'https://api.test/1', + method: 'GET', + headers: [{ key: 'Authorization', value: 'Bearer leaked' }], + queryParams: [], + authType: 'bearer', + bearerToken: 'leaked_token', + basicUser: '', + basicPass: '', + bodyText: '', + }, + { + id: '2', + name: 'Clean 2', + url: 'https://api.test/2', + method: 'GET', + headers: [{ key: 'Accept', value: 'text/html' }], + queryParams: [], + authType: 'none', + bearerToken: '', + basicUser: '', + basicPass: '', + bodyText: '', + } + ]; + + const dirtyHistory: HistoryItem[] = [ + { + id: 'h1', + timestamp: 1000, + url: 'https://api.test/hist', + method: 'POST', + headers: [{ key: 'Proxy-Authorization', value: 'Basic leakedpass' }], + queryParams: [], + authType: 'basic', + bearerToken: '', + basicUser: 'admin', + basicPass: 'leakedpass', + bodyText: '', + } + ]; + + localStorage.setItem('toolglass_saved_requests', JSON.stringify(dirtySaved)); + localStorage.setItem('toolglass_request_history', JSON.stringify(dirtyHistory)); + + const { scrubbedSaved, scrubbedHistory } = scrubStorageAuth(); + + expect(scrubbedSaved).toBe(1); + expect(scrubbedHistory).toBe(1); + + // Verify localStorage was updated and clean + const cleanedSaved: SavedRequest[] = JSON.parse(localStorage.getItem('toolglass_saved_requests')!); + expect(cleanedSaved[0].bearerToken).toBe(''); + expect(cleanedSaved[0].headers).toEqual([]); + expect(cleanedSaved[0].requiresAuth).toBe(true); + expect(cleanedSaved[1].bearerToken).toBe(''); + + const cleanedHistory: HistoryItem[] = JSON.parse(localStorage.getItem('toolglass_request_history')!); + expect(cleanedHistory[0].basicUser).toBe(''); + expect(cleanedHistory[0].basicPass).toBe(''); + expect(cleanedHistory[0].headers).toEqual([]); + }); + + it('does not modify localStorage if items are already clean', () => { + const cleanSaved: SavedRequest[] = [ + { + id: '1', + name: 'Clean', + url: 'https://api.test/1', + method: 'GET', + headers: [], + queryParams: [], + authType: 'none', + bearerToken: '', + basicUser: '', + basicPass: '', + bodyText: '', + } + ]; + + localStorage.setItem('toolglass_saved_requests', JSON.stringify(cleanSaved)); + const { scrubbedSaved, scrubbedHistory } = scrubStorageAuth(); + expect(scrubbedSaved).toBe(0); + expect(scrubbedHistory).toBe(0); + }); + }); +}); diff --git a/src/tools/curl-to-fetch/curlSecurity.ts b/src/tools/curl-to-fetch/curlSecurity.ts new file mode 100644 index 0000000..88505fd --- /dev/null +++ b/src/tools/curl-to-fetch/curlSecurity.ts @@ -0,0 +1,252 @@ +export interface HeaderParam { + key: string; + value: string; +} + +export interface SavedRequest { + id: string; + name: string; + url: string; + method: string; + headers: HeaderParam[]; + queryParams: { key: string; value: string; type: 'text' }[]; + authType: 'none' | 'bearer' | 'basic'; + bearerToken: string; + basicUser: string; + basicPass: string; + bodyText: string; + autoCopyPath?: string; + runCount?: number; + avgTime?: number; + description?: string; + lastStatus?: number; + lastStatusText?: string; + lastRunTimestamp?: number; + requiresAuth?: boolean; +} + +export interface HistoryItem { + id: string; + timestamp: number; + url: string; + method: string; + headers: HeaderParam[]; + queryParams: { key: string; value: string; type: 'text' }[]; + authType: 'none' | 'bearer' | 'basic'; + bearerToken: string; + basicUser: string; + basicPass: string; + bodyText: string; +} + +export interface SessionAuthData { + bearerToken?: string; + basicUser?: string; + basicPass?: string; +} + +const SENSITIVE_HEADER_NAMES = new Set([ + 'authorization', + 'proxy-authorization', + 'x-api-key', + 'cookie', + 'set-cookie', +]); + +const STORAGE_KEY_SAVED = 'toolglass_saved_requests'; +const STORAGE_KEY_HISTORY = 'toolglass_request_history'; +const SESSION_KEY_AUTH = 'toolglass_session_credentials'; + +/** + * Checks whether a header key contains sensitive credential data that must + * never be persisted to unencrypted localStorage or exported in workflows. + */ +export function isSensitiveHeader(key: string): boolean { + if (!key) return false; + return SENSITIVE_HEADER_NAMES.has(key.trim().toLowerCase()); +} + +/** + * Strips all sensitive headers from a header list. + */ +export function sanitizeHeaders(headers: HeaderParam[]): HeaderParam[] { + if (!Array.isArray(headers)) return []; + return headers.filter(h => !isSensitiveHeader(h.key)); +} + +/** + * Checks whether a request or configuration contains sensitive auth credentials. + */ +export function hasAuthCredentials(config: { + authType?: string; + bearerToken?: string; + basicUser?: string; + basicPass?: string; + headers?: HeaderParam[]; +}): boolean { + if (!config) return false; + if (config.authType === 'bearer' && (config.bearerToken || '').trim().length > 0) { + return true; + } + if ( + config.authType === 'basic' && + ((config.basicUser || '').trim().length > 0 || (config.basicPass || '').trim().length > 0) + ) { + return true; + } + if (Array.isArray(config.headers)) { + return config.headers.some(h => isSensitiveHeader(h.key) && h.value.trim().length > 0); + } + return false; +} + +/** + * Returns a sanitized copy of a saved request with sensitive credentials stripped. + * Note: requiresAuth flag is set to true if authType is 'bearer' or 'basic'. + */ +export function sanitizeSavedRequest(req: SavedRequest): SavedRequest { + const needsAuth = req.authType === 'bearer' || req.authType === 'basic'; + return { + ...req, + bearerToken: '', + basicUser: '', + basicPass: '', + headers: sanitizeHeaders(req.headers), + requiresAuth: needsAuth ? true : req.requiresAuth, + }; +} + +/** + * Returns a sanitized copy of a history item with sensitive credentials stripped. + */ +export function sanitizeHistoryItem(item: HistoryItem): HistoryItem { + return { + ...item, + bearerToken: '', + basicUser: '', + basicPass: '', + headers: sanitizeHeaders(item.headers), + }; +} + +/** + * Stores active session credentials in sessionStorage for a given workflow ID. + * SessionStorage is cleared automatically when the browser tab is closed. + */ +export function saveSessionAuth(workflowId: string, auth: SessionAuthData): void { + if (typeof window === 'undefined' || !window.sessionStorage) return; + try { + const raw = sessionStorage.getItem(SESSION_KEY_AUTH); + const store: Record = raw ? JSON.parse(raw) : {}; + store[workflowId] = auth; + sessionStorage.setItem(SESSION_KEY_AUTH, JSON.stringify(store)); + } catch { + // Ignore sessionStorage quota or security restrictions + } +} + +/** + * Retrieves active session credentials from sessionStorage for a given workflow ID. + */ +export function getSessionAuth(workflowId: string): SessionAuthData | null { + if (typeof window === 'undefined' || !window.sessionStorage) return null; + try { + const raw = sessionStorage.getItem(SESSION_KEY_AUTH); + if (!raw) return null; + const store: Record = JSON.parse(raw); + return store[workflowId] || null; + } catch { + return null; + } +} + +/** + * Clears all or a specific session auth entry from sessionStorage. + */ +export function clearSessionAuth(workflowId?: string): void { + if (typeof window === 'undefined' || !window.sessionStorage) return; + try { + if (workflowId) { + const raw = sessionStorage.getItem(SESSION_KEY_AUTH); + if (raw) { + const store: Record = JSON.parse(raw); + delete store[workflowId]; + sessionStorage.setItem(SESSION_KEY_AUTH, JSON.stringify(store)); + } + } else { + sessionStorage.removeItem(SESSION_KEY_AUTH); + } + } catch { + // Ignore + } +} + +/** + * Scans localStorage for any unencrypted legacy credentials or sensitive headers + * in saved requests and history. Sanitizes them in place and updates localStorage. + * Returns the count of scrubbed items. + */ +export function scrubStorageAuth(): { scrubbedSaved: number; scrubbedHistory: number } { + let scrubbedSaved = 0; + let scrubbedHistory = 0; + + if (typeof window === 'undefined' || !window.localStorage) { + return { scrubbedSaved, scrubbedHistory }; + } + + // 1. Scrub saved requests + try { + const savedRaw = localStorage.getItem(STORAGE_KEY_SAVED); + if (savedRaw) { + const parsed: SavedRequest[] = JSON.parse(savedRaw); + if (Array.isArray(parsed)) { + let modified = false; + const sanitized = parsed.map((item) => { + const hasTokens = Boolean(item.bearerToken || item.basicUser || item.basicPass); + const hasAuthHeaders = (item.headers || []).some(h => isSensitiveHeader(h.key)); + if (hasTokens || hasAuthHeaders) { + modified = true; + scrubbedSaved++; + return sanitizeSavedRequest(item); + } + return item; + }); + + if (modified) { + localStorage.setItem(STORAGE_KEY_SAVED, JSON.stringify(sanitized)); + } + } + } + } catch { + // Ignore parse errors + } + + // 2. Scrub request history + try { + const historyRaw = localStorage.getItem(STORAGE_KEY_HISTORY); + if (historyRaw) { + const parsed: HistoryItem[] = JSON.parse(historyRaw); + if (Array.isArray(parsed)) { + let modified = false; + const sanitized = parsed.map((item) => { + const hasTokens = Boolean(item.bearerToken || item.basicUser || item.basicPass); + const hasAuthHeaders = (item.headers || []).some(h => isSensitiveHeader(h.key)); + if (hasTokens || hasAuthHeaders) { + modified = true; + scrubbedHistory++; + return sanitizeHistoryItem(item); + } + return item; + }); + + if (modified) { + localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(sanitized)); + } + } + } + } catch { + // Ignore parse errors + } + + return { scrubbedSaved, scrubbedHistory }; +}