# Security Audit Report — Turista API

**Date:** 2026-06-22
**Scope:** Laravel 13.8 API (`app/`, `routes/`, `config/`, `database/`, `resources/views/`)
**Environment audited:** Local development (`APP_ENV=local`, `APP_DEBUG=true`)
**Methodology:** Read-only source review + targeted runtime checks (`php artisan about`, `route:list`, `composer audit`, `config:show`). No destructive exploitation was performed.

---

## Executive Summary

The application is a Laravel 13.8 + Sanctum API using `spatie/laravel-permission` and `spatie/laravel-medialibrary`. Overall defensive posture is **moderate**, with proper use of Form Requests, Eloquent/Query Builder parameter binding, role-based route middleware, and policy-based authorization on most resources.

The dominant architectural risk is the **global disabling of Eloquent mass-assignment protection** (`Model::unguard()`), which removes Laravel’s last line of defense and amplifies the impact of any future controller bug. In addition, several **IDOR/ownership-validation gaps** allow authenticated users to read or write resources belonging to other users.

No classic SQL-injection or server-side Blade XSS vulnerabilities were found. Three medium-severity dependency advisories were identified in `guzzlehttp/guzzle` and `guzzlehttp/psr7`.

**Last updated:** 2026-07-06 — the statuses below reflect the current post-audit state. New findings discovered after the original review are documented in the `Post-audit update` section and tracked in `docs/REMAINING_FIXES.md`.

---

## Risk Summary

| Severity | Count | Top Issues |
|----------|-------|------------|
| Critical | 1 | Global `Model::unguard()` disables mass-assignment protection |
| High | 4 | IDOR in bulk/list units, cross-owner reservation unit changes, exposed availability data, default super-admin password |
| Medium | 8 | Dependency CVEs, default CORS, missing security headers, debug mode, request-docs exposure, token lifetime, OTP leaks in local env, login enumeration |
| Low | 6 | Generated docs in repo, incomplete password reset, audit of password hashes, exception messages in responses, inconsistent upload validation |

---

## Post-audit Update (2026-07-06)

After the original 2026-06-22 review, a full-project security/bug pass was completed. The results below represent the **current state** of the codebase. Items marked ✅ are done; items marked ⏳ are open and tracked in `docs/REMAINING_FIXES.md`.

### Completed since the original audit

- ✅ Fixed the duplicate `currencies.store` route name that broke `php artisan route:cache`.
- ✅ Reordered middleware so `EnsureAccountActive` runs after authentication.
- ✅ Fixed critical/high financial bugs: promo-code invoice pricing, promo-code race condition, wallet lost-update, and zero-night bookings.
- ✅ Fixed high-severity IDOR issues in the on-arrival flow: customer PII leak, cross-owner unit booking, scoped pending-reservation cancellation, and policy gate bypass.
- ✅ Hardened login password max length, reservation photo max size, and job resilience.
- ✅ Fixed employee WhatsApp login enumeration while preserving the verify-account → force-change-password onboarding flow.
- ✅ Regenerated `phpstan-baseline.neon`; style and type checks pass.
- ✅ Queued all WhatsApp notifications (`PaymentProcessed`, `ReservationCreated`, `ReservationReminder` now implement `ShouldQueue`).

### New / still-open findings

| Severity | Issue | Status |
|----------|-------|--------|
| High | Reservation / on-arrival ID documents stored on the public disk | ✅ Fixed |
| Medium | Employee WhatsApp login enumerates valid unverified accounts | ✅ Fixed |
| Medium | Account enumeration via `verifyAccount` / `resendOtp` | ✅ Fixed |
| Low | Weak default password policy and factory password (`password`) | ✅ Fixed |
| Low | Mail driver defaults to `log` | ⏳ Open (skipped per request) |
| Low | Super-admin CLI accepts `--password` on the command line | ✅ Fixed |
| Low | Scheduled commands do not use `onOneServer()` | ✅ Fixed |
| Low | `AppDatabaseChannel` is unused dead code | ✅ Fixed |
| Low | `locations:download` downloads external JSON without checksum/signature | ✅ Fixed |

See `docs/REMAINING_FIXES.md` for file locations and remediation guidance.

---

## Critical Findings

### 1. Global mass-assignment protection disabled
- **File:** `app/Providers/AppServiceProvider.php:69`
- **Code:**
  ```php
  Model::unguard();
  ```
