# Security Controls Summary

Technical summary of every security control in TFB Engine, for a
developer/auditor reviewing the codebase. See
`docs/USER_ACCEPTANCE_CHECKLIST_FA.md` for the non-technical
Persian verification checklist, and `docs/LOGGING_AND_DIAGNOSTICS_FA.md`
for how security-relevant events are logged.

## 1. Webhook authentication

- Every POST to `public/index.php` must carry a valid
  `X-Telegram-Bot-Api-Secret-Token` header matching
  `config/app.php`'s `bot.webhook_secret` (a 32-byte random token
  generated at install time).
- Comparison uses `hash_equals()` (constant-time) via
  `Tfb\Support\Security::equals()` — never `===`.
- This check (`WebhookAuthMiddleware`) runs **before any other
  middleware or business logic** in the pipeline.
- A mismatch is logged (WARNING + a `ProblemsLog` entry,
  `E_WEBHOOK_SECRET_MISMATCH`) but never reveals *why* the request
  was rejected in the HTTP response (`401 {"ok":false,"error":"unauthorized"}`).

## 2. Admin authentication

- Admin status is **only** ever `config admins.ids` (a fixed array in
  `config/app.php`), read by `Tfb\Services\AdminAuthService`.
- `users.role = 'admin'` is a convenience mirror synced by
  `UserService::upsertFromTelegram()`, **never** the source of truth
  — a compromised or manually-edited DB row cannot self-promote a
  Telegram user to admin.
- Every admin-only entrypoint re-checks `AdminAuthService::isAdmin()`
  independently and fresh on every request:
  - `/admin` command → `AdminMenuController::openMenu()`
  - every `adm:` callback → `AdminCallbackDispatcher::handle()`
  - every admin-state text/media message →
    `AdminMessageDispatcher::handle()` (also self-heals: if a
    non-admin is somehow found sitting in an `admin.*` state, it is
    reset to idle immediately rather than acted upon)
- Unauthorized attempts are logged (`AdminCallbackDispatcher` →
  `Diagnostic::warning('security', 'Unauthorized admin callback attempt', ...)`).

## 3. Callback data is never trusted as an authorization token (anti-IDOR)

- Student callbacks (`opt:`/`test:` namespaces, `CallbackCodec`) and
  admin callbacks (`adm:` namespace, `AdminCallbackCodec`) are both
  shape/length-validated only.
- Every student option-answer callback is re-validated against the
  caller's own live `in_progress` session
  (`QuizFlowHandler::handleOptionAnswer()`) — a callback referencing
  a foreign session, wrong test, or an already-answered/expired
  session is silently rejected (`rejectStaleCallback()`), logged at
  WARNING via `Diagnostic`.
- Every admin controller method re-fetches its referenced entity
  fresh from the database rather than trusting the callback's
  numeric ids to imply the entity exists or belongs to the stated
  parent (e.g. `AdminOptionController::showView()` checks
  `$option->questionId !== $questionId`).

## 4. Idempotency / replay protection

- `processed_callbacks` (`ProcessedCallbackRepository::tryClaim()`)
  guarantees a Telegram-redelivered `callback_query` cannot
  double-advance a student's session or double-record an answer —
  enforced via a UNIQUE constraint + catching the resulting
  integrity violation, not a racy SELECT-then-INSERT.

## 5. Rate limiting

- Fixed-window rate limiting keyed by Telegram user id (falls back
  to remote IP for malformed updates), `RateLimitMiddleware` +
  `rate_limits` table.
- Separate, higher ceiling for admins (`security.rate_limit.admin_limit`)
  so operating the bot never gets throttled.
- A "please slow down" notice is sent at most once per window
  (the first time the limit is crossed), and rate-limit trips are
  logged once (not per-hit, to avoid log flooding).
- The installer has its own separate, file-based rate limiter
  (`Tfb\Security\InstallRateLimiter`) since no DB/rate_limits table
  exists yet during installation.

## 6. Ban / maintenance gating

- `users.is_banned` is checked (`BanCheckMiddleware`) after user
  resolution, before any business controller runs.
- Maintenance mode (`app.maintenance` config OR `settings.maintenance_enabled`
  DB setting, editable from the admin panel) blocks all non-admin
  traffic (`MaintenanceMiddleware`) with zero DB work for the
  blocked request.

