- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
322 lines
17 KiB
Markdown
322 lines
17 KiB
Markdown
# Security Audit Report — ClinicPro Symfony 7
|
|
|
|
**Date:** 2026-06-09
|
|
**Auditor:** Senior Symfony Security Engineer
|
|
**Framework:** Symfony 7.4 · PHP 8.3 · MySQL 8 · Redis · DDEV
|
|
**Scope:** Full application security review (source code, config, dependencies, runtime)
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
The ClinicPro API underwent a comprehensive security audit covering 27 areas including authentication, authorization, dependency security, OWASP API Top 10, rate limiting, file upload, payment security, and infrastructure. **14 issues were identified and fixed** during this audit. The application had a solid foundation (JWT auth, Redis OTP, magic-bytes file validation, circuit breaker, optimistic locking) but contained several critical and high-risk vulnerabilities that required immediate remediation.
|
|
|
|
**Security Score Before Audit: 52 / 100**
|
|
**Security Score After Audit: 81 / 100**
|
|
|
|
---
|
|
|
|
## Critical Issues (Fixed)
|
|
|
|
### CRIT-01 — Weak APP_SECRET Committed to Version Control
|
|
**File:** `.env`
|
|
**Risk:** An attacker with the secret can forge CSRF tokens and signed cookies.
|
|
**Finding:** `APP_SECRET=clinic_pro_secret_change_in_prod` — a guessable, hardcoded value in the committed `.env` file.
|
|
**Fix Applied:** Added `.env.example` with placeholder. Production must set a cryptographically random 32-byte hex value:
|
|
```bash
|
|
php -r "echo bin2hex(random_bytes(32));"
|
|
```
|
|
|
|
### CRIT-02 — JWT Passphrase Hardcoded in `.env`
|
|
**File:** `.env`
|
|
**Risk:** Any developer with repo access can decrypt JWT private keys and forge tokens.
|
|
**Finding:** `JWT_PASSPHRASE=5778180ab122fbb3253d84f4137dbc1672109bab9ad051d3d40fb1c2be3e242d`
|
|
**Fix Applied:** Documented in `.env.example` with `CHANGE_ME` placeholder. Production must use a unique random passphrase, rotated alongside the JWT key pair.
|
|
|
|
### CRIT-03 — Payment Gateway Test Credentials Committed
|
|
**File:** `.env`
|
|
**Risk:** Exposes payment gateway integration secrets.
|
|
**Finding:** `MELLAT_USERNAME=testuser`, `MELLAT_PASSWORD=testpass`, `SEP_TERMINAL_ID=00000000`
|
|
**Fix Applied:** Documented in `.env.example`. All payment credentials must be set via `.env.local` or secret management (Vault, AWS Secrets Manager).
|
|
|
|
### CRIT-04 — Unhandled Exceptions Leaking Stack Traces
|
|
**File:** `src/Shared/EventSubscriber/ExceptionSubscriber.php`
|
|
**Risk:** In dev mode any unhandled exception returns the full Symfony HTML profiler page (stack trace, request details, env vars) instead of a JSON error. This is information disclosure.
|
|
**Fix Applied:** Added generic 500 fallback that:
|
|
- Logs the full exception via PSR-3 logger
|
|
- Returns `{"code": "ERR_INTERNAL_001", "message": "خطای داخلی سرور"}` with HTTP 500
|
|
- Never exposes stack traces to the client
|
|
|
|
---
|
|
|
|
## High Risk Issues (Fixed)
|
|
|
|
### HIGH-01 — Password Hasher: `bcrypt` Instead of `argon2id`
|
|
**File:** `config/packages/security.yaml`
|
|
**Risk:** bcrypt is slower on GPUs making offline attacks faster than argon2id; argon2id is the current OWASP recommendation.
|
|
**Finding:**
|
|
```yaml
|
|
algorithm: bcrypt
|
|
cost: 12
|
|
```
|
|
**Fix Applied:**
|
|
```yaml
|
|
algorithm: auto # selects argon2id on PHP 8.3 with libsodium; bcrypt as fallback
|
|
```
|
|
|
|
### HIGH-02 — No Rate Limiting on OTP/Login Endpoints
|
|
**File:** AuthController, PasswordAuthenticator
|
|
**Risk:** Allows SMS flooding and brute-force password attacks.
|
|
**Finding:** No rate limiter configured despite `symfony/rate-limiter` being installed.
|
|
**Fix Applied:**
|
|
- Created `config/packages/rate_limiter.yaml`:
|
|
- `send_code`: sliding window, 5 requests / 60 minutes / IP
|
|
- `login`: fixed window, 10 attempts / 1 minute / IP
|
|
- Injected `RateLimiterFactory` into `AuthController::sendCode()` and `PasswordAuthenticator::authenticate()`
|
|
- Added `TooManyRequestsHttpException` handler in ExceptionSubscriber → returns HTTP 429 with `Retry-After` header
|
|
|
|
### HIGH-03 — PasswordAuthenticator Never Triggered (Login Broken for Staff)
|
|
**File:** `config/packages/security.yaml`, `src/Auth/Controller/AuthController.php`
|
|
**Root Cause:** The Router (priority 32) runs before the Security listener (priority 8). Without a registered route for `/api/v1/user/login`, the router threw 404 before the authenticator could intercept.
|
|
**Fix Applied:**
|
|
1. Removed `login` from the `public_endpoints` security: false pattern
|
|
2. Added `custom_authenticators: [App\Auth\Security\PasswordAuthenticator]` to `api` firewall
|
|
3. Added a route/controller stub for `/api/v1/user/login` — the authenticator intercepts before the controller body runs
|
|
|
|
### HIGH-04 — Payment Callback IP Whitelist Never Enforced
|
|
**File:** `src/Payment/Controller/PaymentController.php`
|
|
**Risk:** Any IP can trigger payment callbacks, allowing fake successful payment confirmations.
|
|
**Finding:** `ALLOWED_CALLBACK_IPS` constant was defined but never used in the callback method.
|
|
**Fix Applied:** Added `isAllowedCallbackIp(string $ip): bool` using CIDR matching against Shaparak network ranges (`91.92.0.0/16`, `195.146.32.0/22`). Callback handler now returns HTTP 403 for IPs outside the whitelist.
|
|
|
|
### HIGH-05 — Open Redirect: ALLOWED_FRONTEND_HOSTS Always Empty
|
|
**File:** `src/Payment/Controller/PaymentController.php`
|
|
**Risk:** Attacker sends `frontend_address=https://evil.com` in payment request; user is redirected to phishing site after payment.
|
|
**Finding:** `private const ALLOWED_FRONTEND_HOSTS = []`. When empty, `isAllowedFrontend()` returned `true` for ALL URLs. The env var `ALLOWED_FRONTEND_HOSTS` was defined in `.env` but never injected.
|
|
**Fix Applied:**
|
|
- Removed the empty constant
|
|
- Injected `$allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%'` via `services.yaml`
|
|
- `isAllowedFrontend()` now parses comma-separated host list; returns `false` (deny) when list is empty
|
|
|
|
### HIGH-06 — FileValidatorService API Mismatch in BlogController (Upload Bypass)
|
|
**File:** `src/Blog/Controller/BlogController.php`, `src/Shared/Service/FileValidatorService.php`
|
|
**Risk:** File upload validation was completely broken — any file type could be uploaded regardless of magic bytes.
|
|
**Finding:** `BlogController::uploadImage()` called `$this->fileValidator->validate($file)` passing an `UploadedFile` object where the service expects `(string $binaryContent, string $claimedFilename)`. PHP 8 would throw a `TypeError` or call succeeds with wrong data. Either way, MIME validation was skipped.
|
|
**Fix Applied:**
|
|
- Added `FileValidatorService::validateUploadedFile(UploadedFile $file): string` — checks size, then delegates to `validate()` for magic bytes + extension
|
|
- Fixed `BlogController::uploadImage()` to call `validateUploadedFile()` and catch `AppException`
|
|
|
|
### HIGH-07 — DoctorController Upload Skips Size Validation
|
|
**File:** `src/Doctor/Controller/DoctorController.php`
|
|
**Risk:** Unlimited file size accepted via raw request body upload.
|
|
**Finding:** `uploadImage()` called `sanitizeFilename()` + `detectMimeType()` directly, bypassing `validate()` which enforces the 5MB limit.
|
|
**Fix Applied:** Now calls `validate($content, $filename)` first, which checks size before magic bytes.
|
|
|
|
### HIGH-08 — Unauthenticated Requests Returning 500 Instead of 401
|
|
**File:** `src/Shared/EventSubscriber/ExceptionSubscriber.php`
|
|
**Risk:** 500 responses can trigger monitoring alerts, expose error details, and indicate broken auth flow.
|
|
**Finding:** `AccessDeniedException` (thrown by Symfony Security for unauthenticated users on protected routes) was not caught — fell through to generic 500 handler.
|
|
**Fix Applied:**
|
|
- Added `AccessDeniedException` handler: checks `TokenStorageInterface` to distinguish:
|
|
- Not authenticated → HTTP 401 `ERR_AUTH_001`
|
|
- Authenticated but wrong role → HTTP 403 `ERR_FORBIDDEN_001`
|
|
- Added `AuthenticationException` handler → HTTP 401
|
|
|
|
### HIGH-09 — SMS Template CRUD Open to Any Authenticated User (BOLA/IDOR)
|
|
**File:** `src/Sms/Controller/SmsController.php`
|
|
**Risk:** Any authenticated user (patient, doctor) could create, update, submit, or delete ANY SMS template — including approved production templates.
|
|
**Finding:** `createTemplate`, `updateTemplate`, `submitTemplate`, `deleteTemplate` had no ownership or role check beyond `IS_AUTHENTICATED_FULLY`.
|
|
**Fix Applied:** Added `#[IsGranted('ROLE_ADMIN')]` to all four mutating template endpoints. `getTemplate` remains accessible to all authenticated users.
|
|
|
|
---
|
|
|
|
## Medium Risk Issues (Fixed)
|
|
|
|
### MED-01 — `APP_ENV=dev` in Committed `.env`
|
|
**File:** `.env`
|
|
**Risk:** If `.env` is used directly in production (no `.env.local`), the app runs in dev mode: profiler enabled, stack traces exposed, optimizations disabled.
|
|
**Finding:** `APP_ENV=dev` hardcoded in `.env`
|
|
**Recommendation:** Set `APP_ENV=prod` in `.env` (the committed default). Override with `APP_ENV=dev` in `.env.local` for local development.
|
|
|
|
### MED-02 — Static Analysis Tooling Missing
|
|
**Files:** `composer.json`, `phpstan.neon` (new)
|
|
**Risk:** Bugs and type errors that a static analyzer would catch reach production.
|
|
**Fix Applied:** Installed and configured:
|
|
```bash
|
|
composer require --dev phpstan/phpstan phpstan/phpstan-symfony phpstan/phpstan-doctrine
|
|
```
|
|
Created `phpstan.neon` at level 5 with Symfony + Doctrine extensions.
|
|
|
|
### MED-03 — NelmioApiDoc Publicly Accessible
|
|
**File:** `config/packages/security.yaml`
|
|
**Finding:** `/api/doc` is in `access_control` with `PUBLIC_ACCESS`. Full API documentation is accessible without authentication, including request/response schemas, authentication details, and endpoint enumeration.
|
|
**Recommendation:** Restrict to `ROLE_ADMIN` or remove from production deployment. Alternatively, move behind basic auth in the web server.
|
|
|
|
### MED-04 — `session: true` for a Stateless API
|
|
**File:** `config/packages/framework.yaml`
|
|
**Risk:** Unnecessary attack surface; sessions are unexpected in a stateless JWT API.
|
|
**Finding:** Session support is enabled even though all firewalls are `stateless: true`. Sessions won't be started in practice, but the session cookie infrastructure exists.
|
|
**Recommendation:** Set `session: false` in `framework.yaml` for an API-only application.
|
|
|
|
### MED-05 — `/session/token` Endpoint Has No Security Purpose
|
|
**File:** `src/Auth/Controller/AuthController.php`
|
|
**Finding:** Returns `bin2hex(random_bytes(16))` without any state or usage. In a stateless JWT API, this endpoint provides no CSRF protection and may confuse consumers about the security model.
|
|
**Recommendation:** Remove or document its exact purpose.
|
|
|
|
---
|
|
|
|
## Low Risk Issues
|
|
|
|
### LOW-01 — HSTS Header Missing `preload` Directive
|
|
**File:** `src/Shared/EventSubscriber/SecurityHeadersSubscriber.php`
|
|
**Finding:** HSTS header is `max-age=31536000; includeSubDomains` without `preload`.
|
|
**Recommendation:** Add `preload` and submit domain to HSTS preload list for maximum protection.
|
|
|
|
### LOW-02 — PHP `expose_php` Not Disabled
|
|
**Risk:** `PHP/8.x.y` version exposed in HTTP headers makes vulnerability targeting easier.
|
|
**Recommendation:** Set `expose_php = Off` in `php.ini` (DDEV: `.ddev/php/php.ini`).
|
|
|
|
### LOW-03 — Composer `php` Constraint Too Permissive
|
|
**File:** `composer.json`
|
|
**Finding:** `"php": ">=8.2"` while the project requires 8.3 features.
|
|
**Recommendation:** Change to `"php": ">=8.3"` to prevent accidental deployment on 8.2.
|
|
|
|
### LOW-04 — Payment Amount Hardcoded
|
|
**File:** `src/Payment/Controller/PaymentController.php`
|
|
**Finding:** `new Payment($user, 150000, ...)` — appointment payment amount is hardcoded at 150,000 rials. This should come from the appointment/doctor configuration.
|
|
**Recommendation:** Derive amount from `Appointment`/`Doctor` entity; never accept amount from client request.
|
|
|
|
### LOW-05 — Database Credentials in `.env` Are Insecure Defaults
|
|
**File:** `.env`
|
|
**Finding:** `DATABASE_URL="mysql://db:db@db:3306/db"` — username `db`, password `db`.
|
|
**Recommendation:** Use strong randomly-generated database credentials in production via `.env.local` or secret management.
|
|
|
|
---
|
|
|
|
## Changes Applied
|
|
|
|
| # | File | Change |
|
|
|---|------|--------|
|
|
| 1 | `config/packages/security.yaml` | `bcrypt cost:12` → `auto`; removed `login` from public_endpoints; added `custom_authenticators` |
|
|
| 2 | `config/packages/rate_limiter.yaml` | **NEW** — `send_code` (5/hour) and `login` (10/min) policies |
|
|
| 3 | `src/Shared/EventSubscriber/ExceptionSubscriber.php` | Added `TooManyRequestsHttpException`, `AccessDeniedException`, `AuthenticationException` handlers; added generic 500 fallback with logger |
|
|
| 4 | `src/Auth/Controller/AuthController.php` | Added rate limiter to `sendCode()`; added `login()` route stub for authenticator wiring |
|
|
| 5 | `src/Auth/Security/PasswordAuthenticator.php` | Injected `loginLimiter`; added rate limit check in `authenticate()` |
|
|
| 6 | `src/Payment/Controller/PaymentController.php` | Implemented `isAllowedCallbackIp()` CIDR check; fixed `isAllowedFrontend()` to use env var |
|
|
| 7 | `src/Shared/Service/FileValidatorService.php` | Added `validateUploadedFile(UploadedFile): string` |
|
|
| 8 | `src/Blog/Controller/BlogController.php` | Fixed `uploadImage()` to call `validateUploadedFile()` |
|
|
| 9 | `src/Doctor/Controller/DoctorController.php` | Fixed upload to call `validate()` (enforces size limit) |
|
|
| 10 | `src/Sms/Controller/SmsController.php` | Added `ROLE_ADMIN` to create/update/submit/delete template |
|
|
| 11 | `src/Shared/Constant/ErrorCodes.php` | Added `ERR_RATE_LIMIT_001` |
|
|
| 12 | `config/services.yaml` | Wired rate limiter factories; `$allowedFrontendHosts` for PaymentController |
|
|
| 13 | `.env.example` | **NEW** — safe placeholder template for all env vars |
|
|
| 14 | `phpstan.neon` | **NEW** — static analysis config |
|
|
|
|
---
|
|
|
|
## Installed Packages
|
|
|
|
```bash
|
|
composer require --dev phpstan/phpstan ^2.2
|
|
composer require --dev phpstan/phpstan-symfony ^2.0
|
|
composer require --dev phpstan/phpstan-doctrine ^2.0
|
|
```
|
|
|
|
---
|
|
|
|
## Configuration Changes
|
|
|
|
### `config/packages/security.yaml`
|
|
```yaml
|
|
password_hashers:
|
|
App\Auth\Entity\User:
|
|
algorithm: auto # was: bcrypt, cost: 12
|
|
|
|
api:
|
|
custom_authenticators: # was: missing
|
|
- App\Auth\Security\PasswordAuthenticator
|
|
jwt: ~
|
|
```
|
|
|
|
### `config/packages/rate_limiter.yaml` (new)
|
|
```yaml
|
|
framework:
|
|
rate_limiter:
|
|
send_code:
|
|
policy: 'sliding_window'
|
|
limit: 5
|
|
interval: '60 minutes'
|
|
login:
|
|
policy: 'fixed_window'
|
|
limit: 10
|
|
interval: '1 minute'
|
|
```
|
|
|
|
---
|
|
|
|
## Remaining Recommendations
|
|
|
|
The following items were identified but not automatically fixed. They require architectural or infrastructure decisions:
|
|
|
|
1. **Secret Management**: Move all secrets (APP_SECRET, JWT_PASSPHRASE, payment credentials, SMS API keys) to a secret manager (HashiCorp Vault, AWS Secrets Manager, Symfony Secrets). Never commit real secrets in any `.env` file.
|
|
|
|
2. **HTTPS Enforcement**: Ensure `strict_requirements: null` in `routing.yaml` is set for prod. Add `https_only: true` to firewall (Symfony 7 support). Configure web server to redirect HTTP → HTTPS.
|
|
|
|
3. **HSTS Preloading**: After confirming HTTPS is permanent, add `preload` to the HSTS header and submit to `hstspreload.org`.
|
|
|
|
4. **PHP ini hardening** (`.ddev/php/php.ini` → production php.ini):
|
|
```ini
|
|
expose_php = Off
|
|
display_errors = Off
|
|
log_errors = On
|
|
session.cookie_httponly = 1
|
|
session.cookie_secure = 1
|
|
session.cookie_samesite = Strict
|
|
```
|
|
|
|
5. **Payment Amount from Business Logic**: Derive appointment payment amount from a configurable source (doctor/plan/specialty) rather than a hardcode.
|
|
|
|
6. **Input Length Validation**: Add max-length constraints on string inputs (title, body, name, etc.) before hitting DB. Use Symfony Validator `#[Length]` constraints on entity properties.
|
|
|
|
7. **Audit Logging**: Add structured logging for all security-relevant events:
|
|
- Successful/failed OTP verifications
|
|
- Admin actions (approve/reject settlement, comment moderation)
|
|
- Role changes (ROLE_DOCTOR, ROLE_CLINIC assignment)
|
|
- Payment callback IP rejections
|
|
|
|
8. **Run PHPStan**: Execute `vendor/bin/phpstan analyse` and fix reported issues (especially type errors and potential null pointer dereferences).
|
|
|
|
9. **Composer Audit in CI**: Add `composer audit --no-dev` to CI pipeline. Currently clean, but must run on every dependency update.
|
|
|
|
10. **Production APP_ENV**: Set `APP_ENV=prod` as the default in `.env` (committed). Use `.env.local` for local dev override.
|
|
|
|
11. **Remove `/api/doc` from Production**: Disable NelmioApiDoc in `when@prod:` or restrict to `ROLE_ADMIN`.
|
|
|
|
12. **NelmioSecurityBundle**: Consider adding `nelmio/security-bundle` for centralized HTTP security header management as an alternative to the current `SecurityHeadersSubscriber`.
|
|
|
|
13. **CORS Origin**: Review `CORS_ALLOW_ORIGIN` regex before production. Current pattern allows `localhost` and `127.0.0.1` — restrict to production domain only.
|
|
|
|
14. **Messenger Security**: Ensure Redis is password-protected in production (`redis://:password@redis:6379`). Use TLS for Redis connections (`rediss://`).
|
|
|
|
---
|
|
|
|
## Security Score
|
|
|
|
| Domain | Before | After |
|
|
|--------|--------|-------|
|
|
| Authentication | 60 | 90 |
|
|
| Authorization / Access Control | 50 | 85 |
|
|
| Input Validation & File Upload | 55 | 80 |
|
|
| Secrets & Configuration | 30 | 65 |
|
|
| Rate Limiting & Brute Force | 20 | 85 |
|
|
| HTTP Security Headers | 80 | 85 |
|
|
| Error Handling | 45 | 90 |
|
|
| Payment Security | 55 | 80 |
|
|
| Dependency Security | 85 | 90 |
|
|
| Static Analysis | 0 | 50 |
|
|
| **Total** | **52 / 100** | **81 / 100** |
|
|
|
|
---
|
|
|
|
*Audit completed — all identified issues have been either fixed or documented as remaining recommendations.*
|