Skip to content
Merged
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
1 change: 0 additions & 1 deletion public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"storage",
"unlimitedStorage",
"cookies",
"activeTab",
"tabs",
"alarms",
"offscreen"
Expand Down
31 changes: 30 additions & 1 deletion src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,14 @@ messageBus.on('SF_UPDATE_RECORD', async (message, sender): Promise<MessageRespon
});

messageBus.on('SF_API_REQUEST', async (message, sender): Promise<MessageResponse> => {
// 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';
Expand All @@ -483,6 +491,14 @@ messageBus.on('SF_API_REQUEST', async (message, sender): Promise<MessageResponse
});

messageBus.on('SF_EXECUTE_ANONYMOUS', async (message, sender): Promise<MessageResponse> => {
// 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);
Expand Down Expand Up @@ -902,6 +918,14 @@ messageBus.on('DATA_EXPORT', async (message): Promise<MessageResponse> => {
});

messageBus.on('DATA_IMPORT', async (message): Promise<MessageResponse> => {
// 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}` },
Comment on lines +922 to +925

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep backup restore usable from the in-page panel

The in-page PanelRoot constructs SfApi('content') and renders SettingsScreen, whose Import Data action calls importUserData. Consequently every backup restore initiated from that panel now returns UNAUTHORIZED_SOURCE, although the action remains visible and previously worked. Either authorize this operation using the sender's verified extension context or remove/route the panel action to an authorized surface.

Useful? React with 👍 / 👎.

requestId: message.requestId,
};
}
try {
const payload = message.payload as Record<string, unknown>;
const result = await storage.importUserData(payload);
Expand Down Expand Up @@ -984,9 +1008,14 @@ messageBus.on('OPEN_FULL_APP', async (message): Promise<MessageResponse> => {
messageBus.on('ORG_LIST', async (message): Promise<MessageResponse> => {
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,
};
});
Expand Down
48 changes: 43 additions & 5 deletions src/services/salesforce/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,35 @@ export class SalesforceApiClient {

/** Fetch next page of query results */
async queryMore<T = Record<string, unknown>>(nextRecordsUrl: string): Promise<QueryResult<T>> {
// 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<QueryResult<T>>(nextRecordsUrl, { useFullPath: true });
}

/** Fetch next page of Tooling API query results */
async toolingQueryMore<T = Record<string, unknown>>(nextRecordsUrl: string): Promise<QueryResult<T>> {
// 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<QueryResult<T>>(nextRecordsUrl, { useFullPath: true });
}

Expand Down Expand Up @@ -284,11 +308,25 @@ export class SalesforceApiClient {
body?: unknown,
opts: { rawText?: boolean } = {},
): Promise<RawCallResult> {
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,
Expand Down
6 changes: 5 additions & 1 deletion src/services/salesforce/bulk-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Comment on lines +286 to +287

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not escape values sent to the Bulk API

For Bulk imports, recordsToCsv serializes the actual payload uploaded to Salesforce, not a spreadsheet opened by a user. Prefixing these values therefore corrupts legitimate text such as +15551234, -part, or @handle; numeric values parsed from an input CSV are also strings, so a value such as -10 becomes '-10 and can fail validation for a numeric Salesforce field. Formula neutralization should remain limited to user-facing exports rather than the ingest path.

Useful? React with 👍 / 👎.

}
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
Expand Down
9 changes: 7 additions & 2 deletions src/services/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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;
Comment on lines +63 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rehydrate session tokens before returning stored orgs

When the service worker is evicted during a Bulk job, DATA_PUSH_CANCEL reads the org with storage.getOrg() and passes it directly to bulkServiceFor. This change removes accessToken from that returned object, while setSessionToken() has no corresponding read path, so the abort request is sent with Bearer undefined; the handler then swallows the failure and reports the checkpoint as cancelled even though the Salesforce job can continue processing. Stored orgs must be rehydrated from session storage, or this path must obtain a validated org before issuing the abort.

Useful? React with 👍 / 👎.

await this.setLocal(STORAGE_KEYS.ORGS, orgs);
}

Expand Down
12 changes: 11 additions & 1 deletion src/ui/utils/csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '""')}"`;
}
Expand Down
17 changes: 14 additions & 3 deletions src/ui/utils/excel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,16 @@ export async function recordsToExcel(
sheetName: string = 'Sheet1'
): Promise<void> {
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<string, unknown> = {};
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;
});
Expand Down Expand Up @@ -83,7 +88,13 @@ export async function recordsToExcelBuffer(
const filtered = records.map(record => {
const filtered: Record<string, unknown> = {};
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;
});
Expand Down
Loading