> **I'm using the writing-plans skill to create the implementation plan.**

# Security Remediation Implementation Plan

**Goal:** Fix the 2026-06-22 security audit findings that are safe to address without removing global `Model::unguard()`.

**Architecture:** Apply minimal, targeted hardening: ownership checks in controllers/service, scoping fixes in availability queries, stricter config defaults, security middleware, and safer API responses. Each change is paired with an updated or new test.

**Tech Stack:** Laravel 13, PHP 8.4, Sanctum, Pest, Composer, Git.

---

## Task 1: Unit bulk-create ownership check

**Files:**
- Modify: `app/Http/Controllers/UnitController.php:102-124`
- Test: `tests/Feature/BulkUnitTest.php`

- [ ] **Step 1: Add ownership guard to `bulkStore`**

```php
public function bulkStore(BulkUnitRequest $request, Building $building)
{
    $this->authorize('create', Unit::class);

    if ($building->owner_id !== auth()->user()->owner?->id) {
        abort(403, 'You do not own this building.');
    }

    // existing logic continues...
}
```

- [ ] **Step 2: Add regression test**

Append a test in `BulkUnitTest.php` that creates a second owner and building and asserts `403` when the first owner posts to the second building's bulk endpoint.

- [ ] **Step 3: Run targeted test**

Run: `php artisan test tests/Feature/BulkUnitTest.php`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add app/Http/Controllers/UnitController.php tests/Feature/BulkUnitTest.php
git commit -m "fix: enforce building ownership on bulk unit creation"
```

---

## Task 2: Unit listing ownership binding + regression test

**Files:**
- Modify: `app/Http/Controllers/UnitController.php:24-33`
- Test: `tests/Feature/BuildingUnitFiltersTest.php`

- [ ] **Step 1: Bind building parameter in `index`**

Change the method signature to:

```php
public function index(ListingFilterRequest $request, ?Building $building = null)
```

Replace `if ($building = request()->route('building'))` with:

```php
if ($building) {
    if ($building->owner_id !== auth()->user()->owner?->id) {
        abort(403, 'You do not own this building.');
    }
    $unit->where('building_id', $building->id);
}
```

- [ ] **Step 2: Add regression test**

In `BuildingUnitFiltersTest.php`, add a test that creates a second owner/building and asserts `403` on `GET /api/v1/buildings/{otherBuilding}/units`.

- [ ] **Step 3: Run targeted tests**

Run: `php artisan test tests/Feature/BuildingUnitFiltersTest.php tests/Feature/BulkUnitTest.php`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add app/Http/Controllers/UnitController.php tests/Feature/BuildingUnitFiltersTest.php
git commit -m "fix: bind building in unit index and enforce owner scope"
```

---

## Task 3: Reservation unit_id cross-owner guard

**Files:**
- Modify: `app/Services/ReservationService.php:210-294`
- Test: `tests/Feature/ReservationFixesTest.php`

- [ ] **Step 1: Add owner-boundary validation in `updateReservation`**

After calculating `$unitChanged`, before booking availability, add:

```php
if ($unitChanged) {
    $newUnit = Unit::with('building')->findOrFail($newUnitId);
    $originalOwnerId = $reservation->unit->building->owner_id;

    if ($newUnit->building->owner_id !== $originalOwnerId) {
        throw ValidationException::withMessages([
            'unit_id' => ['The selected unit does not belong to the same owner.'],
        ]);
    }
}
```

- [ ] **Step 2: Add regression test**

In `ReservationFixesTest.php`, create a reservation, then attempt `PUT /api/v1/customer/reservations/{id}` with `unit_id` set to a unit owned by `otherOwnerUser`. Assert `422` with `unit_id` error.

- [ ] **Step 3: Run targeted test**

Run: `php artisan test tests/Feature/ReservationFixesTest.php`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add app/Services/ReservationService.php tests/Feature/ReservationFixesTest.php
git commit -m "fix: reject reservation unit changes across owners"
```

---

## Task 4: Empty-scope availability leak

**Files:**
- Modify: `app/Http/Controllers/UnitAvailabilityController.php:45-49`
- Test: `tests/Feature/UnitAvailabilityTest.php`

- [ ] **Step 1: Always apply unit scope when it exists**

Replace the existing block with:

```php
if ($unitIds !== null) {
    $query->whereIn('unit_id', $unitIds);
}
```

- [ ] **Step 2: Add regression test**

In `UnitAvailabilityTest.php`, add a test that creates a fresh owner with no buildings and asserts the index returns an empty collection.

- [ ] **Step 3: Run targeted test**

Run: `php artisan test tests/Feature/UnitAvailabilityTest.php`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add app/Http/Controllers/UnitAvailabilityController.php tests/Feature/UnitAvailabilityTest.php
git commit -m "fix: scope availability query even when allowed unit list is empty"
```

