Skip to content

Commit 3a59b39

Browse files
Add security documentation (securitybook.md)
Documents the application's security architecture: - SecurityUser boundary pattern and custom UserProvider - Stateless CSRF protection (Origin/Referer check, double-submit cookie, session pinning) and why the literal "csrf-token" value in hidden fields is by design - Session configuration and why SameSite=none is safe - Password hashing strategy and test environment overrides - Access control rules and role hierarchy
1 parent 97054e5 commit 3a59b39

1 file changed

Lines changed: 227 additions & 0 deletions

File tree

docs/securitybook.md

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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

Comments
 (0)