Fix Intuit security compliance issues
- CSRF: store OAuth state in Secure/HttpOnly cookie, validate on callback - Cache-Control: add no-cache, no-store headers to all responses - Sensitive info: callback errors now 302 redirect instead of returning HTML - Logging: remove realmId and sanitize error logs to prevent QBO data leaks
This commit is contained in:
parent
acfe8185a9
commit
266fe833fd
2 changed files with 80 additions and 20 deletions
|
|
@ -200,7 +200,7 @@ export const handler = async (event: BedrockActionEvent): Promise<BedrockActionR
|
|||
const vendors = await queryVendors(accessToken, secret.realmId, trade, name);
|
||||
return makeResponse(event, formatVendors(vendors));
|
||||
} catch (err) {
|
||||
console.error('QBO lookup error:', err);
|
||||
console.error('QBO lookup error:', (err as Error).message);
|
||||
return makeResponse(event, `QuickBooks lookup failed: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,26 +19,39 @@ const REVOKE_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke';
|
|||
// Scopes needed for vendor queries
|
||||
const SCOPES = 'com.intuit.quickbooks.accounting';
|
||||
|
||||
// Security headers applied to every response (Intuit requires no-cache, no-store)
|
||||
const SECURITY_HEADERS: Record<string, string> = {
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
'Pragma': 'no-cache',
|
||||
};
|
||||
|
||||
interface APIGatewayEvent {
|
||||
requestContext: { http: { method: string; path: string } };
|
||||
queryStringParameters?: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
cookies?: string[];
|
||||
}
|
||||
|
||||
interface APIGatewayResponse {
|
||||
statusCode: number;
|
||||
headers?: Record<string, string>;
|
||||
cookies?: string[];
|
||||
body: string;
|
||||
}
|
||||
|
||||
function redirect(url: string): APIGatewayResponse {
|
||||
return { statusCode: 302, headers: { Location: url }, body: '' };
|
||||
function redirect(url: string, cookies?: string[]): APIGatewayResponse {
|
||||
return {
|
||||
statusCode: 302,
|
||||
headers: { ...SECURITY_HEADERS, Location: url },
|
||||
...(cookies && { cookies }),
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
function html(title: string, message: string): APIGatewayResponse {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
headers: { ...SECURITY_HEADERS, 'Content-Type': 'text/html' },
|
||||
body: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title} — Sea Haven Industries</title>
|
||||
|
|
@ -52,10 +65,30 @@ function html(title: string, message: string): APIGatewayResponse {
|
|||
};
|
||||
}
|
||||
|
||||
// ── Cookie helpers for CSRF state ────────────────────────────────────────────
|
||||
|
||||
function parseCookies(cookieHeaders: string[] | undefined): Record<string, string> {
|
||||
const cookies: Record<string, string> = {};
|
||||
if (!cookieHeaders) return cookies;
|
||||
for (const header of cookieHeaders) {
|
||||
const [name, ...rest] = header.split('=');
|
||||
if (name) cookies[name.trim()] = rest.join('=').trim();
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function makeStateCookie(state: string): string {
|
||||
// 10-minute expiry, Secure + HttpOnly per Intuit cookie requirements
|
||||
return `qbo_oauth_state=${state}; Max-Age=600; Path=/qbo; Secure; HttpOnly; SameSite=Lax`;
|
||||
}
|
||||
|
||||
function clearStateCookie(): string {
|
||||
return 'qbo_oauth_state=; Max-Age=0; Path=/qbo; Secure; HttpOnly; SameSite=Lax';
|
||||
}
|
||||
|
||||
// ── /qbo/connect — redirect to Intuit OAuth ──────────────────────────────────
|
||||
|
||||
function handleConnect(): APIGatewayResponse {
|
||||
// Generate a simple state parameter for CSRF protection
|
||||
const state = crypto.randomUUID();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
|
|
@ -66,18 +99,34 @@ function handleConnect(): APIGatewayResponse {
|
|||
state,
|
||||
});
|
||||
|
||||
return redirect(`${AUTHORIZE_URL}?${params.toString()}`);
|
||||
return redirect(
|
||||
`${AUTHORIZE_URL}?${params.toString()}`,
|
||||
[makeStateCookie(state)],
|
||||
);
|
||||
}
|
||||
|
||||
// ── /qbo/callback — exchange code for tokens, store in Secrets Manager ───────
|
||||
// Per Intuit sensitive-info requirement: this endpoint receives tokens in URL
|
||||
// params, so it must NEVER return HTML — always 302 redirect.
|
||||
|
||||
async function handleCallback(
|
||||
query: Record<string, string>,
|
||||
cookies: Record<string, string>,
|
||||
): Promise<APIGatewayResponse> {
|
||||
const { code, realmId } = query;
|
||||
const clearCookie = [clearStateCookie()];
|
||||
|
||||
// Validate CSRF state
|
||||
const { state, code, realmId } = query;
|
||||
const savedState = cookies['qbo_oauth_state'];
|
||||
|
||||
if (!state || !savedState || state !== savedState) {
|
||||
console.error('OAuth callback: state mismatch');
|
||||
return redirect('/qbo/launch?error=csrf', clearCookie);
|
||||
}
|
||||
|
||||
if (!code || !realmId) {
|
||||
return html('Connection Failed', 'Missing authorization code or company ID from Intuit. Please try connecting again.');
|
||||
console.error('OAuth callback: missing code or realmId');
|
||||
return redirect('/qbo/launch?error=missing_params', clearCookie);
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`${QBO_CLIENT_ID}:${QBO_CLIENT_SECRET}`).toString('base64');
|
||||
|
|
@ -97,9 +146,8 @@ async function handleCallback(
|
|||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const err = await tokenRes.text();
|
||||
console.error('Token exchange failed:', err);
|
||||
return html('Connection Failed', 'Could not exchange authorization code for tokens. Please try again.');
|
||||
console.error('OAuth callback: token exchange failed with status', tokenRes.status);
|
||||
return redirect('/qbo/launch?error=token_exchange', clearCookie);
|
||||
}
|
||||
|
||||
const tokens = (await tokenRes.json()) as {
|
||||
|
|
@ -122,8 +170,8 @@ async function handleCallback(
|
|||
}),
|
||||
);
|
||||
|
||||
console.log('QBO OAuth tokens stored successfully for realmId:', realmId);
|
||||
return redirect('/qbo/launch');
|
||||
console.log('QBO OAuth: tokens stored successfully');
|
||||
return redirect('/qbo/launch', clearCookie);
|
||||
}
|
||||
|
||||
// ── /qbo/disconnect — revoke token and clear secret ──────────────────────────
|
||||
|
|
@ -155,8 +203,8 @@ async function handleDisconnect(): Promise<APIGatewayResponse> {
|
|||
},
|
||||
body: JSON.stringify({ token: refreshToken }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Token revocation failed (non-fatal):', err);
|
||||
} catch {
|
||||
console.error('OAuth disconnect: token revocation failed (non-fatal)');
|
||||
}
|
||||
|
||||
// Clear the stored secret
|
||||
|
|
@ -179,9 +227,20 @@ async function handleDisconnect(): Promise<APIGatewayResponse> {
|
|||
);
|
||||
}
|
||||
|
||||
// ── /qbo/launch — success landing page ───────────────────────────────────────
|
||||
// ── /qbo/launch — success/error landing page ────────────────────────────────
|
||||
|
||||
function handleLaunch(query: Record<string, string>): APIGatewayResponse {
|
||||
const error = query['error'];
|
||||
|
||||
if (error) {
|
||||
const messages: Record<string, string> = {
|
||||
csrf: 'The connection request could not be verified. Please try again.',
|
||||
missing_params: 'Missing authorization details from Intuit. Please try connecting again.',
|
||||
token_exchange: 'Could not complete the connection to QuickBooks. Please try again.',
|
||||
};
|
||||
return html('Connection Failed', messages[error] ?? 'An unexpected error occurred. Please try again.');
|
||||
}
|
||||
|
||||
function handleLaunch(): APIGatewayResponse {
|
||||
return html(
|
||||
'Connected',
|
||||
'Your QuickBooks account is connected to Sea Haven Industries. You can close this window.',
|
||||
|
|
@ -193,17 +252,18 @@ function handleLaunch(): APIGatewayResponse {
|
|||
export const handler = async (event: APIGatewayEvent): Promise<APIGatewayResponse> => {
|
||||
const path = event.requestContext.http.path;
|
||||
const query = event.queryStringParameters ?? {};
|
||||
const cookies = parseCookies(event.cookies);
|
||||
|
||||
switch (path) {
|
||||
case '/qbo/connect':
|
||||
return handleConnect();
|
||||
case '/qbo/callback':
|
||||
return handleCallback(query);
|
||||
return handleCallback(query, cookies);
|
||||
case '/qbo/disconnect':
|
||||
return handleDisconnect();
|
||||
case '/qbo/launch':
|
||||
return handleLaunch();
|
||||
return handleLaunch(query);
|
||||
default:
|
||||
return { statusCode: 404, body: 'Not found' };
|
||||
return { statusCode: 404, headers: SECURITY_HEADERS, body: 'Not found' };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Reference in a new issue