# Security

The portal holds customer contact data, service addresses and commercial information for several
independent companies. The design goal is **OWASP ASVS Level 2**: every request is authenticated,
authorized and validated **on the server**, and one sub-reseller can never read or change another
sub-reseller's data, whatever the browser sends.

> Reporting a vulnerability: contact the system owner privately (do not open a public issue). Include the
> URL, the account role used and the steps to reproduce.

---

## 1. Threat model (summary)

| Threat | Primary controls |
|---|---|
| Cross-tenant access (IDOR / BOLA) — e.g. a sub-reseller changes a ULID in the URL or a Livewire payload | Global `OrganizationScope`, explicit `visibleTo()` scopes, route binding through the user's scope (**404** for foreign records), policies on every action, ownership derived on the server |
| Privilege escalation — a user grants themselves or others more rights | Permission gates, role privilege levels, role scopes, "cannot manage equal or higher level" rule, locked system roles, session destruction on role change |
| Credential attacks (stuffing, brute force, spraying) | Argon2id, per-account and per-IP throttling, generic messages, constant-time responses, optional or enforced TOTP 2FA |
| Session theft / fixation | Encrypted DB sessions, HttpOnly + SameSite + Secure cookies, regeneration at login, idle and absolute timeouts, server-side invalidation |
| Injection (SQL, XSS, CSV formula, header) | Query builder bindings, whitelisted sorting, escaped LIKE, Blade auto-escaping, nonce-based CSP, CSV cell neutralization |
| Malicious uploads | Extension allowlist, byte-level type detection, random names, private storage, forced download with `nosniff` and a sandbox CSP |
| Repudiation / insider misuse | Append-only audit log (model guards + database triggers), security log channel |
| Data loss through cascades | `RESTRICT` foreign keys on history, deactivation instead of deletion, soft deletes |

## 2. Authentication (ASVS V2)

| Control | Implementation |
|---|---|
| Password hashing | **Argon2id** (`HASH_DRIVER=argon2id`; memory 64 MB, time cost 4, 1 thread by default). Hashes are upgraded on login if the parameters change. |
| Password policy | Settings → Security: minimum length (default **12**, max 128), upper + lower case, number and symbol (all on by default), optional **Have I Been Pwned** k-anonymity check (only a 5-character SHA-1 prefix leaves the server). The same policy applies everywhere (`Password::defaults()`): reset, forced change, profile, admin-created users and temporary passwords. A new password must differ from the current one. |
| Brute force | `LoginThrottle`: after **5** failures an account is locked for **15 minutes**, whatever the source IP. Each IP is limited to 30 failures per 5 minutes to block spraying. Keys use a SHA-256 of the e-mail. Both thresholds are configurable. |
| Enumeration | One generic "These credentials do not match our records" message. Unknown e-mails still perform a dummy Argon2id check, and every attempt runs inside a `Timebox`, so response times do not reveal whether an account exists. Password-reset requests always return the same message. |
| Account state | Inactive users, users of inactive organizations and users without an organization (other than platform admins) cannot sign in. Signed-in sessions are cut on the next request (`EnsureAccountIsActive`). |
| Forced password change | Administrator-set temporary passwords set `must_change_password`. Until it is changed, every page, Livewire and JSON request is redirected to the change form. |
| Password reset | Laravel's hashed, single-use tokens that expire after **30 minutes**. Limited to 5 requests per minute per IP and 5 per hour per e-mail. A successful reset destroys all of the user's sessions. |
| Two-factor (TOTP) | RFC 6238 (SHA-1, 6 digits, 30 s, ±1 step), verified against RFC test vectors. The QR code is rendered locally as SVG, so the secret never leaves the server. The secret and **8 recovery codes** are encrypted at rest. A used time-step cannot be replayed. Each recovery code works once. Challenges are limited to 5 per minute. Settings → *Require two-factor authentication for administrators* forces enrolment for users with user, role, organization or settings management rights. |
| First administrator | `php artisan portal:create-admin` (interactive). The seeders never create production accounts. |