---

## Task 5: Remove default super-admin seeder

**Files:**
- Modify: `database/seeders/DatabaseSeeder.php`

- [ ] **Step 1: Remove hard-coded super-admin creation**

Replace lines 23-29 with:

```php
// Super-admin bootstrap should be done via a secure CLI command or one-time env values.
// Do not create a default password here.
```

- [ ] **Step 2: Run full test suite**

Run: `php artisan test`
Expected: PASS

- [ ] **Step 3: Commit**

```bash
git add database/seeders/DatabaseSeeder.php
git commit -m "fix: remove default super-admin account from seeder"
```

---

## Task 6: Patch Guzzle dependencies

**Files:**
- Modify: `composer.lock` (via Composer)

- [ ] **Step 1: Update Guzzle packages**

Run: `composer update guzzlehttp/guzzle guzzlehttp/psr7 --no-interaction`
Expected: guzzle >= 7.12.1 and psr7 >= 2.12.1.

- [ ] **Step 2: Verify with composer audit**

Run: `composer audit --format=plain`
Expected: no Guzzle CVEs.

- [ ] **Step 3: Run full test suite**

Run: `php artisan test`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add composer.lock composer.json
git commit -m "fix: update guzzle to patched versions"
```

---

## Task 7: Publish and restrict CORS config

**Files:**
- Create: `config/cors.php`
- Modify: `.env.example`

- [ ] **Step 1: Publish CORS config**

Run: `php artisan config:publish cors`

- [ ] **Step 2: Replace wildcard default**

Edit `config/cors.php`:

```php
'allowed_origins' => array_filter(explode(',', env('CORS_ALLOWED_ORIGINS', 'http://localhost,http://127.0.0.1'))),
```

- [ ] **Step 3: Add env example**

Append to `.env.example`:

```dotenv
CORS_ALLOWED_ORIGINS=http://localhost
```

- [ ] **Step 4: Run full test suite**

Run: `php artisan test`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add config/cors.php .env.example
git commit -m "fix: publish cors config and default to localhost origins"
```

---

## Task 8: Add security headers middleware

**Files:**
- Create: `app/Http/Middleware/SecurityHeadersMiddleware.php`
- Modify: `bootstrap/app.php`

- [ ] **Step 1: Create middleware**

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeadersMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $response->headers->set('X-Frame-Options', 'DENY');
        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
        $response->headers->set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
        $response->headers->set('Content-Security-Policy', "default-src 'self'");

        return $response;
    }
}
```

- [ ] **Step 2: Register middleware globally**

In `bootstrap/app.php` inside `withMiddleware`:

```php
$middleware->append(\App\Http\Middleware\SecurityHeadersMiddleware::class);
```

- [ ] **Step 3: Add regression test**

Create `tests/Feature/SecurityHeadersTest.php`:

```php
<?php

it('adds security headers to api responses', function () {
    $response = $this->getJson('/api/v1/countries');
    $response->assertOk();
    $response->assertHeader('X-Frame-Options', 'DENY');
    $response->assertHeader('X-Content-Type-Options', 'nosniff');
    $response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
});
```

- [ ] **Step 4: Run targeted test**

Run: `php artisan test tests/Feature/SecurityHeadersTest.php`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Http/Middleware/SecurityHeadersMiddleware.php bootstrap/app.php tests/Feature/SecurityHeadersTest.php
git commit -m "fix: add security headers middleware"
```

---

## Task 9: Lock down debug and request-docs defaults

**Files:**
- Modify: `.env.example`
- Modify: `config/request-docs.php`

- [ ] **Step 1: Update `.env.example`**

Set:

```dotenv
APP_DEBUG=false
REQUEST_DOCS_ENABLED=false
```

- [ ] **Step 2: Enable production middleware for request docs**

Uncomment line 17 in `config/request-docs.php`:

```php
\Rakutentech\LaravelRequestDocs\NotFoundWhenProduction::class,
```

- [ ] **Step 3: Run full test suite**

Run: `php artisan test`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add .env.example config/request-docs.php
git commit -m "fix: disable debug and request-docs in example env"
```

---

## Task 10: Sanctum token expiration

**Files:**
- Modify: `config/sanctum.php`
- Test: `tests/Feature/Auth/LoginTest.php`

- [ ] **Step 1: Set expiration**

Set `config/sanctum.php`:

```php
'expiration' => env('SANCTUM_TOKEN_EXPIRATION', 60 * 24 * 7), // 1 week
```

- [ ] **Step 2: Add test**

In `LoginTest.php`, after successful login:

```php
$tokenId = explode('|', $response->json('data.token'))[0];
$token = \Laravel\Sanctum\PersonalAccessToken::find($tokenId);
expect($token->expires_at)->not->toBeNull();
```

- [ ] **Step 3: Run targeted test**

Run: `php artisan test tests/Feature/Auth/LoginTest.php`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add config/sanctum.php tests/Feature/Auth/LoginTest.php
git commit -m "fix: set sanctum token expiration"
```