## 7. Input validation

- All installer form input is validated server-side
  (`Tfb\Support\Validator`, `Tfb\Install\Installer::validateBotAndChannels/validateDatabase/validateAppSettings()`)
  — never trusts client-side validation alone.
- All admin-typed free text (titles, question/option text, settings
  values, campaign codes) is length-clamped
  (`Tfb\Support\DomainValidator::clampLength()`) and, where
  structurally meaningful, format-validated (slugs, codes, CTA URLs
  requiring `https://`, `log_level` restricted to a fixed enum).
- Backup restore files are validated (`BackupService::validateBackupFile()`)
  before a single SQL statement executes: must exist, be non-empty,
  under a size cap, and start with the project's own
  `-- TFB_ENGINE_BACKUP_V1` marker line — a random `.sql` file
  someone uploads cannot be silently executed against the database.

## 8. XSS / HTML injection (Telegram `parse_mode=HTML`)

- `Tfb\Bot\MessageBuilder::escapeHtml()` escapes `&`, `<`, `>` before
  any dynamic (user/admin-typed) string is interpolated into a
  Telegram HTML-parsed message.
- `AdminReplier` exposes two families of send methods:
  `reply()`/`replyWithKeyboard()` (always escape) and
  `replyHtml()`/`replyHtmlWithKeyboard()` (caller has already built
  trusted HTML and is responsible for escaping every dynamic
  fragment individually) — see `docs/CODING_GUIDE.md`'s "Admin
  Telegram screens: escaping gotcha" section.