## 3. Session management (ASVS V3)

* `SESSION_DRIVER=database` with `SESSION_ENCRYPT=true`. Cookies are `HttpOnly` and `SameSite=Lax`, and
  `Secure` when `SESSION_SECURE_COOKIE=true` (required in production).
* The session ID is regenerated at login (prevents fixation). Logout invalidates the session and rotates the CSRF token.
* **Idle timeout** (default 60 minutes) and **absolute timeout** (default 12 hours) are enforced on the server
  for every request. Background polling (the notification badge) does not count as activity.
* All of a user's sessions are destroyed when an administrator deactivates the user, changes their role or
  organization, or sets a temporary password, and when the user resets their password. Changing their own
  password keeps only the current session.

## 4. Access control (ASVS V4)

* **Deny by default.** Every route has `auth` and a `can:<permission>` middleware. Livewire re-applies the
  route middleware on every component request, and every action calls `authorize()` on a policy.
* **Permissions, not roles**, are checked (`App\Enums\Permission`, 42 permissions). See
  [ROLES_AND_PERMISSIONS.md](ROLES_AND_PERMISSIONS.md).
* **Tenant isolation**, in three independent layers:
  1. The global `OrganizationScope` restricts every Eloquent query on customers, sellers, work orders and
     attachments to the user's accessible organizations.
  2. Explicit `visibleTo($user)` scopes add seller-row rules (a seller login sees only its own customers and
     work orders) and are used by lists, exports, reports and the dashboard.
  3. Policies check permission, organization and row ownership again for every record action.
* **Route model binding** resolves `{customer}`, `{workOrder}`, … through the user's scope. A record of another
  tenant returns **404**, so its existence is never confirmed. Access-denied events are written to the security log.
* **No trust in browser IDs.** A work order's organization always comes from the customer. Vendors and packages
  must be available to that organization. The seller, assignee and package are looked up inside the user's scope.
  Public identifiers are random **ULIDs**; numeric keys are never exposed.
* **Privilege levels:** users can only create, edit, reset or deactivate users with a **lower** role level,
  and can only assign roles below their own level whose scope fits the target organization. The Super Admin
  role is locked. System roles cannot be deleted and keep their level and scope. Level 100 is reserved. A user
  cannot change their own role, organization or status. Delegated role managers (other than platform admins) can only
  grant or revoke permissions they hold themselves, so they can't escalate by proxy. Changing a role's
  permissions signs out every user who has that role.
* **Status workflow:** each transition needs its own permission (submit, complete, cancel, reopen, change
  status). The state machine is enforced in `WorkOrderStatusService` under a row lock (`SELECT … FOR UPDATE`)
  with an expected-status check, so stale screens cannot apply a transition twice.

## 5. Validation, output encoding and injection (ASVS V5)

* All input is validated on the server: Livewire `validate()` with explicit rules, enum `Rule::in` checks,
  USPS states, ZIP / ZIP+4 and 10-digit NANP phone formats, date order, money ranges and max lengths. The UI
  hints (`required`, `maxlength`) are convenience only.
* **Mass assignment:** models declare `$fillable`. System fields (organization, status, numbers, snapshots,
  `created_by`) are only set explicitly by services (`forceFill`). Outside production,
  `preventSilentlyDiscardingAttributes` makes any attempt to set a non-fillable field fail loudly.
* **SQL:** Eloquent and the query builder use bound parameters. Raw fragments are static or bound. Sort
  columns come from a per-list **whitelist**. User LIKE wildcards are escaped (`App\Support\Search`).
* **XSS:** Blade `{{ }}` escaping everywhere. The only two raw outputs are developer-defined (an icon flag and
  the locally generated 2FA QR SVG). No user-supplied HTML is rendered. Notes are shown as plain text.
