# Security Vulnerability Fix Report

**Date:** 2026-05-22
**Scope:** API (`apps/api`) — automated scan + manual review
**Branch:** `develop`

---

## Summary

A full security review of the API codebase identified 3 confirmed, high-confidence vulnerabilities. All 3 were fixed in the same session. A separate frontend scan (dashboard, admin, marketplace, public-site) found no confirmed vulnerabilities above the reporting threshold.

| # | Severity | Category | Status |
|---|----------|----------|--------|
| [VF-01](#vf-01--payment-credential-exposure) | High | Credential / Data Exposure | Fixed |
| [VF-02](#vf-02--webhook-signature-bypass) | High | Authentication Bypass | Fixed |
| [VF-03](#vf-03--idor-on-vehicle-maintenance-logs) | Medium-High | IDOR / Tenant Isolation | Fixed |

---

## VF-01 — Payment Credential Exposure

**Severity:** High
**Confidence:** 9/10
**Category:** Credential / Data Exposure

### Description

`GET /api/v1/companies/me/brand` returned the full `BrandSettings` database row, including the live payment gateway credentials `amanpaySecretKey`, `amanpayMerchantId`, `paypalEmail`, and `paypalMerchantId` in the JSON response. The endpoint had no role guard, so any authenticated employee — including the lowest-privilege `AGENT` role — could call it and receive the secret key in plaintext.

### Exploit Scenario

An `AGENT`-role employee calls `GET /api/v1/companies/me/brand` with a valid session token. The response body includes `amanpaySecretKey`. The attacker uses the merchant ID and secret key to interact directly with the AmanPay payment gateway on behalf of the company, initiating fraudulent charges or refunds outside the application.

### Root Cause

`presentBrand()` in `company.presenter.ts` was a transparent passthrough (`return brand`). The DB query had no `select` clause, so Prisma returned every column. The public-facing site module correctly stripped these fields (evidenced by explicit test assertions), but the authenticated dashboard endpoint did not.

### Fix

**File:** `apps/api/src/modules/companies/company.presenter.ts`

`presentBrand()` now destructures and drops all four credential fields before returning. The caller receives boolean presence indicators (`amanpayConfigured`, `paypalConfigured`) instead of the raw secrets.

```diff
 export function presentBrand(brand: any) {
-  return brand
+  if (!brand) return brand
+  const { amanpaySecretKey, amanpayMerchantId, paypalEmail, paypalMerchantId, ...safe } = brand
+  return {
+    ...safe,
+    amanpayConfigured: !!(amanpayMerchantId && amanpaySecretKey),
+    paypalConfigured:  !!(paypalEmail || paypalMerchantId),
+  }
 }
```

---

## VF-02 — Webhook Signature Bypass

**Severity:** High
**Confidence:** 8/10
**Category:** Authentication Bypass / Payment Fraud

### Description

Both payment and subscription webhook endpoints for AmanPay and PayPal used a short-circuit AND guard for signature verification:

```ts
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
  return res.status(401).json({ error: 'invalid_signature' })
}
await service.handleAmanpayWebhook(req.body)  // executed unconditionally
```

When `isConfigured()` returns `false` (i.e., credentials are absent or set to placeholder defaults), the entire guard short-circuits and the webhook handler executes on any unauthenticated request. The endpoints had no IP allowlist and were explicitly placed before `requireCompanyAuth`.

This affected 4 endpoints:
- `POST /api/v1/payments/webhooks/amanpay`
- `POST /api/v1/payments/webhooks/paypal`
- `POST /api/v1/subscriptions/webhooks/amanpay`
- `POST /api/v1/subscriptions/webhooks/paypal`

### Exploit Scenario

On any staging or misconfigured production environment where AmanPay credentials are not set, an attacker posts `{"transaction_id": "<known_id>", "status": "PAID"}` to `/api/v1/payments/webhooks/amanpay`. The handler finds the pending payment by `transactionId`, marks it succeeded, and unlocks the reservation — with no money changing hands. A valid `amanpayTransactionId` can leak through receipts, logs, or API responses visible to renters.

The same attack against `/api/v1/subscriptions/webhooks/amanpay` activates a paid subscription tier for free.

### Fix

**Files:** `apps/api/src/modules/payments/payment.routes.ts`, `apps/api/src/modules/subscriptions/subscription.routes.ts`

Inverted the guard logic: an unconfigured provider now immediately returns `401` instead of passing through. Applied to all 4 endpoints.

```diff
-if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
+if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) {
   return res.status(401).json({ error: 'invalid_signature' })
 }
```

```diff
-if (paypal.isConfigured() && !isValid) return res.status(401).json({ error: 'invalid_signature' })
+if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' })
```

---

## VF-03 — IDOR on Vehicle Maintenance Logs

**Severity:** Medium-High
**Confidence:** 9/10
**Category:** Insecure Direct Object Reference (IDOR) / Tenant Isolation Bypass

### Description

`GET /api/v1/vehicles/:id/maintenance` was protected by `requireCompanyAuth` and `requireTenant`, making `req.companyId` available. However, `req.companyId` was never forwarded to the service or repository. The database query used only `vehicleId` with no tenant filter:

```ts
// vehicle.repo.ts
prisma.maintenanceLog.findMany({ where: { vehicleId } })
```

An authenticated employee from Company A could fetch the full maintenance history of any vehicle belonging to any other company by knowing its UUID. The write path (`POST /:id/maintenance`) already performed the correct ownership check using `repo.findById(id, companyId)` — the read path did not.

### Exploit Scenario

An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/maintenance`. Vehicle UUIDs can leak through marketplace listings, shared bookings, or support interactions. The response contains the complete maintenance history of Company B's vehicle — including service records, mileage data, and operational schedules — across tenant boundaries.

### Fix

**Files:** `apps/api/src/modules/vehicles/vehicle.routes.ts`, `apps/api/src/modules/vehicles/vehicle.service.ts`

`req.companyId` is now threaded through to `getMaintenanceLogs`, which performs the same ownership pre-check already used by the write path. A cross-tenant request returns `404`.

```diff
 // vehicle.routes.ts
-const logs = await service.getMaintenanceLogs(id)
+const logs = await service.getMaintenanceLogs(id, req.companyId)
```

```diff
 // vehicle.service.ts
-export async function getMaintenanceLogs(id: string) {
-  return repo.findMaintenanceLogs(id)
+export async function getMaintenanceLogs(id: string, companyId: string) {
+  const vehicle = await repo.findById(id, companyId)
+  if (!vehicle) throw new NotFoundError('Vehicle not found')
+  return repo.findMaintenanceLogs(id)
 }
```

---

## Frontend Scan Results

A full scan of all four frontend applications (dashboard, admin, marketplace, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed.

### Hardening Recommendations (not vulnerabilities)

Two items were identified as best-practice improvements:

1. **Admin auth-redirect `next` param** (`apps/admin/src/app/auth-redirect/page.tsx`) — Validate the `next` query parameter is a relative path (starts with `/`, does not contain `://`) before passing it to `router.replace`. The router itself does not perform cross-origin navigation, but defense-in-depth is recommended.

2. **`postMessage` wildcard origin + missing `frame-ancestors` CSP** (`apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`, `apps/dashboard/next.config.js`) — Replace `targetOrigin: '*'` with the marketplace origin, and add `Content-Security-Policy: frame-ancestors 'self' <marketplace-origin>` to prevent the sign-in page from being embeddable by arbitrary third-party domains.

---

## Files Changed

| File | Change |
|------|--------|
| `apps/api/src/modules/companies/company.presenter.ts` | Strip payment credentials in `presentBrand()` |
| `apps/api/src/modules/payments/payment.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) |
| `apps/api/src/modules/subscriptions/subscription.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) |
| `apps/api/src/modules/vehicles/vehicle.routes.ts` | Pass `companyId` to maintenance log service |
| `apps/api/src/modules/vehicles/vehicle.service.ts` | Add ownership check in `getMaintenanceLogs` |
