Addresses 11 original findings (1 critical, 3 high, 4 medium, 3 low) plus 4 additional findings from security review: - Mask secrets in PUT /settings response (was leaking decrypted keys) - Add paymentCheckoutRateLimit (10/hr/IP) to all 5 checkout endpoints - Implement durable audit logging to payment_audit_log table - Pin Stripe API version to 2026-01-28.clover (SDK v20.3.1) - Add charge.dispute.created/closed webhook handlers with DISPUTED status - Restore tickets on dispute won, handle charge_refunded closure - Guard against sentinel passthrough corrupting stored Stripe keys - Wrap refund DB updates in try/catch with webhook reconciliation fallback - Add $transaction for product maxPurchases race condition - Remove dead Payment model lookup from handleChargeRefunded - Cap donation amount at $100k in both schemas - Add requirePaymentsEnabled middleware on all checkout routes - Remove Stripe internal IDs from CSV exports - Add Cache-Control: no-store on admin settings responses Bunker Admin
93 lines
3.1 KiB
TypeScript
93 lines
3.1 KiB
TypeScript
import { Request, Response, NextFunction } from 'express';
|
|
import { prisma } from '../../config/database';
|
|
import type { PaymentSettings } from '@prisma/client';
|
|
import type { UpdatePaymentSettingsInput } from './payments.schemas';
|
|
import { encrypt, decrypt } from '../../utils/crypto';
|
|
import { resetStripeClient } from '../../services/stripe.client';
|
|
|
|
const ENCRYPTED_FIELDS = ['stripeSecretKey', 'stripeWebhookSecret'] as const;
|
|
const SENSITIVE_FIELDS = ['stripeSecretKey', 'stripeWebhookSecret'] as const;
|
|
|
|
function decryptSettings(settings: PaymentSettings): PaymentSettings {
|
|
for (const field of ENCRYPTED_FIELDS) {
|
|
const value = settings[field];
|
|
if (typeof value === 'string' && value) {
|
|
(settings as Record<string, unknown>)[field] = decrypt(value);
|
|
}
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
export const paymentSettingsService = {
|
|
/** Full settings with decrypted secrets (admin use) */
|
|
async get(): Promise<PaymentSettings> {
|
|
let settings = await prisma.paymentSettings.findFirst();
|
|
if (!settings) {
|
|
settings = await prisma.paymentSettings.create({ data: {} });
|
|
}
|
|
return decryptSettings(settings);
|
|
},
|
|
|
|
/** Public-safe settings (strips secret keys) */
|
|
async getPublic() {
|
|
const settings = await this.get();
|
|
const result = { ...settings } as Record<string, unknown>;
|
|
for (const field of SENSITIVE_FIELDS) {
|
|
delete result[field];
|
|
}
|
|
return result;
|
|
},
|
|
|
|
async update(data: UpdatePaymentSettingsInput): Promise<PaymentSettings> {
|
|
const toWrite = { ...data } as Record<string, unknown>;
|
|
|
|
// Encrypt sensitive fields, skipping masked sentinel values from the admin UI
|
|
for (const field of ENCRYPTED_FIELDS) {
|
|
if (field in toWrite && typeof toWrite[field] === 'string') {
|
|
const val = toWrite[field] as string;
|
|
if (!val || val.startsWith('••••')) {
|
|
// Empty or mask string submitted — preserve existing encrypted value
|
|
delete toWrite[field];
|
|
continue;
|
|
}
|
|
toWrite[field] = encrypt(val);
|
|
}
|
|
}
|
|
|
|
// Handle donationSuggestedAmounts as JSON
|
|
if (data.donationSuggestedAmounts) {
|
|
toWrite.donationSuggestedAmounts = JSON.stringify(data.donationSuggestedAmounts);
|
|
}
|
|
|
|
const existing = await prisma.paymentSettings.findFirst();
|
|
let settings: PaymentSettings;
|
|
if (existing) {
|
|
settings = await prisma.paymentSettings.update({
|
|
where: { id: existing.id },
|
|
data: toWrite,
|
|
});
|
|
} else {
|
|
settings = await prisma.paymentSettings.create({ data: toWrite });
|
|
}
|
|
|
|
// Reset Stripe client so it picks up new keys
|
|
resetStripeClient();
|
|
|
|
return decryptSettings(settings);
|
|
},
|
|
};
|
|
|
|
/** Middleware: reject requests when payments are disabled in site settings */
|
|
export async function requirePaymentsEnabled(_req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const settings = await prisma.siteSettings.findFirst({ select: { enablePayments: true } });
|
|
if (!settings?.enablePayments) {
|
|
res.status(403).json({ error: { message: 'Payments are not enabled', code: 'PAYMENTS_DISABLED' } });
|
|
return;
|
|
}
|
|
next();
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|