* **CSV / Excel formula injection:** every exported cell beginning with `=`, `+`, `-`, `@`, tab or CR (and not
  numeric) is prefixed with `'` (`App\Support\CsvExport::cell`).
* **Open redirects:** notification links are internal paths validated before redirecting.

## 6. CSRF

Laravel's CSRF middleware is active for the whole `web` group. The Livewire update endpoint is registered
inside the `web` group, so every component request carries and verifies the token. No route is excluded
(`ValidateCsrfToken::except` is empty, which a test verifies). `SameSite=Lax` cookies add a second layer.

## 7. Security headers and Content Security Policy (ASVS V14)

`App\Http\Middleware\SecurityHeaders` runs on every response:

```
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-…' 'unsafe-eval'; style-src 'self' 'unsafe-inline';
                         img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';
                         form-action 'self'; base-uri 'self'; object-src 'none'; worker-src 'self' blob:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() …
Cross-Origin-Opener-Policy: same-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains     (when SECURITY_HSTS=true)
```

* Scripts need a per-request **nonce**. Livewire and Alpine are bundled by Vite, so there are no inline
  scripts without a nonce and no third-party CDNs. Fonts and Chart.js are self-hosted.
* **Accepted trade-off:** `'unsafe-eval'` is needed because Alpine.js evaluates directive expressions, and
  Livewire 3 has no CSP-safe build. Its impact is limited: with no `'unsafe-inline'` scripts, an attacker
  still has to inject markup that runs, and Blade escaping blocks that. `'unsafe-inline'` for **styles** is needed
  by Livewire/Alpine transitions; style injection cannot run code.
* While the Vite dev server is running outside production, its origin is allowed automatically.

## 8. File uploads (ASVS V12)

| Step | Control |
|---|---|
| Allowlist | pdf, jpg, jpeg, png, webp, gif, doc, docx, xls, xlsx, csv, txt (`config/portal.php`) |
| Size and count | 10 MB per file, 25 files per work order; Livewire temporary uploads are throttled (20 per minute) |
| Real type | The bytes are inspected with `finfo`, and the detected type must match the extension (e.g. a PHP script renamed `.pdf` is rejected). DOCX/XLSX must also start with a ZIP signature. The browser's MIME type is ignored. |
| Storage | Private `attachments` disk under `storage/app/private` (outside the web root), path `org-ULID/YYYY/MM/<random ULID>.<ext>`. The original name is kept for display only. A SHA-256 checksum is stored. |
| Malware scanning | `App\Services\Files\FileScanner` contract (default `NullFileScanner`). Bind a ClamAV or other scanner in `AppServiceProvider` for production. |
| Download | Authorized by `WorkOrderAttachmentPolicy` (tenant + seller-row rules), rate-limited, audited and always sent as an **attachment** with `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox` and `Cache-Control: private, no-store` |
| Logo | PNG/JPG/WEBP only, decoded and **re-encoded with GD** (strips metadata and payloads), resized to at most 512 px, stored privately and served by a controller |

## 9. Rate limits

| Endpoint | Limit |
|---|---|
| Sign-in | 5 failures per account → 15-minute lock; 30 failures per IP per 5 minutes |
| Two-factor challenge | 5 per minute per pending login |
| Password-reset request / submit | 5 per minute per IP, and 5 per hour per e-mail |
| Livewire requests | 300 per minute per user |
| CSV exports and print views | 10 per minute per user |
| Attachment downloads | 60 per minute per user |
| Temporary uploads | 20 per minute |

## 10. Data protection (ASVS V6, V8)

* **Encrypted at rest** (AES-256-CBC with `APP_KEY`): 2FA secrets, recovery codes, vendor account references
  and the session payload.
* Data is limited to what operations need: no SSNs, no payment cards, no bank details. Prices are informational only.
* Exports are permission-gated, audited, rate-limited and capped at 50,000 rows (2,000 for print). There is **no**
  database backup download in the portal.
* Error pages never show stack traces, SQL or paths in production (`APP_DEBUG=false`). There are custom pages for 401, 403, 404,
  419, 422, 429, 500 and 503.

