diff --git a/public/manifest.json b/public/manifest.json index f0f6b61..5a81350 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -8,7 +8,6 @@ "storage", "unlimitedStorage", "cookies", - "activeTab", "tabs", "alarms", "offscreen" diff --git a/src/background/index.ts b/src/background/index.ts index 255204b..879c9b5 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -462,6 +462,14 @@ messageBus.on('SF_UPDATE_RECORD', async (message, sender): Promise => { + // Security (#68): restrict privileged handler to trusted extension contexts. + if (message.source !== 'app' && message.source !== 'popup') { + return { + success: false, + error: { code: 'UNAUTHORIZED_SOURCE', message: `SF_API_REQUEST not allowed from source: ${message.source}` }, + requestId: message.requestId, + }; + } try { const { method, path, body, rawText } = message.payload as { method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; @@ -483,6 +491,14 @@ messageBus.on('SF_API_REQUEST', async (message, sender): Promise => { + // Security (#68): restrict privileged handler to trusted extension contexts. + if (message.source !== 'app' && message.source !== 'popup') { + return { + success: false, + error: { code: 'UNAUTHORIZED_SOURCE', message: `SF_EXECUTE_ANONYMOUS not allowed from source: ${message.source}` }, + requestId: message.requestId, + }; + } try { const { apexBody } = message.payload as { apexBody: string }; const org = await resolveSfOrg(message.payload, sender); @@ -902,6 +918,14 @@ messageBus.on('DATA_EXPORT', async (message): Promise => { }); messageBus.on('DATA_IMPORT', async (message): Promise => { + // Security (#68): restrict privileged handler to trusted extension contexts. + if (message.source !== 'app' && message.source !== 'popup') { + return { + success: false, + error: { code: 'UNAUTHORIZED_SOURCE', message: `DATA_IMPORT not allowed from source: ${message.source}` }, + requestId: message.requestId, + }; + } try { const payload = message.payload as Record; const result = await storage.importUserData(payload); @@ -984,9 +1008,14 @@ messageBus.on('OPEN_FULL_APP', async (message): Promise => { messageBus.on('ORG_LIST', async (message): Promise => { const orgs = await storage.getOrgs(); const activeOrgId = await storage.getActiveOrgId(); + // Security (#67): strip tokens before sending to UI surfaces. + // Tokens never need to leave the background worker. + const safeOrgs = Object.values(orgs).map(({ orgId, username, instanceUrl, displayName, environment, nickname, id, connectedAt, lastUsedAt, apiVersion }) => ({ + orgId, username, instanceUrl, displayName, environment, nickname, id, connectedAt, lastUsedAt, apiVersion, + })); return { success: true, - data: { orgs: Object.values(orgs), activeOrgId }, + data: { orgs: safeOrgs, activeOrgId }, requestId: message.requestId, }; }); diff --git a/src/services/salesforce/api-client.ts b/src/services/salesforce/api-client.ts index 9557d76..712db6f 100644 --- a/src/services/salesforce/api-client.ts +++ b/src/services/salesforce/api-client.ts @@ -134,11 +134,35 @@ export class SalesforceApiClient { /** Fetch next page of query results */ async queryMore>(nextRecordsUrl: string): Promise> { + // Security (#65): validate nextRecordsUrl is a relative Salesforce path. + if (/^https?:\/\//i.test(nextRecordsUrl)) { + const allowedOrigin = new URL(this.config.instanceUrl).origin; + const requestedOrigin = new URL(nextRecordsUrl).origin; + if (requestedOrigin !== allowedOrigin) { + throw new SalesforceApiError( + `queryMore blocked: nextRecordsUrl origin (${requestedOrigin}) does not match org instanceUrl (${allowedOrigin})`, + 403, + 'URL_ORIGIN_MISMATCH', + ); + } + } return this.request>(nextRecordsUrl, { useFullPath: true }); } /** Fetch next page of Tooling API query results */ async toolingQueryMore>(nextRecordsUrl: string): Promise> { + // Security (#65): validate nextRecordsUrl is a relative Salesforce path. + if (/^https?:\/\//i.test(nextRecordsUrl)) { + const allowedOrigin = new URL(this.config.instanceUrl).origin; + const requestedOrigin = new URL(nextRecordsUrl).origin; + if (requestedOrigin !== allowedOrigin) { + throw new SalesforceApiError( + `toolingQueryMore blocked: nextRecordsUrl origin (${requestedOrigin}) does not match org instanceUrl (${allowedOrigin})`, + 403, + 'URL_ORIGIN_MISMATCH', + ); + } + } return this.request>(nextRecordsUrl, { useFullPath: true }); } @@ -284,11 +308,25 @@ export class SalesforceApiClient { body?: unknown, opts: { rawText?: boolean } = {}, ): Promise { - const url = /^https?:\/\//.test(path) - ? path - : path.startsWith('/services/') - ? `${this.config.instanceUrl}${path}` - : `${this.config.instanceUrl}${API_BASE_PATH}/${this.config.apiVersion}${path.startsWith('/') ? path : `/${path}`}`; + let url: string; + if (/^https?:\/\//.test(path)) { + // Security (#65): reject absolute URLs whose origin does not match the org's instanceUrl. + // Prevents Bearer-token exfiltration to attacker-controlled domains. + const allowedOrigin = new URL(this.config.instanceUrl).origin; + const requestedOrigin = new URL(path).origin; + if (requestedOrigin !== allowedOrigin) { + throw new SalesforceApiError( + `rawCall blocked: absolute URL origin (${requestedOrigin}) does not match org instanceUrl (${allowedOrigin})`, + 403, + 'URL_ORIGIN_MISMATCH', + ); + } + url = path; + } else if (path.startsWith('/services/')) { + url = `${this.config.instanceUrl}${path}`; + } else { + url = `${this.config.instanceUrl}${API_BASE_PATH}/${this.config.apiVersion}${path.startsWith('/') ? path : `/${path}`}`; + } const response = await fetch(url, { method, diff --git a/src/services/salesforce/bulk-api.ts b/src/services/salesforce/bulk-api.ts index 7d915bc..7253b84 100644 --- a/src/services/salesforce/bulk-api.ts +++ b/src/services/salesforce/bulk-api.ts @@ -281,7 +281,11 @@ export class BulkApiService { const values = headers.map(h => { const val = record[h]; if (val === null || val === undefined) return ''; - const str = String(val); + let str = String(val); + // Security (#66): neutralize formula-injection payloads in upload CSV. + if (/^[=+\-@\t\r]/.test(str)) { + str = `'${str}`; + } if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"`; } diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 31db557..49b4f5b 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -55,10 +55,15 @@ export class StorageService { return orgs[orgId] ?? null; } - /** Save or update an org */ + /** Save or update an org (tokens stripped — stored only in session storage) */ async saveOrg(org: SalesforceOrg): Promise { const orgs = await this.getOrgs(); - orgs[org.orgId] = org; + // Security (#64): never persist tokens to local storage. + // Tokens live only in chrome.storage.session via setSessionToken(). + const { accessToken, tokenExpiresAt, ...safeOrg } = org; + void accessToken; + void tokenExpiresAt; + orgs[org.orgId] = safeOrg as SalesforceOrg; await this.setLocal(STORAGE_KEYS.ORGS, orgs); } diff --git a/src/ui/utils/csv.ts b/src/ui/utils/csv.ts index 67d7a3d..f2ece31 100644 --- a/src/ui/utils/csv.ts +++ b/src/ui/utils/csv.ts @@ -6,9 +6,19 @@ import type { FlatRecord } from './records'; +/** Neutralize formula-injection payloads (CSV injection / DDE). */ +function neutralizeFormulaInjection(s: string): string { + // Prefix with a single quote if the cell starts with a dangerous character. + // Excel/Sheets treats leading = + - @ \t \r as formula/DDE triggers. + if (/^[=+\-@\t\r]/.test(s)) { + return `'${s}`; + } + return s; +} + function escapeCsvValue(value: unknown): string { if (value === null || value === undefined) return ''; - const s = String(value); + const s = neutralizeFormulaInjection(String(value)); if (/[",\n\r]/.test(s)) { return `"${s.replace(/"/g, '""')}"`; } diff --git a/src/ui/utils/excel.ts b/src/ui/utils/excel.ts index 593f89d..46de2ea 100644 --- a/src/ui/utils/excel.ts +++ b/src/ui/utils/excel.ts @@ -25,11 +25,16 @@ export async function recordsToExcel( sheetName: string = 'Sheet1' ): Promise { const XLSX = await loadXLSX(); - // Filter records to only include specified columns + // Filter records to only include specified columns and neutralize formula injection (#66) const filtered = records.map(record => { const filtered: Record = {}; for (const col of columns) { - filtered[col] = record[col]; + const val = record[col]; + if (typeof val === 'string' && /^[=+\-@\t\r]/.test(val)) { + filtered[col] = `'${val}`; + } else { + filtered[col] = val; + } } return filtered; }); @@ -83,7 +88,13 @@ export async function recordsToExcelBuffer( const filtered = records.map(record => { const filtered: Record = {}; for (const col of columns) { - filtered[col] = record[col]; + const val = record[col]; + // Security (#66): neutralize formula-injection payloads in Excel export. + if (typeof val === 'string' && /^[=+\-@\t\r]/.test(val)) { + filtered[col] = `'${val}`; + } else { + filtered[col] = val; + } } return filtered; });