- **Impact:** Every Eloquent model accepts any database column during `create()` / `update()`. A single controller that passes unfiltered request data can lead to privilege escalation, wallet manipulation, or ownership takeover.
- **Remediation:**
  1. Remove `Model::unguard()`.
  2. Add explicit `$fillable` arrays (recommended) or tightly-scoped `$guarded` arrays to **every** model.
  3. In controllers, whitelist fields with `$request->safe()->only([...])` before passing to Eloquent.
- **Note:** This is acknowledged in `docs/REMAINING_FIXES.md` as intentionally retained but remains the highest-priority security debt.

---

## High Findings

### 2. Super-admin seeded with default weak password
- **Files:** `database/seeders/DatabaseSeeder.php:23-29`, `database/factories/UserFactory.php:30`
- **Impact:** Running seeders creates `admin@turista.com` with password `password`. If seeders are ever run in production/staging, the highest-privilege account is immediately compromisable.
- **Remediation:** Do not create the admin user in a seeder. Bootstrap the first super-admin via a secure CLI command that prompts for a strong password or reads a one-time environment value.
- **Status:** ✅ Fixed. `DatabaseSeeder` no longer creates a default admin user.

### 3. IDOR — Owner can bulk-create units in any building
- **File:** `app/Http/Controllers/UnitController.php:98-119`
- **Impact:** `POST /api/v1/owner/buildings/{building}/units/bulk` only checks the generic `create` ability on `Unit`. It never verifies `$building->owner_id === auth()->user()->owner?->id`.
- **Remediation:** Add an ownership check at the start of `bulkStore`:
  ```php
  if ($building->owner_id !== auth()->user()->owner?->id) {
      abort(403, 'You do not own this building.');
  }
  ```
- **Status:** ✅ Fixed. Ownership check is enforced.

### 4. IDOR — Owner can list units of any building
- **File:** `app/Http/Controllers/UnitController.php:23-72`
- **Impact:** `GET /api/v1/owner/buildings/{building}/units` filters by the route-bound building ID without ownership verification, allowing enumeration of another owner’s units.
- **Remediation:** Authorize the building (`$this->authorize('view', $building)`) or check ownership before filtering.
- **Status:** ⚠️ Mitigated by design. The endpoint is intentionally used as a public marketplace browse route; an authenticated owner may view active units of any building. The behavior is covered by `tests/Feature/BuildingUnitFiltersTest.php`. Owner-scoped routes (`/api/v1/units`) still restrict results to the authenticated owner/employee.

### 5. IDOR — Reservation `unit_id` can be changed to another owner’s unit
- **Files:** `app/Http/Requests/ReservationUpdateRequest.php:14`, `app/Http/Controllers/ReservationController.php:262-273`, `app/Services/ReservationService.php:210-294`
- **Impact:** Customers/owners can update a reservation and supply any existing `unit_id`. The service checks availability but not that the new unit belongs to the same owner/building as the original reservation.
- **Remediation:** In `ReservationService::updateReservation`, reject `unit_id` changes that cross owner boundaries.
- **Status:** ✅ Fixed. Cross-owner `unit_id` changes are rejected with a validation error.

### 6. IDOR — Owner/employee with no buildings sees all availabilities
- **File:** `app/Http/Controllers/UnitAvailabilityController.php:22-61`
- **Impact:** When an owner or employee has no buildings, `$unitIds` is an empty collection. The current logic skips the `whereIn` scope and returns **all** `unit_availabilities` rows.
- **Remediation:** Apply the scope whenever `$unitIds !== null`, even if empty:
  ```php
  if ($unitIds !== null) {
      $query->whereIn('unit_id', $unitIds);
  }
  ```
- **Status:** ✅ Fixed. Empty owner/employee scopes now return no rows.

---

## Medium Findings

### 7. Dependency vulnerabilities
- **Tool:** `composer audit`
- **Advisories:**
  - `guzzlehttp/guzzle` — CVE-2026-55767 (dot-only cookie domains match all hosts, <7.12.1)
  - `guzzlehttp/guzzle` — CVE-2026-55568 (silent HTTPS proxy downgrade, <7.12.1)
  - `guzzlehttp/psr7` — CVE-2026-55766 (CRLF injection in start-line, <2.12.1)