## 11. Logging and audit (ASVS V7)

* **Audit log** (`audit_logs`), append-only: the model blocks updates and deletes, and the database triggers
  `audit_logs_block_update` / `audit_logs_block_delete` reject them even for direct SQL. Each entry stores the actor,
  organization, category, event, subject, old → new values, IP, user agent and timestamp (UTC). Sub-resellers only
  see entries of their own organization.
* Audited events include sign-in success, failure, throttling and blocking; logout; password changes and resets;
  2FA enable, disable, failure and recovery-code use; session revocation; create, update, activate and deactivate of
  organizations, users, roles, vendors, services, packages, sellers and customers; role and permission changes;
  vendor-access changes; package price changes; work order create, update and every status change (submit,
  schedule, complete, cancel, reopen…); notes; attachment upload, download and delete; exports and printed reports;
  settings and logo changes.
* **Never logged:** passwords, reset tokens, session IDs, 2FA secrets or codes, API keys. `AuditLogger` replaces
  sensitive keys with `[redacted]`.
* **Security log** (`storage/logs/security-YYYY-MM-DD.log`, daily rotation, kept 90 days): throttled sign-ins
  and denied access attempts (user ID, method, path, IP). `LOG_SECURITY_STACK=security_file,syslog` also forwards
  these events to syslog or a SIEM. Test runs log to the null channel, so they never mix with real events.

## 12. Dependencies and configuration (ASVS V10, V14)

* `composer audit` and `npm audit` reported **no known vulnerabilities** at release. Run both before every deployment.
* **Laravel 12** was chosen because every Laravel 11 release is affected by unpatched advisories (including
  high-severity CVE-2026-48019).
* Few third-party packages: `livewire/livewire`, `bacon/bacon-qr-code` (local QR rendering). Front-end: Alpine
  (bundled with Livewire), Chart.js and the Inter font, all self-hosted.
* `.env` is never committed. `.env.example` contains no secrets.

## 13. Production hardening checklist

- [ ] `APP_ENV=production`, `APP_DEBUG=false`, unique `APP_KEY` stored in a vault
- [ ] HTTPS only; `SESSION_SECURE_COOKIE=true`; `SECURITY_HSTS=true` after HTTPS is confirmed
- [ ] `TRUSTED_PROXIES` set only to your load balancer addresses
- [ ] Web root is `public/`; `storage/` and `.env` are not web-accessible
- [ ] Least-privilege database user (see [INSTALLATION.md](INSTALLATION.md)); audit triggers present (`SHOW TRIGGERS LIKE 'audit_logs'`)
- [ ] Settings → Security reviewed; *Require two-factor authentication for administrators* enabled
- [ ] Demo accounts absent (the demo seeders refuse to run in production)
- [ ] Malware scanner bound to `FileScanner` if attachments come from untrusted sources
- [ ] Scheduler configured; log rotation and backups (database + private storage + `APP_KEY`) tested
- [ ] `composer audit` / `npm audit` clean

## 14. Known limitations and residual risks

| Item | Notes / mitigation |
|---|---|
| CSP allows `'unsafe-eval'` (scripts) and `'unsafe-inline'` (styles) | Needed by Livewire 3 and Alpine; partly offset by nonces, no inline scripts and output escaping. Revisit if Livewire ships a CSP-safe build. |
| No built-in antivirus | Use the `FileScanner` hook. Uploads are never executed or served inline. |
| Breached-password check is off by default | It needs outbound internet. Enable it in Settings when available. |
| 2FA is optional unless enforced | Enforce it for administrators in production. WebAuthn/passkeys are not implemented. |
| Rate limits use the database cache | Correct for a single application server. Use a shared database for multiple nodes. |
| No real-browser penetration test was performed | The automated suite covers authorization, isolation, IDOR, uploads, CSRF configuration, headers and throttling. Run an external penetration test before going live. |