---

## Task 11: Remove OTP from API responses

**Files:**
- Modify: `app/Http/Controllers/Api/AuthController.php:283-296`
- Modify: `app/Http/Controllers/ReservationController.php:136-148`
- Tests: `tests/Feature/PendingCustomerFlowTest.php`, `tests/Feature/WalletPaymentRefundTest.php`

- [ ] **Step 1: Remove OTP branch in `AuthController::otpResponse`**

```php
private function otpResponse(string $message, $data = null, ?string $otp = null, int $status = 200): JsonResponse
{
    $payload = ['message' => $message];

    if ($data !== null) {
        $payload['data'] = $data;
    }

    return response()->json($payload, $status);
}
```

- [ ] **Step 2: Remove OTP branch in `ReservationController::onArrivalResponse`**

```php
private function onArrivalResponse($resource, string $message, string $otp, string $resourceKey)
{
    return response()->json([
        $resourceKey => $resource,
        'message' => $message,
    ], 201);
}
```

- [ ] **Step 3: Update tests to read OTP from cache**

In `PendingCustomerFlowTest.php`, update `prepareCustomer()` to fetch OTP via `Cache::get(app(OtpService::class)->cacheKey($phone))`. Remove `assertJsonStructure(['otp'])` assertions.

In `WalletPaymentRefundTest.php`, update `createPendingReservation()` to fetch OTP from cache.

- [ ] **Step 4: Run targeted tests**

Run: `php artisan test tests/Feature/PendingCustomerFlowTest.php tests/Feature/WalletPaymentRefundTest.php tests/Feature/Auth/VerificationTest.php`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Http/Controllers/Api/AuthController.php app/Http/Controllers/ReservationController.php tests/Feature/PendingCustomerFlowTest.php tests/Feature/WalletPaymentRefundTest.php
git commit -m "fix: never return OTPs in API responses"
```

---

## Task 12: Generic login failure response

**Files:**
- Modify: `app/Http/Controllers/Api/AuthController.php:33-50`
- Test: `tests/Feature/Auth/LoginTest.php`

- [ ] **Step 1: Return same 401 for unverified accounts**

```php
public function login(LoginRequest $request)
{
    $field = $request->has('email') ? 'email' : 'phone';
    $value = $request->validated($field);
    $password = $request->validated('password');

    $user = User::where($field, $value)->first();

    if (! $user || ! Hash::check($password, $user->password) || ! $user->is_verified) {
        return response()->json(['message' => 'Invalid credentials.'], 401);
    }

    return $this->issueAuthToken($user);
}
```

- [ ] **Step 2: Update test**

In `LoginTest.php`, change the unverified test to assert `401` and `'Invalid credentials.'`.

- [ ] **Step 3: Run targeted test**

Run: `php artisan test tests/Feature/Auth/LoginTest.php`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add app/Http/Controllers/Api/AuthController.php tests/Feature/Auth/LoginTest.php
git commit -m "fix: return generic 401 to prevent login enumeration"
```

---

## Task 13: Remove generated docs from repository

**Files:**
- Delete (from git index): `api.json`, `routes.json`

- [ ] **Step 1: Remove from index**

Run:

```bash
git rm --cached api.json routes.json
```

- [ ] **Step 2: Verify they remain ignored**

`.gitignore` already contains `api.json` and `routes.json`.

- [ ] **Step 3: Commit**

```bash
git commit -m "fix: remove generated api docs from repository"
```

---

## Task 14: Implement password-reset email

**Files:**
- Create: `app/Mail/PasswordResetMail.php`
- Create: `resources/views/emails/password-reset.blade.php`
- Modify: `app/Http/Controllers/Api/AuthController.php:198-231`
- Test: `tests/Feature/Auth/ForgotResetPasswordTest.php`

- [ ] **Step 1: Create mailable**

```php
<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

class PasswordResetMail extends Mailable implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $token, public string $email) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Reset your Turista password',
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.password-reset',
            with: [
                'url' => config('app.url').'/reset-password?token='.urlencode($this->token).'&email='.urlencode($this->email),
            ],
        );
    }
}
```

- [ ] **Step 2: Create view**

```blade
<p>You requested a password reset for your Turista account.</p>
<p><a href="{{ $url }}">Click here to reset your password</a></p>
<p>If you did not request this, please ignore this email.</p>
```

- [ ] **Step 3: Send mail in `forgotPassword`**

```php
use Illuminate\Support\Facades\Mail;
use App\Mail\PasswordResetMail;

// inside the if ($user) block, after storing the hashed token:
Mail::to($user->email)->send(new PasswordResetMail($token, $email));
```