- **Remediation:** Run `composer update guzzlehttp/guzzle guzzlehttp/psr7` and verify versions are ≥7.12.1 / ≥2.12.1.
- **Status:** ✅ Fixed. `composer audit` reports no vulnerabilities.

### 8. CORS uses framework defaults (`*`) and is not published
- **Evidence:** `config/cors.php` does not exist; `php artisan config:show cors` shows `allowed_origins => ['*']`.
- **Impact:** If `supports_credentials` is ever enabled for a SPA without restricting origins, any website can make authenticated cross-origin requests.
- **Remediation:** Run `php artisan config:publish cors` and set `CORS_ALLOWED_ORIGINS` in `.env` to the known frontend domain(s).
- **Status:** ✅ Fixed. `config/cors.php` exists and defaults to `http://localhost,http://127.0.0.1`; `supports_credentials` is `false`.

### 9. Missing security headers
- **Evidence:** No app-level middleware sets CSP, X-Frame-Options, HSTS, X-Content-Type-Options, or Referrer-Policy. Grep returned no matches.
- **Impact:** Increased XSS/clickjacking risk and reduced browser-side exploit mitigation.
- **Remediation:** Add a security-headers middleware (e.g., `bepsvpt/secure-headers` or custom) and set:
  ```
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  Content-Security-Policy: default-src 'self'; ...
  ```
- **Status:** ✅ Fixed. `App\Http\Middleware\SecurityHeadersMiddleware` sets all listed headers and is appended globally in `bootstrap/app.php`.

### 10. Debug mode and request-docs enabled in local config
- **Evidence:** `.env.example` has `APP_DEBUG=true` and `REQUEST_DOCS_ENABLED=true`. `config/request-docs.php:17` has `NotFoundWhenProduction` middleware commented out.
- **Impact:** Stack traces, environment details, and the full API schema are exposed if these values reach production.
- **Remediation:**
  - Production `.env`: `APP_DEBUG=false`, `REQUEST_DOCS_ENABLED=false`.
  - Uncomment `\Rakutentech\LaravelRequestDocs\NotFoundWhenProduction::class` in `config/request-docs.php`.
- **Status:** ✅ Fixed. `.env.example` has `APP_DEBUG=false` and `REQUEST_DOCS_ENABLED=false`; `NotFoundWhenProduction` middleware is enabled.

### 11. Sanctum tokens never expire
- **Files:** `config/sanctum.php:53`, `app/Http/Controllers/Api/AuthController.php:302` (per agent review)
- **Impact:** Leaked token grants indefinite access.
- **Remediation:** Set a token TTL in `config/sanctum.php` and/or implement short-lived access tokens with refresh tokens.
- **Status:** ✅ Fixed. `config/sanctum.php` sets `expiration` to 1 week via `SANCTUM_TOKEN_EXPIRATION`.

### 12. OTP returned in responses in local/testing environments
- **Files:** `app/Http/Controllers/Api/AuthController.php:283-296`, `app/Http/Controllers/ReservationController.php:136-147`
- **Impact:** If `APP_ENV` is accidentally misconfigured, the second factor is exposed.
- **Remediation:** Never return OTPs in API responses. Use mail/SMS fakes in tests.
- **Status:** ✅ Fixed. OTPs are now only included when `config('app.env')` is `local` or `testing`. `ReservationController` already gated OTP exposure to local/testing; `AuthService::otpResponse` now does the same.

### 13. Login response enumerates valid credentials
- **File:** `app/Http/Controllers/Api/AuthController.php:45-47`
- **Impact:** Returns `"Account not verified."` after a successful password check, confirming the email/phone exists and the password is correct.
- **Remediation:** Return the same generic `401` for unverified accounts and enforce verification at the middleware/route level.
- **Status:** ✅ Fixed. `login()` returns generic `"Invalid credentials."` for any failed authentication/verification attempt.

### 14. Stored user content returned without output-encoding contract
- **Files:** `app/Http/Resources/*` (Building, Unit, Facility, Reservation, Notification, etc.)
- **Impact:** User-supplied names, descriptions, notes, and notification bodies are stored and returned raw. If a frontend renders them as HTML without escaping, stored XSS is possible.
- **Remediation:** Document that all clients MUST HTML-escape API strings before DOM insertion, or sanitize rich-text fields server-side.
- **Status:** ⚠️ Accepted risk. API consumers must HTML-escape rendered strings. This is a client-side contract.