- **Real bug found and fixed during Phase 07 hardening:**
  `AdminCampaignController::showList()` was interpolating
  `$campaign->title` (free-text, admin-typed) into an
  HTML-parsed message via `replyHtmlWithKeyboard()` without
  escaping it — an admin-entered title containing `<script>` or
  other tags would have been sent to Telegram's HTML parser
  unescaped. Fixed by escaping the title before interpolation.
  Covered by a permanent regression test in
  `tests/phase07_ops_e2e.php` ("A campaign title containing literal
  HTML tags is sent to Telegram HTML-escaped...").
- The installer's HTML templates use the global `e()` helper
  (`Tfb\Support\Str::escapeHtml()`) for every dynamic value echoed
  into a page; audited during Phase 07 — no unescaped user-controlled
  string interpolation was found (only booleans/`isset()` checks
  drive CSS classes / `checked` attributes, never raw echo).

## 9. CSRF protection (installer)

- `Tfb\Security\Csrf` — a single session-backed token reused across
  wizard steps, verified with `hash_equals()` on every state-changing
  POST (`bot`, `db`, `app`, `execute`, `retry_webhook` steps).
- Session cookie is the standard PHP session mechanism
  (`tfb_install_session`), started before any output.

## 10. Secrets handling

- `config/app.php` (bot token, webhook secret, DB password) is:
  - never committed (`.gitignore`)
  - blocked from direct web access (`config/.htaccess`:
    `Require all denied`)
  - only ever read via `Tfb\Core\Config`, dot-notation
- `Tfb\Logging\Redaction` (used by `FileLogger` and `ProblemsLog`)
  redacts, in every logged context array, any key whose name
  contains `token`/`password`/`secret`/`api_key`/`authorization`/etc.,
  **and** defensively pattern-matches any string value that *looks
  like* a Telegram bot token (`\d{6,12}:[A-Za-z0-9_-]{30,50}`) even
  under an unrelated key name, masking it to `12***:AB***wxyz`.
- SQL fingerprints logged on query failures/slow-query warnings
  (`Tfb\Core\Database`) never include bound parameter *values* —
  only the normalized SQL text shape.
- `tests/phase07_ops_e2e.php` includes an explicit assertion that
  the real bot token and webhook secret never appear in any log file
  written during a full E2E run.

## 11. Backup/restore path safety

- Backups are written only under `storage/backup/` (blocked from web
  access identically to `storage/logs/`).
- `BackupService::resolveBackupPath()` is the **only** place a
  filename is turned into a filesystem path: `basename()` is applied
  before any existence check, and the filename must match the
  `tfb-backup-*.sql` pattern this project itself generates — path
  traversal (`../../etc/passwd`) is structurally impossible, not
  just filtered.
- Restoring from a Telegram-uploaded file downloads it to a
  `sys_get_temp_dir()` path with a random suffix, validates it, then
  deletes it after use — restoring from an *existing on-server*
  backup (picked from a list, addressed by position/index, never a
  raw filename from the client) explicitly does **not** delete that
  file afterward (a real bug caught and fixed during Phase 07 E2E
  testing — see `docs/CHANGELOG.md`).
- Restore requires typing the exact Persian confirmation phrase
  ("تایید بازیابی") as a genuine second step — not merely a second
  button tap, which could be mis-clicked.

## 12. No binary storage rule

- Only Telegram `file_id` metadata is ever persisted
  (`stored_files` table) — the project never writes uploaded binary
  content to disk or the database, except the deliberate, narrow
  exception of a locally-generated `.sql` backup file (which is
  itself never binary — plain SQL text) sent once via
  `TelegramClient::sendLocalDocument()` and never re-used as
  persistent storage.

## 13. Error handling / no internal detail leakage to end users

- `public/index.php`'s top-level catch returns a generic
  `{"ok":false,"error":"internal_error"}` to Telegram; the real
  exception message/trace is only included in the JSON response
  when `app.debug` is `true` (never the production default).
- `Tfb\Logging\GlobalErrorHandler` installs a global exception/error/
  shutdown handler so an uncaught throwable anywhere in the request
  never produces a raw PHP stack trace to the caller — it is logged
  (CRITICAL + a `ProblemsLog` entry) and the webhook still responds
  `200 OK` (per Telegram's own recommendation: always ack, retry
  logic on Telegram's side is undesirable for a webhook consumer
  that already handled/logged the failure).

## 14. In-bot ZIP update system (Update Pack F09)

- Every uploaded update package is validated BEFORE extraction:
  size cap (60 MB), must be a structurally valid zip
  (`ZipArchive::CHECKCONS`), and — critically — every single entry
  path is scanned for `..` sequences or an absolute/drive-letter path;
  the presence of even ONE such entry rejects the WHOLE archive. This
  was verified with a real malicious test zip containing a
  `../../../etc/evil.php` entry (`tests/phase_update_security_test.php`).
- The package must also contain the expected top-level
  `src/`/`public/`/`bootstrap/`/`config/` directories, or it is
  rejected before ever touching disk.
- Extraction always goes to `storage/update_staging/` — never a
  web-served path (already blocked by `storage/.htaccess`) — and is
  always cleaned up afterward (success or failure).
- `UpdateService::swap()` categorically EXCLUDES `config/app.php` and
  everything under `storage/` from being overwritten, even if the
  uploaded update zip itself contains a `config/app.php` — verified
  by an automated test that builds a zip with a deliberately
  malicious `config/app.php` payload and confirms it is never applied.
- An automatic code + database backup (uploaded to the private files
  channel) is created and confirmed successful BEFORE an update or
  downgrade is allowed to proceed at all.
- Only a super admin (first id in `admins.ids`, or a `users` row with
  `is_super_admin=1`) may reach any update/downgrade screen — checked
  at the top of every relevant controller method.
- See `docs/UPDATE_ROLLBACK.md` for the full architecture and the
  honestly-stated limits of what "atomic" can mean on shared hosting.

## 15. One-time secure core-settings console (Update Pack F10)

- Changing the bot token or database credentials is never done as a
  direct in-bot action under the CURRENT secret — it always goes
  through `public/secure-setup.php`, reached only via a link an admin
  explicitly generates from "🔐 هسته/امنیت".
- The link token is single-use, enforced ATOMICALLY at the database
  layer (`SecureSetupTokenRepository::markUsed()`'s `UPDATE ... WHERE
  used_at IS NULL AND expires_at > NOW()` — a race between two
  concurrent requests for the same token can only ever have one
  winner), has a 15-minute TTL, and only a SHA-256 hash of the raw
  token is ever persisted.
- The page is CSRF-protected (session-backed, same mechanism as the
  installer) and IP-rate-limited (reuses
  `Tfb\Security\InstallRateLimiter`).
- A new bot token is verified with a real `getMe` call, and new DB
  credentials are verified with a real (throwaway) PDO connection,
  BEFORE either is ever written to `config/app.php` — a failed test
  leaves the live configuration completely untouched (verified by an
  automated test: wrong DB password → config unchanged → token NOT
  consumed → a correct retry with the SAME token still works).
- Re-visiting an already-used token link returns HTTP 403 — verified
  by an automated test.
- The page never echoes back a raw secret value in its HTML (existing
  values are never pre-filled into the form; only a masked view is
  ever shown, and that only inside the Telegram bot's "🔐 هسته/امنیت"
  screen, never on the web page itself).

## 16. Installer self-cleanup (Update Pack PART4)

- `public/install.php` and the optional `config/install.defaults.php`
  prefill file are both deleted immediately after a verified
  successful install (`Tfb\Install\InstallSelfCleanup`).
- If deletion fails due to hosting file permissions, `install.php` is
  overwritten with a locked stub that always returns HTTP 403 instead
  of being left in a reusable state — and a loud warning is shown to
  the admin either way (never a silent "clean success" claim when a
  file could not actually be removed/locked).
- `AdminHealthController` includes a dedicated `installer_exposed`
  CRITICAL check that fires if `install.php` is still present and
  NOT locked after the app is already installed — catching a stuck
  installer that a permission failure left behind.
- Verified end-to-end against an isolated copy of the project (never
  the live working tree) — confirmed both files are actually absent
  from disk after a real, full HTTP wizard run
  (`tests/phase_install_defaults_test.php` covers the underlying
  filesystem logic in isolation, including the permission-denied
  stub-fallback path).

## 17. Known residual risks (honest, non-blocking)

- The `.htaccess`-based protections for `config/`, `storage/`, and
  dotfiles under `public/` **depend on the hosting Apache
  configuration allowing `.htaccess` overrides** (`AllowOverride
  All`/`FileInfo`/`Limit`). This is the standard default on cPanel
  hosting, but a misconfigured or unusually locked-down Apache vhost
  could theoretically ignore `.htaccess` entirely, exposing
  `config/app.php` directly. `docs/DEPLOY_CPANEL_FA.md` §5 now
  includes an explicit post-deploy verification step for exactly
  this (attempt to load `config/app.php` in a browser and confirm it
  is blocked). This is an environment/hosting-configuration risk,
  not a code defect — Nginx or other non-Apache setups would need an
  equivalent `location` block instead of `.htaccess`, which is
  outside this project's control since it targets Apache/cPanel
  shared hosting specifically.

- The installer's own file-based rate limiter is IP-based and can be
  bypassed by an attacker rotating IPs; this is an accepted tradeoff
  for a one-time setup wizard with no DB yet — the *real* long-term
  admin auth (Telegram user id allowlist) takes over immediately
  after install.
- `BackupService::restoreFromFile()`'s "atomicity" is best-effort:
  MySQL/MariaDB DDL statements (`DROP TABLE`/`CREATE TABLE`) trigger
  an implicit commit, so a crash mid-restore could leave the schema
  in a partially-restored state. This is documented in
  `docs/USER_ACCEPTANCE_CHECKLIST_FA.md` (test restore on staging
  first) and is a standard limitation of any MySQL-based logical
  restore, not specific to this project's implementation.
- No automated dependency/CVE scanning is configured (the project
  has zero Composer runtime dependencies by design, which
  significantly reduces this surface, but PHP/MySQL itself should
  be kept patched by the hosting provider).
- `public/secure-setup.php`'s rate limiter shares the same IP-based
  approach/tradeoff as the installer's (§ above) — an attacker
  rotating source IPs could exhaust more attempts than intended. The
  single-use/short-TTL token requirement remains the primary defense
  regardless (a rate-limit bypass alone does not grant a valid token).
- `UpdateService::swap()`'s file-by-file copy is not a true atomic
  filesystem transaction (shared hosting offers no such primitive
  reliably) — a crash mid-swap could leave the live tree with a mix
  of old and new files for whichever paths were not yet reached. The
  mitigating facts: (1) a fresh backup always exists before this can
  happen, (2) an already-working file is never deleted before its
  replacement is confirmed written, so the worst case is "some files
  didn't get the update yet", never "the app has half-deleted files".
- A code-only downgrade does not automatically revert DATABASE data
  changes made after the backup point (only the code is restored) —
  stated explicitly in `docs/UPDATE_ROLLBACK.md` so an operator does
  not assume more than the feature actually guarantees.