- [ ] **Step 4: Add test**

In `ForgotResetPasswordTest.php`, wrap the known-email test in `Mail::fake()` and assert `Mail::assertSent(PasswordResetMail::class, fn ($mail) => $mail->email === $user->email);`.

- [ ] **Step 5: Run targeted test**

Run: `php artisan test tests/Feature/Auth/ForgotResetPasswordTest.php`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add app/Mail/PasswordResetMail.php resources/views/emails/password-reset.blade.php app/Http/Controllers/Api/AuthController.php tests/Feature/Auth/ForgotResetPasswordTest.php
git commit -m "fix: send password-reset email notification"
```

---

## Task 15: Exclude password from audits

**Files:**
- Modify: `app/Models/User.php`
- Test: `tests/Unit/Models/ModelRelationsTest.php` or new test

- [ ] **Step 1: Add audit exclude**

In `User.php`, add:

```php
protected array $auditExclude = ['password'];
```

- [ ] **Step 2: Add test**

Append to `ModelRelationsTest.php` or create `tests/Unit/Models/UserAuditTest.php`:

```php
it('does not audit password changes', function () {
    $user = User::factory()->create();
    $user->update(['password' => 'new-password-123']);

    $audit = \OwenIt\Auditing\Models\Audit::where('auditable_type', User::class)
        ->where('auditable_id', $user->id)
        ->latest()
        ->first();

    expect($audit->new_values)->not->toHaveKey('password')
        ->and($audit->old_values)->not->toHaveKey('password');
});
```

- [ ] **Step 3: Run targeted test**

Run: `php artisan test tests/Unit/Models`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add app/Models/User.php tests/Unit/Models/ModelRelationsTest.php
git commit -m "fix: exclude password from model audits"
```

---

## Task 16: Replace exception messages with safe responses

**Files:**
- Modify: `app/Http/Controllers/ReservationController.php:79-83,266-270`
- Modify: `app/Http/Controllers/UnitAvailabilityController.php:74-78`
- Modify: `app/Http/Controllers/Api/PendingCustomerController.php:53-57`

- [ ] **Step 1: Use fixed messages**

ReservationController store/update catch:

```php
return response()->json(['message' => 'The selected dates are not available.'], 422);
```

UnitAvailabilityController block catch:

```php
return response()->json(['message' => 'The selected dates cannot be blocked.'], 422);
```

PendingCustomerController catch:

```php
return response()->json(['message' => 'The reservation could not be confirmed.'], 422);
```

- [ ] **Step 2: Run targeted tests**

Run: `php artisan test tests/Feature/UnitAvailabilityTest.php tests/Feature/ReservationFixesTest.php tests/Feature/PendingCustomerFlowTest.php`
Expected: PASS

- [ ] **Step 3: Commit**

```bash
git add app/Http/Controllers/ReservationController.php app/Http/Controllers/UnitAvailabilityController.php app/Http/Controllers/Api/PendingCustomerController.php
git commit -m "fix: return fixed safe error messages instead of exception text"
```

---

## Task 17: Standardize upload validation

**Files:**
- Modify: `app/Http/Requests/BuildingRequest.php`
- Modify: `app/Http/Requests/UnitRequest.php`
- Modify: `app/Http/Requests/UnitUpdateRequest.php`
- Modify: `app/Http/Controllers/BuildingController.php:139-146`
- Modify: `app/Http/Controllers/UnitController.php:226-233`

- [ ] **Step 1: Create a shared rule helper**

Add to `app/Rules/PhotoFileRules.php`:

```php
<?php

namespace App\Rules;

use Illuminate\Validation\Rule;

class PhotoFileRules
{
    public static function forSinglePhoto(string $key = 'photo'): array
    {
        return [
            $key => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240', Rule::dimensions()->maxWidth(5000)->maxHeight(5000)],
        ];
    }
}
```

- [ ] **Step 2: Apply rule to request classes and controllers**

Replace each photo rule with the same MIME list and add dimensions/max.

- [ ] **Step 3: Run targeted tests**

Run: `php artisan test tests/Feature/BuildingManagementTest.php tests/Feature/UnitUpdatePhotoTest.php tests/Feature/BulkUnitTest.php`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add app/Rules/PhotoFileRules.php app/Http/Requests/BuildingRequest.php app/Http/Requests/UnitRequest.php app/Http/Requests/UnitUpdateRequest.php app/Http/Controllers/BuildingController.php app/Http/Controllers/UnitController.php
git commit -m "fix: standardize image upload validation"
```

---

## Final Verification

- [ ] Run `php artisan test`
- [ ] Run `vendor/bin/pint --test`
- [ ] Run `composer audit --format=plain`
- [ ] Run `npm run build` (if assets changed)

Expected: all green, no Guzzle CVEs.
