|
| 1 | +# Security Book |
| 2 | + |
| 3 | +This document explains the security architecture of the sitebuilder application: how authentication works, how CSRF protection works, and why the configuration looks the way it does. The goal is to eliminate guesswork for developers maintaining or extending the application. |
| 4 | + |
| 5 | + |
| 6 | +## Authentication: SecurityUser Boundary Pattern |
| 7 | + |
| 8 | +The application does **not** use Symfony's built-in entity user provider. Instead, a custom `SecurityUserProvider` wraps the `AccountCore` Doctrine entity into a `SecurityUser` readonly DTO before handing it to the security system. |
| 9 | + |
| 10 | +### Why |
| 11 | + |
| 12 | +`AccountCore` is a Domain entity in the Account vertical. If `$this->getUser()` returned `AccountCore` directly, every vertical that needs the current user (Organization, ProjectManagement, etc.) would depend on `Account/Domain/Entity/AccountCore` -- violating vertical boundaries. |
| 13 | + |
| 14 | +### How it works |
| 15 | + |
| 16 | +1. `SecurityUserProvider` (`src/Account/Infrastructure/Security/SecurityUserProvider.php`) implements `UserProviderInterface`. It loads `AccountCore` from the database and maps it to a `SecurityUser`. |
| 17 | +2. `SecurityUser` (`src/Common/Domain/Security/SecurityUser.php`) is a readonly DTO in `Common/` implementing `UserInterface` and `PasswordAuthenticatedUserInterface`. It exposes only what the security system and controllers need: id, email, roles, password hash, mustSetPassword, createdAt. |
| 18 | +3. `config/packages/security.yaml` uses a service-based provider: |
| 19 | + |
| 20 | +```yaml |
| 21 | +providers: |
| 22 | + app_user_provider: |
| 23 | + id: App\Account\Infrastructure\Security\SecurityUserProvider |
| 24 | +``` |
| 25 | +
|
| 26 | +4. Controllers call `$this->getUser()` and get a `SecurityUser`. When they need Account-specific data (e.g. `currentlyActiveOrganizationId`), they go through the Account facade. |
| 27 | + |
| 28 | +### Password upgrades |
| 29 | + |
| 30 | +`SecurityUserProvider` also implements `PasswordUpgraderInterface`, so Symfony can transparently rehash passwords when the hashing algorithm changes -- without any controller involvement. |
| 31 | + |
| 32 | + |
| 33 | +## CSRF Protection: Stateless Tokens |
| 34 | + |
| 35 | +The application uses **stateless CSRF tokens** (Symfony 7.2+), not the traditional session-bound tokens. This is a fundamentally different protection model. |
| 36 | + |
| 37 | +### Configuration |
| 38 | + |
| 39 | +`config/packages/csrf.yaml`: |
| 40 | + |
| 41 | +```yaml |
| 42 | +framework: |
| 43 | + form: |
| 44 | + csrf_protection: |
| 45 | + token_id: submit |
| 46 | +
|
| 47 | + csrf_protection: |
| 48 | + stateless_token_ids: |
| 49 | + - submit # all Symfony forms |
| 50 | + - authenticate # login form |
| 51 | + - logout # logout action |
| 52 | + - chat_based_content_editor_run |
| 53 | + - accept_invitation |
| 54 | +``` |
| 55 | + |
| 56 | +`config/packages/security.yaml` (on the `form_login` block): |
| 57 | + |
| 58 | +```yaml |
| 59 | +form_login: |
| 60 | + enable_csrf: true |
| 61 | +``` |
| 62 | + |
| 63 | +### How stateless CSRF tokens work |
| 64 | + |
| 65 | +Traditional (stateful) CSRF stores a random token in the session and validates it on submission. Stateless CSRF does not use the session at all. Instead, it relies on the browser's Same-Origin Policy through a layered defense: |
| 66 | + |
| 67 | +#### Layer 1: Origin / Referer header check (primary) |
| 68 | + |
| 69 | +When a form is submitted, the browser always sends an `Origin` header (or `Referer` as fallback). Symfony's `SameOriginCsrfTokenManager` compares this header against the application's own origin (`https://sitebuilder.dx-tooling.org` in production). |
| 70 | + |
| 71 | +- **Same-origin request** (legitimate): `Origin: https://sitebuilder.dx-tooling.org` -- matches, allowed. |
| 72 | +- **Cross-origin request** (attacker): `Origin: https://evil.com` -- does not match, rejected. |
| 73 | +- **No Origin header**: rejected (with caveats for some browser edge cases where `Referer` is checked as fallback). |
| 74 | + |
| 75 | +Browsers **do not allow** JavaScript to forge the `Origin` header, even via `fetch()` or `XMLHttpRequest`. This is enforced at the browser engine level and is not bypassable by client-side code. |
| 76 | + |
| 77 | +#### Layer 2: Double-submit cookie (enhanced, requires JavaScript) |
| 78 | + |
| 79 | +The file `assets/controllers/csrf_protection_controller.js` adds a second layer: |
| 80 | + |
| 81 | +1. On page load, every form has a hidden field `<input name="_csrf_token" value="csrf-token">`. The value `"csrf-token"` is a **cookie name identifier**, not a secret. |
| 82 | +2. When the form is submitted, the JavaScript intercepts the `submit` event and: |
| 83 | + - Stores `"csrf-token"` as the cookie name prefix. |
| 84 | + - Replaces the field value with a cryptographically random 24+ character Base64 token (generated via `window.crypto.getRandomValues`). |
| 85 | + - Sets a cookie: `__Host-csrf-token_<random-token>=csrf-token` with `SameSite=Strict` and `Secure`. |
| 86 | +3. The server validates that the cookie and the submitted field value are consistent. |
| 87 | + |
| 88 | +This "double-submit" pattern provides defense-in-depth because: |
| 89 | + |
| 90 | +- An attacker on a different origin **cannot read or set** `__Host-` prefixed cookies for your domain. |
| 91 | +- `SameSite=Strict` ensures the cookie is **never sent** on cross-origin requests. |
| 92 | +- A fresh random token is generated per submission, preventing replay. |
| 93 | + |
| 94 | +#### Layer 3: Session pinning (opportunistic) |
| 95 | + |
| 96 | +Once the double-submit check succeeds for a session, Symfony remembers this and **requires** it for future requests in that session. This prevents downgrade attacks where an attacker might try to bypass the JavaScript layer. |
| 97 | + |
| 98 | +### What the literal `"csrf-token"` value means |
| 99 | + |
| 100 | +When you view the page source and see: |
| 101 | + |
| 102 | +```html |
| 103 | +<input type="hidden" name="_csrf_token" value="csrf-token"> |
| 104 | +``` |
| 105 | + |
| 106 | +This is **expected behavior**, not a bug. The string `"csrf-token"` is: |
| 107 | + |
| 108 | +- A marker that the JavaScript picks up (it matches the regex `/^[-_a-zA-Z0-9]{4,22}$/`). |
| 109 | +- Used as the cookie name prefix for the double-submit pattern. |
| 110 | +- Replaced with a real random token **before** the form is actually submitted. |
| 111 | + |
| 112 | +If JavaScript is disabled, the field value `"csrf-token"` is submitted as-is. The server still validates the request using the Origin/Referer header check (Layer 1), so the request is still protected. |
| 113 | + |
| 114 | +### Prerequisite: trusted_proxies |
| 115 | + |
| 116 | +For the Origin check to work in production (behind a reverse proxy or load balancer), the application must know its own origin. This requires correct `trusted_proxies` and `trusted_headers` configuration in `config/packages/framework.yaml`: |
| 117 | + |
| 118 | +```yaml |
| 119 | +trusted_proxies: "%env(default::TRUSTED_PROXIES)%" |
| 120 | +trusted_headers: |
| 121 | + [ |
| 122 | + "x-forwarded-for", |
| 123 | + "x-forwarded-host", |
| 124 | + "x-forwarded-proto", |
| 125 | + "x-forwarded-port", |
| 126 | + "x-forwarded-prefix", |
| 127 | + ] |
| 128 | +``` |
| 129 | + |
| 130 | +If `trusted_proxies` is misconfigured, Symfony may see the wrong host/protocol and reject legitimate same-origin requests or (worse) accept cross-origin ones. Make sure `TRUSTED_PROXIES` is set to the proxy's IP range in your deployment environment. |
| 131 | + |
| 132 | +### Why not session-based CSRF? |
| 133 | + |
| 134 | +1. **Cacheability**: Stateless tokens allow full page caching. Session-bound tokens require a session to exist before the page is rendered, which defeats HTTP caching. |
| 135 | +2. **Login form**: The login page is visited by unauthenticated users who have no session yet. A session-based token would force starting a session just to show the login form. |
| 136 | +3. **Simplicity**: One protection model for all forms, no need to manage token storage or expiration. |
| 137 | + |
| 138 | +### Adding CSRF protection to new forms |
| 139 | + |
| 140 | +For any new form that performs a state-changing action: |
| 141 | + |
| 142 | +1. Add a hidden field in the Twig template: |
| 143 | + ```html |
| 144 | + <input type="hidden" name="_csrf_token" value="{{ csrf_token('your-token-id') }}"> |
| 145 | + ``` |
| 146 | +2. If the token ID is new, add it to `stateless_token_ids` in `config/packages/csrf.yaml`. |
| 147 | +3. Validate in the controller using `$this->isCsrfTokenValid('your-token-id', $request->request->getString('_csrf_token'))` or the `#[IsCsrfTokenValid]` attribute. |
| 148 | + |
| 149 | +The JavaScript double-submit enhancement works automatically for any `<input name="_csrf_token">` field -- no additional wiring needed. |
| 150 | + |
| 151 | +### Testing CSRF protection |
| 152 | + |
| 153 | +To verify protection is working, you can create a page on a different origin that attempts to POST to the application. The file `csrf-attack-test.html` (in the dx-tooling workspace root) contains five attack vectors: |
| 154 | + |
| 155 | +| Attack | Method | Expected result | |
| 156 | +|--------|--------|-----------------| |
| 157 | +| Classic form POST | `<form>` targeting the app | Rejected (Origin mismatch) | |
| 158 | +| fetch() with spoofed headers | JavaScript `fetch()` | Blocked by CORS | |
| 159 | +| Forged double-submit cookie | Set cookie + POST | Cookie not sent (SameSite=Strict) | |
| 160 | +| XHR with credentials | `XMLHttpRequest` | Blocked by CORS | |
| 161 | +| Missing token entirely | POST without `_csrf_token` | Rejected | |
| 162 | + |
| 163 | +All five must be rejected for the protection to be considered sound. |
| 164 | + |
| 165 | + |
| 166 | +## Session Configuration |
| 167 | + |
| 168 | +```yaml |
| 169 | +session: |
| 170 | + cookie_samesite: "none" |
| 171 | + cookie_secure: true |
| 172 | + cookie_httponly: true |
| 173 | + name: PHPSESSID_JA |
| 174 | + handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler |
| 175 | +``` |
| 176 | + |
| 177 | +- `cookie_secure: true` -- session cookie is only sent over HTTPS. |
| 178 | +- `cookie_httponly: true` -- session cookie is not accessible to JavaScript (mitigates XSS-based session theft). |
| 179 | +- `cookie_samesite: "none"` -- allows the session cookie on cross-origin requests. This is set to `"none"` (rather than `"strict"` or `"lax"`) because the application needs to support cross-origin flows (e.g. OAuth callbacks, embedded contexts). CSRF protection does **not** rely on `SameSite` for the session cookie -- it relies on the stateless CSRF mechanism described above. |
| 180 | +- `handler_id: PdoSessionHandler` -- sessions are stored in the database, not on the filesystem. |
| 181 | + |
| 182 | + |
| 183 | +## Password Hashing |
| 184 | + |
| 185 | +```yaml |
| 186 | +password_hashers: |
| 187 | + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: "auto" |
| 188 | +``` |
| 189 | + |
| 190 | +Symfony's `"auto"` strategy selects the best available algorithm (currently bcrypt or argon2, depending on PHP extensions). The `SecurityUserProvider` implements `PasswordUpgraderInterface` to transparently rehash passwords on login when the algorithm changes. |
| 191 | + |
| 192 | +In the `test` environment, cost parameters are reduced to minimum values for speed: |
| 193 | + |
| 194 | +```yaml |
| 195 | +when@test: |
| 196 | + security: |
| 197 | + password_hashers: |
| 198 | + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: |
| 199 | + algorithm: auto |
| 200 | + cost: 4 # bcrypt minimum |
| 201 | + time_cost: 3 # argon minimum |
| 202 | + memory_cost: 10 # argon minimum |
| 203 | +``` |
| 204 | + |
| 205 | + |
| 206 | +## Access Control |
| 207 | + |
| 208 | +```yaml |
| 209 | +access_control: |
| 210 | + - { path: ^/sign-in, roles: PUBLIC_ACCESS } |
| 211 | + - { path: ^/sign-up, roles: PUBLIC_ACCESS } |
| 212 | + - { path: "^/organization/invitation/[^/]+$", roles: PUBLIC_ACCESS } |
| 213 | + - { path: ^/review, roles: ROLE_USER } |
| 214 | + - { path: ^/projects, roles: ROLE_USER } |
| 215 | + - { path: ^/conversation, roles: ROLE_USER } |
| 216 | + - { path: ^/workspace, roles: ROLE_USER } |
| 217 | + - { path: ^/organization, roles: ROLE_USER } |
| 218 | +``` |
| 219 | + |
| 220 | +Only the first matching rule applies. Public pages (sign-in, sign-up, invitation acceptance) are explicitly listed. All application features require `ROLE_USER`. The role hierarchy grants `ROLE_ADMIN` all permissions of `ROLE_USER`: |
| 221 | + |
| 222 | +```yaml |
| 223 | +role_hierarchy: |
| 224 | + ROLE_ADMIN: ROLE_USER |
| 225 | +``` |
| 226 | + |
| 227 | +Routes not covered by `access_control` rules are accessible without authentication by default. When adding new verticals with protected routes, add corresponding `access_control` entries. |
0 commit comments