# Security

Status: **Phase 2 foundation.** Items marked *(Phase N)* are designed and
have their schema in place, but the code arrives in that phase. A full
security review is Phase 10.

The master rule: **no verified payment = no vote.**

## Secrets & configuration

- `.env` lives in the project root, **outside the web root**. It is not
  merely hidden by `.htaccess`: the document root is `public/`.
- `.env.example` contains no real values (a test enforces this).
- `.env` values are not copied into `$_ENV` / `$_SERVER`.
- Payment environment guard: boot is refused if `INTASEND_ENV=live` outside
  `APP_ENV=production`, or if the secret key's `test`/`live` marker
  contradicts `INTASEND_ENV`.
- `APP_DEBUG` is ignored in production. Visitors never see exception detail.

## Web root & file exposure

- Only `public/` is served. `public/.htaccess` routes everything to
  `index.php`, blocks dotfiles, denies execution of any PHP file other than
  `index.php`, and denies config-like extensions (`.env`, `.ini`, `.sql`,
  `.log`, `.md`, …).
- A deny-all `.htaccess` sits in the project root and in `app/`, `config/`,
  `database/`, `storage/`, `bin/`, `bootstrap/` and `resources/`. It is a
  safety net in case a host is ever misconfigured to serve the project root.
- `public/media/` (uploads): PHP/CGI handlers removed, only
  `.jpg/.jpeg/.png/.webp` served, `nosniff`, and a sandboxing CSP.
  Re-encoding through GD with random filenames *(Phase 4)*.
- `bin/*.php` refuse to run under any web SAPI (tested by requesting them
  through a web server).

## HTTP headers (every response, including error pages)

| Header | Value |
|---|---|
| Content-Security-Policy | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-src 'self' https://www.google.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'` (+ `upgrade-insecure-requests` over HTTPS) |
| X-Content-Type-Options | `nosniff` |
| X-Frame-Options | `DENY` |
| Referrer-Policy | `strict-origin-when-cross-origin` |
| Permissions-Policy | camera, microphone, geolocation, payment, usb disabled |
| Cross-Origin-Opener-Policy | `same-origin` |
| Strict-Transport-Security | only over HTTPS and only when `SECURITY_HSTS=true` |

- Scripts: only same-origin files or `<script>` tags carrying the
  per-request nonce. No inline event handlers, no `eval`, no CDNs. Fonts,
  Bootstrap, Swiper, AOS and Font Awesome are all self-hosted.
- Styles allow `'unsafe-inline'` because the supplied template uses inline
  `style=""` attributes (Phase 1 decision). This does not allow script
  execution.
- `frame-src https://www.google.com` exists only for an optional Google Maps
  embed on the Contact page; the view refuses any other embed URL.

## Sessions

- PHP file sessions in `storage/sessions/` (outside the web root), with GC
  enabled for that path.
- `use_strict_mode`, `use_only_cookies`, `HttpOnly`, `SameSite=Lax`
  (public), `Secure` automatically over HTTPS, idle timeout
  (`SESSION_LIFETIME`).
- Sessions are **lazy**: first-time visitors browsing public pages get no
  cookie. A session starts only when a form needs a CSRF token. Responses
  that used a session are sent `Cache-Control: private, no-store`.
- `Session::regenerate()` exists for login and privilege changes. The admin
  area gets its own cookie name with `SameSite=Strict` *(Phase 3)*.

## CSRF

- Synchronizer token, 256-bit random, one per session, compared with
  `hash_equals()`. Sent as the `_token` form field or the `X-CSRF-Token`
  header.
- `VerifyCsrf` runs on every POST/PUT/PATCH/DELETE in the web route group.
  The **only** exemption is `/webhooks/intasend` (config-listed and
  test-enforced). The webhook authenticates by challenge and server-side
  status verification *(Phase 8)*.
- A failure returns 419 with a friendly page and changes nothing.

## Input & output

- `App\Core\Validator` checks shape and format. Business rules live in
  services.
- Every query uses PDO prepared statements with
  `ATTR_EMULATE_PREPARES = false`. No method accepts SQL fragments built from
  input.
- Route parameters are regex-constrained (`[a-z0-9-]+` slugs, ULID payment
  references), so injection strings return 404 before any query runs.
- All output is escaped with `e()` (`htmlspecialchars`, `ENT_QUOTES |
  ENT_SUBSTITUTE | ENT_HTML5`). No user HTML is stored or rendered: plain
  text plus `nl2br`. JSON-LD uses `json_ld()`, which escapes `<`, `>`, `&`.
- Settings are validated before output: colours must be `#RRGGBB`, and links
  must be `http(s)` URLs (`javascript:` and similar are dropped).
- View names are allow-listed by regex, so request data can never choose an
  arbitrary file.
- Redirects go only to same-site paths or the configured `APP_URL` host.
  Header values containing CR/LF are rejected.

## Errors & logging

- Production: generic error page with a request reference, never SQL, paths,
  stack traces or keys (tested with a deliberately leaking exception).
- Logs are JSON lines in `storage/logs/`, each with the request ID. Keys that
  look like passwords, secrets, tokens, challenges or authorization headers
  are replaced with `[redacted]`. `Bearer …` values and `ISSecretKey_…`
  values are stripped from any string, and Kenyan phone numbers are masked
  (`2547****678`).

## Money & votes (schema-level guarantees now, services later)

See `docs/DATABASE.md` → "Rules enforced by the database itself". In short:
the amount always equals quantity × price, a payment can fund votes only
once and only for its own nominee, quantity and amount, SUCCESS requires
provider verification data, and nothing is ever hard-deleted.
`PaymentConfirmationService` (Phase 8) is the only code allowed to create
confirmed votes.

## Planned (not yet built)

| Control | Phase |
|---|---|
| Admin login, password hashing (Argon2id/bcrypt), throttling, role checks, audit log | 3 |
| Upload validation + GD re-encoding | 4 |
| Nomination form hardening (rate limits per IP/phone) | 5 |
| Vote/STK rate limits, amount calculation server-side | 7 |
| IntaSend webhook challenge, status re-verification, idempotency | 8 |
| Full security review & penetration checklist | 10 |