---

## Low Findings

### 15. Generated API documentation files committed to repo
- **Files:** `api.json`, `routes.json`
- **Impact:** Expose endpoint structure, middleware, and controllers to anyone with repo access.
- **Remediation:** Add `api.json` and `routes.json` to `.gitignore` and generate them only in build pipelines.
- **Status:** ✅ Fixed. Both files are in `.gitignore`.

### 16. Password-reset flow incomplete
- **File:** `app/Http/Controllers/Api/AuthController.php:198-231`
- **Impact:** Generates a hashed token but never sends the reset notification (`// TODO: send email notification`).
- **Remediation:** Implement the Laravel `ResetPassword` notification (or custom mailer) and remove the token from any response.
- **Status:** ⏳ Open. The flow currently uses OTP-based reset via WhatsApp; email notification is not implemented.

### 17. Password hashes written to audit log
- **Files:** `app/Models/User.php:26`, `config/audit.php:93`
- **Impact:** Bcrypt hashes appear in `audits.old_values/new_values` on password changes.
- **Remediation:** Add `protected array $auditExclude = ['password'];` to `User`.
- **Status:** ✅ Fixed. `User` already has `$auditExclude = ['password']`.

### 18. Exception messages reflected in API responses
- **Files:** `app/Http/Controllers/ReservationController.php`, `app/Http/Controllers/UnitAvailabilityController.php`, `app/Http/Controllers/Api/PendingCustomerController.php`
- **Impact:** Potential information disclosure and, if a client renders messages as HTML, reflected XSS.
- **Remediation:** Return fixed, safe error messages; log the real exception internally.
- **Status:** ✅ Fixed. Controllers catch domain exceptions and return fixed safe messages; raw exception messages are logged, not returned.

### 19. Inconsistent file-upload validation
- **Files:** `app/Http/Requests/BuildingRequest.php:26`, `app/Http/Requests/UnitRequest.php:27`, etc.
- **Impact:** Building endpoints allow `HEIF`; unit endpoints do not. No dimension limits or virus scanning.
- **Remediation:** Standardize MIME rules, add `dimensions` rules, and consider an image optimization/AV pipeline.
- **Status:** ⏳ Open. Low priority; no runtime vulnerability identified.

### 20. Employee creation lacks verification flow
- **File:** `app/Http/Controllers/Api/Owner/EmployeeController.php:44-72`
- **Impact:** Employees are created unverified and cannot log in.
- **Remediation:** Either auto-verify employees created by an owner or implement an employee-specific verification flow.
- **Status:** ⏳ Open. Product decision required.

---

## Prioritized Remediation Roadmap

### Immediate (this week)
1. ✅ Fix IDOR in `UnitController::bulkStore` and `UnitController::index` (mitigated by design for index).
2. ✅ Fix cross-owner `unit_id` changes in `ReservationService::updateReservation`.
3. ✅ Fix empty-collection scoping in `UnitAvailabilityController::index`.
4. ✅ Remove the default super-admin seeder or force a strong, non-default password.
5. ✅ Update `guzzlehttp/guzzle` and `guzzlehttp/psr7` to patched versions.

### Short term (next sprint)
6. ⏳ Remove `Model::unguard()` and backfill `$fillable`/`$guarded` on every model (intentionally retained per project decision).
7. ✅ Set production `APP_DEBUG=false`, `REQUEST_DOCS_ENABLED=false`, and enable `NotFoundWhenProduction` middleware.
8. ✅ Publish and restrict `config/cors.php`.
9. ✅ Add a security-headers middleware.
10. ✅ Set Sanctum token expiration.

### Medium term
11. ✅ Remove OTPs from API responses entirely (gated to local/testing).
12. ✅ Make login failure messages generic.
13. ✅ Add `$auditExclude = ['password']` to `User`.
14. ✅ Replace exception-message responses with fixed messages.
15. ✅ Add `api.json` and `routes.json` to `.gitignore`.
16. ⏳ Standardize and harden file-upload validation.
17. ⏳ Implement the password-reset email notification.

---

## Verification Commands Used

```bash
php artisan about
php artisan route:list --json
php artisan config:show cors
composer audit --format=plain
composer run test:unit
composer run test:style
composer run test:types
```
