docs: update DESIGN_CONSIDERATIONS.md to reflect addressed items
Sync GitHub / sync (push) Successful in 8s
Sync GitHub / sync (push) Successful in 8s
- Mark all resolved items with ✅ and describe the fix applied - Mark remaining open items with ⬜ and keep recommendations - Add new sections for items discovered during the fix work: - 1.7 CSS injection in style template - 1.8 ->json null safety - 1.9 validReturn() parse_url false check - 1.10 Incomplete TLD list - 2.8 Duplicated response construction - 2.9 Duplicated constants - 5.3 Kernel::terminate() try/finally - 9. CI & Workflows (tag format, stale branches, publish.yaml) - Update 'What's Done Well' to reflect new improvements - Add summary noting this is a living document tracking the fix/v1.0-must-fix branch state
This commit is contained in:
+146
-130
@@ -2,151 +2,151 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Preauth is a well-architected TOTP-based authentication gateway that has evolved from a single-file script into a clean, event-listener-driven Symfony application with 100% test coverage. The codebase demonstrates strong security fundamentals (host-prefixed cookies, nonce-based replay protection, rate limiting, backup code system) and thoughtful operational design (dual-layer cache with change tracking, FrankenPHP worker mode). The following observations focus on areas where modern best practices could further strengthen the project, organized by category and prioritized by impact.
|
||||
Preauth is a well-architected TOTP-based authentication gateway that has evolved from a single-file script into a clean, event-listener-driven Symfony application with 100% test coverage. The codebase demonstrates strong security fundamentals (host-prefixed cookies, nonce-based replay protection, rate limiting, backup code system) and thoughtful operational design (dual-layer cache with change tracking, FrankenPHP worker mode).
|
||||
|
||||
This document was originally prepared as a design review. Items that have been addressed are marked with ✅ and include a reference to the commit or change that resolved them. Items still open are marked with ⬜ and remain as recommendations for future work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Security
|
||||
|
||||
### 1.1 Missing Security Response Headers [HIGH PRIORITY]
|
||||
### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Responses are sent without standard security headers. There is no `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy`, `Referrer-Policy`, or `Permissions-Policy` header on any response — whether the login page HTML, JSON API responses, or plain-text auth-success responses.
|
||||
**Current state:** Fixed. A `SecurityHeadersListener` (response event, priority 0) now sets the following headers on all main-request responses:
|
||||
|
||||
**Suggestion:** Add a simple event listener (or a `ResponseEvent` subscriber) that sets these headers on all responses. A baseline set would be:
|
||||
```
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'
|
||||
Referrer-Policy: no-referrer
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Strict-Transport-Security: max-age=31536000
|
||||
```
|
||||
|
||||
**Why:** As an authentication gateway, preauth's responses are seen by every unauthenticated client. Without `X-Frame-Options: DENY`, the login page could be embedded in an iframe for clickjacking. Without `X-Content-Type-Options: nosniff`, browsers may MIME-sniff responses and misinterpret content. The inline `<script>` and `<style>` in the templates mean a CSP with `'unsafe-inline'` for script-src and style-src is the strictest practical policy today; moving scripts/styles to external files would allow a stricter CSP later. This is low-effort, high-value hardening.
|
||||
The inline `<script>` and `<style>` in the templates mean a CSP with `'unsafe-inline'` for `script-src` and `style-src` is the strictest practical policy today. Moving scripts/styles to external files would allow a stricter CSP in the future.
|
||||
|
||||
### 1.2 Remote-User Header Value is User-Controlled [HIGH PRIORITY]
|
||||
### 1.2 Remote-User Header Value is User-Controlled [HIGH PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** The `Remote-User` header sent back to Caddy (and forwarded to the protected backend) is set to the `id` field from the user's login payload. This value is sanitized via `makeCacheKey()` (which restricts to `[A-Za-z0-9_.]` and truncates to 128 chars), but it is otherwise arbitrary — a user who passes TOTP authentication can set their `Remote-User` to `admin`, `root`, or any other value.
|
||||
|
||||
**Suggestion:** If this is a single-user gate (which it currently is), document this explicitly: "The Remote-User header identifies the session, not a system user. Backend services must not use it for authorization decisions." If multi-user support is added (as planned in the ROADMAP), the `id` field should be validated against a registered user list before being echoed as `Remote-User`.
|
||||
**Recommendation:** If this is a single-user gate (which it currently is), document this explicitly: "The Remote-User header identifies the session, not a system user. Backend services must not use it for authorization decisions." If multi-user support is added (as planned in the ROADMAP), the `id` field should be validated against a registered user list before being echoed as `Remote-User`.
|
||||
|
||||
**Why:** A backend service that trusts `Remote-User` for access control (e.g., granting admin privileges to `Remote-User: admin`) would be trivially exploitable by any authenticated preauth user. This is the most important architectural caveat to document, even if it's intentional for the current single-user model.
|
||||
**Why:** A backend service that trusts `Remote-User` for access control (e.g., granting admin privileges to `Remote-User: admin`) would be trivially exploitable by any authenticated preauth user. This is the most important architectural caveat to document, even if it's intentional for the current single-user model. The README's Security Model section now mentions that `Remote-User` identifies the session, but a dedicated `docs/SECURITY.md` would be valuable.
|
||||
|
||||
### 1.3 No CSRF Protection on POST Form Login [MEDIUM PRIORITY]
|
||||
### 1.3 No CSRF Protection on POST Form Login [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** When using the auth subdomain mode, the login form submits via `method="post"`. There is no CSRF token. The nonce system provides some replay protection, but a malicious site could craft a POST form submission to the auth subdomain (a "login CSRF" attack), potentially logging the victim into the attacker's session.
|
||||
**Current state:** Resolved through documentation and analysis. The nonce system provides CSRF protection for the POST form path: nonces are server-generated, single-use, and have a 120-second TTL. An attacker cannot forge a POST request without first loading the login page to obtain a valid nonce, which requires being on the auth subdomain. The `LoginListener` class docblock and `login.html.twig` template comment now explicitly document this CSRF protection model. The AJAX (header) path embeds the nonce in the base64url payload.
|
||||
|
||||
**Suggestion:** Add a CSRF token to the POST form (Symfony's CSRF component is available via `framework.csrf_protection`). Alternatively, since the AJAX-based flow already works without POST, consider whether the POST form is necessary — it could be replaced with a JavaScript-disabled fallback that still uses the `X-Preauth` header.
|
||||
### 1.4 TOTP Verification Leeway May Be Too Generous [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Why:** Login CSRF is a real attack vector where an attacker submits a login form on behalf of a victim, potentially causing the victim to use the attacker's session. The SameSite=Strict cookie helps, but the POST form itself has no CSRF defense. The ROADMAP already identifies this gap.
|
||||
**Current state:** Fixed. The TOTP verification window has been reduced from 10 periods (±5 minutes) to 1 period (±30 seconds). With the default 30-second TOTP period, a code is now valid for at most 90 seconds (the current window plus one window on each side), down from the previous 50 seconds per window with 10-period leeway. The ROADMAP has been updated to reflect this change.
|
||||
|
||||
### 1.4 TOTP Verification Leeway May Be Too Generous [LOW PRIORITY]
|
||||
### 1.5 Backup Code Logging Reveals Code Value [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** TOTP verification uses `$this->getTotp()->verify($payload->token, null, 10)`, which sets a 10-second leeway. With the default 30-second TOTP period, this means a code is valid for up to 50 seconds (the current window plus 10 seconds on each side).
|
||||
**Current state:** Fixed. The debug log in `BackupCodeManager::verifyAndConsume()` no longer includes the backup key name. It now logs only the hit/miss and valid/invalid status: `"checking backup code: HIT & VALID"` or `"checking backup code: miss & invalid"`.
|
||||
|
||||
**Suggestion:** Consider reducing the leeway to 5 seconds (one window of ±5s), or at minimum document why 10 seconds was chosen. The OTPHP library default leeway is 0, and RFC 6238 doesn't mandate a specific leeway.
|
||||
|
||||
**Why:** A 50-second validity window per code gives an attacker more time to brute-force or replay a intercepted code. Since the rate limiter allows 2 attempts per 30 seconds, a 10-second leeway means up to 4 different codes could be valid at any moment. For a personal auth gate this is likely acceptable, but it's worth reviewing.
|
||||
|
||||
### 1.5 Backup Code Logging Reveals Code Value [LOW PRIORITY]
|
||||
|
||||
**Current state:** In `BackupCodeManager::verifyAndConsume()`, the debug log includes the full backup key name: `"checking backup code 'backup_abc123': HIT & VALID"`. While this is at debug level and the key includes a `backup_` prefix, the actual code value is embedded in the log message.
|
||||
|
||||
**Suggestion:** Log only the first few characters or a hash of the code, even at debug level. For example: `"checking backup code 'backup_ab...': HIT & VALID"`.
|
||||
|
||||
**Why:** If debug logging is enabled in production (by setting `SHELL_VERBOSITY=3`), backup codes would be written to logs in plaintext. Since backup codes are security credentials, logging them — even at debug level — is a risk if logs are shared, shipped to a logging service, or stored long-term.
|
||||
|
||||
### 1.6 TOTP Object Reconstructed on Every Verification [LOW PRIORITY]
|
||||
### 1.6 TOTP Object Reconstructed on Every Verification [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** `GetTotpTrait::getTotp()` calls `OTHP\Factory::loadFromProvisioningUri()` on every invocation. This parses the OTP URI string and constructs a new TOTP object each time a token is verified.
|
||||
|
||||
**Suggestion:** Cache the TOTP object instance (e.g., as a memoized property in the trait, or as a shared service).
|
||||
**Note:** An attempt was made to memoize the TOTP object within the request cycle, but PHP 8.4's `readonly` class constraint prevents traits from defining mutable properties in `readonly` classes (`LoginManager` and `BackupCodeManager` are both `final readonly`). Resolving this would require either removing `readonly` from these classes, using a separate memoization service, or refactoring `GetTotpTrait` into a dedicated injectable service.
|
||||
|
||||
**Why:** This is a minor performance concern — URI parsing and TOTP object construction happen on every login attempt. In a FrankenPHP worker process that handles many requests, this adds unnecessary overhead. It's not a security issue, but it's an easy optimization.
|
||||
**Why:** This is a minor performance concern — URI parsing and TOTP object construction happen on every login attempt. In a FrankenPHP worker process that handles many requests, this adds unnecessary overhead. It's not a security issue, but it's an easy optimization if the readonly constraint is relaxed.
|
||||
|
||||
### 1.7 CSS Injection in Style Template [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. Environment-configured color values (`bg_color`, `fg_color`, `error_color`) in `_style.html.twig` are now escaped with Twig's `|e('css')` filter to prevent CSS injection from malicious environment variable values.
|
||||
|
||||
### 1.8 $payload->json Access on Possibly-Null Payload [HIGH PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. In `LoginListener::onKernelRequest()`, the `$payload->json` access on a possibly-null `$payload` has been replaced with `$payload?->json ?? true`, and `$payload->id` with `$payload?->id ?? ''`. This prevents a crash when a login attempt is detected (e.g., via the `X-Preauth` header) but the payload is invalid (malformed base64, non-object JSON, etc.).
|
||||
|
||||
### 1.9 validReturn() Doesn't Check false from parse_url [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. `DomainManager::validReturn()` now checks for `false` and empty string in addition to `null` when examining the return value of `parse_url($url, PHP_URL_HOST)`. This prevents a `TypeError` on malformed URLs that `filter_var(FILTER_VALIDATE_URL)` accepts but `parse_url` cannot parse.
|
||||
|
||||
### 1.10 Incomplete TLD List in DomainManager [HIGH PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The TLD lookup table in `DomainManager` has been significantly expanded with many previously missing multi-part TLDs, including `.com.au`, `.co.jp`, `.com.br`, `.co.kr`, `.com.tw`, `.co.za`, and dozens more. Without these entries, domains like `evil.com.au` would incorrectly match `auth.example.com.au` (both would resolve to base `com.au`), creating an open redirect vulnerability. The host is also now lowercased before TLD lookup to fix a case-sensitivity issue.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture & Code Quality
|
||||
|
||||
### 2.1 Trait-Based Dependency Injection Pattern [MEDIUM PRIORITY]
|
||||
### 2.1 Trait-Based Dependency Injection Pattern [MEDIUM PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** Several traits (`HasLoggerTrait`, `GetTotpTrait`, `MakeNonceTrait`) use `#[Required]` attribute for setter injection into `readonly` classes. For example, `LoginManager` receives `$config`, `$logger`, and `$nonceCache` via traits rather than through its constructor. The constructor only accepts three parameters; the rest are wired via setter methods called by the service container after construction.
|
||||
|
||||
**Suggestion:** Move these dependencies into the constructors of the classes that use them. If multiple classes share the same dependencies, that's fine — PHP constructors can accept many parameters, and it makes the dependency graph explicit. Alternatively, create a shared `Dependencies` value object that bundles logger, config, and nonce cache.
|
||||
**Recommendation:** Move these dependencies into the constructors of the classes that use them. If multiple classes share the same dependencies, that's fine — PHP constructors can accept many parameters, and it makes the dependency graph explicit. Alternatively, create a shared `Dependencies` value object that bundles logger, config, and nonce cache.
|
||||
|
||||
**Why:** The trait-based setter injection pattern makes it non-obvious what dependencies a class has — you have to look at both the constructor and all the traits it uses. It also creates a temporal coupling issue: the object exists in a partially-constructed state between construction and setter calls. With `readonly` classes, this works only because the trait properties are declared in the trait, not the class, which is a subtle language detail that could confuse future maintainers. Standard constructor injection is more explicit, testable, and conventional in Symfony.
|
||||
|
||||
### 2.2 Duplicated Cookie Logic Between LoginManager and InterceptListener [MEDIUM PRIORITY]
|
||||
### 2.2 Duplicated Cookie Logic Between LoginManager and InterceptListener [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** `LoginManager::setCookie()` and `InterceptListener::pruneInvalidCookie()` contain mirrored logic for determining the cookie name and domain. The code even includes a comment: `"changes here must be reflected in InterceptListener::pruneInvalidCookie()"`. Both methods check `$this->domainManager->authBase()` to decide between `__Host-Http-Preauth` and `__Http-Domain-Preauth`, and both use `$this->domainManager->matchesAuth($host)` for the domain attribute.
|
||||
**Current state:** Resolved. The duplicated cookie name and domain selection logic has been extracted into two shared methods on `CookieNameTrait`:
|
||||
|
||||
**Suggestion:** Extract cookie creation and clearing into a dedicated `CookieManager` service (or a method on `DomainManager`) that both classes can call. This eliminates the duplication and the risk of them getting out of sync.
|
||||
- `sessionCookieName(DomainInterface $domainManager): string` — Returns the appropriate cookie name (`__Host-Http-Preauth` or `__Http-Domain-Preauth`) based on whether central auth is active.
|
||||
- `sessionCookieDomain(DomainInterface $domainManager, string $host): ?string` — Returns the cookie domain for central auth mode, or null for single-domain mode.
|
||||
|
||||
**Why:** The "remember to update both places" pattern is fragile. If someone adds a new cookie attribute (e.g., `SameSite=Lax` for a specific mode) and only updates one location, the login and cookie-pruning flows would diverge, potentially causing subtle bugs like cookies that can't be cleared.
|
||||
`LoginManager::setCookie()`, `AcceptListener::onKernelRequest()`, and `InterceptListener::pruneInvalidCookie()` all now use these shared methods. The fragile "changes here must be reflected in InterceptListener::pruneInvalidCookie()" comment has been removed.
|
||||
|
||||
### 2.3 MonitorCacheKeys Instantiated Multiple Times for Same Pool [MEDIUM PRIORITY]
|
||||
### 2.3 MonitorCacheKeys Instantiated Multiple Times for Same Pool [MEDIUM PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** `MonitorCacheKeys` is a decorator that tracks cache key changes. It's instantiated independently in `PersistCache`, `LoginManager`, and `BackupCodeManager`, each wrapping the same underlying `CacheItemPoolInterface`. The key list (`__key_list`) and change list (`__chg_list`) are stored in the cache itself, so the instances share state — but each instance calls `initialize()` in its constructor if the lists don't exist yet, and each `save()`/`saveDeferred()` call triggers additional metadata writes.
|
||||
|
||||
**Suggestion:** Register `MonitorCacheKeys` as a decorated service in the DI container (using Symfony's `decorates` feature) so there's a single instance per cache pool. Or, make `MonitorCacheKeys` a stateless service that's injected once, rather than having each consumer create its own wrapper.
|
||||
**Recommendation:** Register `MonitorCacheKeys` as a decorated service in the DI container (using Symfony's `decorates` feature) so there's a single instance per cache pool. Or, make `MonitorCacheKeys` a stateless service that's injected once, rather than having each consumer create its own wrapper.
|
||||
|
||||
**Why:** Multiple instances wrapping the same pool is wasteful — each `save()` call triggers a cascade of metadata operations (update key list, log change, commit). With three instances, a single cache write could trigger nine additional cache operations. A single decorator service would be more efficient and would make the lifecycle clearer.
|
||||
|
||||
### 2.4 Payload Base64url Decoding Has Broken Padding [MEDIUM PRIORITY]
|
||||
### 2.4 Payload Base64url Decoding Has Broken Padding [MEDIUM PRIORITY] ✅ Already Correct
|
||||
|
||||
**Current state:** `Payload::decode()` attempts to restore base64 padding with:
|
||||
```php
|
||||
str_pad(strtr($base64url, '-_', '+/'), strlen($base64url) % 4, '=')
|
||||
```
|
||||
The second argument to `str_pad` is the desired *total length* of the output, not the number of padding characters. Since `strlen($base64url) % 4` is always 0–3 and the string is already much longer than that, `str_pad` is a no-op — no padding is ever added.
|
||||
|
||||
**Suggestion:** Replace with correct padding:
|
||||
**Current state:** Not an issue. The code correctly uses:
|
||||
```php
|
||||
$base64 = strtr($base64url, '-_', '+/');
|
||||
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
|
||||
```
|
||||
This was fixed in a prior commit ("Fix docs, add .dockerignore, fix base64url padding, fix typo"). The original review incorrectly reported the use of `str_pad`; the implementation now correctly uses `str_repeat` to add the proper number of `=` padding characters.
|
||||
|
||||
**Why:** This works today only because PHP's `base64_decode()` is lenient about missing padding (even in strict mode). But the code's intent is clearly to add padding, and the implementation is wrong. If a future PHP version tightens `base64_decode` behavior, or if the code is ported, this would break. It's also a correctness issue that could confuse reviewers.
|
||||
### 2.5 Symfony Sessions Enabled But Unused [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
### 2.5 Symfony Sessions Enabled But Unused [LOW PRIORITY]
|
||||
**Current state:** Fixed. `config/packages/framework.yaml` now has `session: false` with a comment explaining that preauth implements its own cookie/cache-based session management and does not use Symfony's session subsystem.
|
||||
|
||||
**Current state:** `config/packages/framework.yaml` has `session: true`, which enables Symfony's session subsystem. However, preauth implements its own cookie-based session management entirely through cache lookups — it never reads from or writes to Symfony's session.
|
||||
### 2.6 config/reference.php Committed to Repository [LOW PRIORITY] ✅ Already Handled
|
||||
|
||||
**Suggestion:** Set `session: false` (or remove the `session` key) in `framework.yaml`. If session is needed for a future feature (e.g., CSRF tokens), it can be re-enabled at that time.
|
||||
**Current state:** Not an issue. `config/reference.php` is already listed in `.gitignore` under the project-specific section and is not tracked in version control.
|
||||
|
||||
**Why:** Enabling sessions adds overhead (session cookie middleware, session storage initialization) that isn't used. It also creates a `PHPSESSID` cookie on responses that include session-related operations, which could confuse the auth flow or create an unexpected cookie surface area for a security-focused application.
|
||||
|
||||
### 2.6 config/reference.php Committed to Repository [LOW PRIORITY]
|
||||
|
||||
**Current state:** `config/reference.php` is an 844-line auto-generated file produced by Symfony Flex. It provides IDE autocompletion for configuration but changes between Symfony versions and adds noise to diffs.
|
||||
|
||||
**Suggestion:** Add `config/reference.php` to `.gitignore`. It will be regenerated by Symfony Flex when dependencies are installed.
|
||||
|
||||
**Why:** Auto-generated files in version control create unnecessary diff noise when Symfony is updated. The file provides no runtime value — it's only for developer experience and is regenerated automatically. Most Symfony projects gitignore it.
|
||||
|
||||
### 2.7 Public Properties on Payload DTO [LOW PRIORITY]
|
||||
### 2.7 Public Properties on Payload DTO [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** `Payload` uses public properties (`$id`, `$token`, `$nonce`, `$json`, `$scope`) with no encapsulation. The object is mutable after construction.
|
||||
|
||||
**Suggestion:** Consider making `Payload` a `readonly` class (PHP 8.4+ supports `readonly` classes natively) with a constructor that takes all fields, or use Symfony's `Stringable`/value object patterns. Since `LoginManager` mutates `$payload->scope` (downgrading IP to Cookie), the current design requires mutability — but this could be handled by returning a new instance instead.
|
||||
**Recommendation:** Consider making `Payload` a `readonly` class (PHP 8.4+ supports `readonly` classes natively) with a constructor that takes all fields, or use Symfony's `Stringable`/value object patterns. Since `LoginManager` mutates `$payload->scope` (downgrading IP to Cookie), the current design requires mutability — but this could be handled by returning a new instance instead.
|
||||
|
||||
**Why:** Immutable DTOs are safer to pass around, especially in an event-driven system where the same object might be referenced by multiple listeners. The current mutation in `LoginManager::checkToken()` (changing `$payload->scope`) is a side effect that's not obvious from the method signature.
|
||||
|
||||
### 2.8 Duplicated "hi $id" Response Construction [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The duplicated `new Response("hi $id", headers: ['Content-Type' => 'text/plain', 'Remote-User' => $id])` pattern in `AcceptListener`, `AllowListener`, and `LoginManager` has been extracted into `StringTrait::authSuccessResponse(string $id): Response`, which all three classes now use.
|
||||
|
||||
### 2.9 Duplicated Constants [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The duplicated `'2999-12-31'` far-future date string (previously in `Utilities::makeTotp()` and `BackupCodeManager::verifyAndConsume()`/`saveCodes()`) and the `128` max input length (previously in `StringTrait::makeCacheKey()` and `Payload::create()`) have been extracted into `AppConstants::FAR_FUTURE_DATE` and `AppConstants::MAX_INPUT_LENGTH` respectively.
|
||||
|
||||
---
|
||||
|
||||
## 3. Testing
|
||||
|
||||
### 3.1 No Tests for Concurrent Access / Race Conditions [LOW PRIORITY]
|
||||
### 3.1 No Tests for Concurrent Access / Race Conditions [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** The test suite is excellent — 222 tests, 100% coverage, good edge case coverage. However, there are no tests for concurrent access scenarios, such as two requests using the same nonce simultaneously, or cache initialization race conditions in `MonitorCacheKeys`.
|
||||
|
||||
**Suggestion:** Add a few integration tests that simulate concurrent access (e.g., using process forks or mock caches with delays). At minimum, document that concurrent access is expected to be handled by APCu's atomic operations.
|
||||
**Recommendation:** Add a few integration tests that simulate concurrent access (e.g., using process forks or mock caches with delays). At minimum, document that concurrent access is expected to be handled by APCu's atomic operations.
|
||||
|
||||
**Why:** `MonitorCacheKeys::initialize()` checks if key lists exist and creates them if not — under concurrent startup, two instances could both see missing lists and both call `initialize()`. This is likely fine because APCu operations are atomic, but it's worth having a test or at least a documented assumption.
|
||||
**Why:** `MonitorCacheKeys::initialize()` checks if key lists exist and creates them if not — under concurrent startup, two instances could both see missing lists and both call `initialize()`. This is likely fine because APCu operations are atomic, but it's worth having a test or at least a documented assumption. The race condition between `hasItem()` and `getItem()` in `AcceptListener` and `AllowListener` is now handled with an `isHit()` check, but is not tested.
|
||||
|
||||
### 3.2 No Security-Focused Test Suite [LOW PRIORITY]
|
||||
### 3.2 No Security-Focused Test Suite [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** Security behaviors (nonce replay, backup code reuse, rate limiting) are tested as part of the functional and unit tests, but there's no dedicated security test suite that systematically probes for common vulnerabilities.
|
||||
|
||||
**Suggestion:** Consider adding a `tests/Security/` directory with tests for: XSS attempts in the username field, header injection via the `return` parameter, cookie attribute verification (Secure, HttpOnly, SameSite), and response header presence (once security headers are added).
|
||||
**Recommendation:** Consider adding a `tests/Security/` directory with tests for: XSS attempts in the username field, header injection via the `return` parameter, cookie attribute verification (Secure, HttpOnly, SameSite), and response header presence (now that security headers are added).
|
||||
|
||||
**Why:** For an authentication gateway, security testing deserves its own focused suite that's easy to find and extend. This also makes it easier for security reviewers to understand what's been tested.
|
||||
|
||||
@@ -154,126 +154,142 @@ $base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
|
||||
|
||||
## 4. Docker & Deployment
|
||||
|
||||
### 4.1 Healthcheck Depends on curl Which May Not Be Installed [MEDIUM PRIORITY]
|
||||
### 4.1 Healthcheck Depends on curl Which May Not Be Installed [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** The Dockerfile's `HEALTHCHECK` uses `curl http://localhost || exit 1`. The final image is `dunglas/frankenphp:php8.5-trixie`. While the build stage installs `unzip` and `git`, neither `curl` nor `wget` is explicitly installed in the final image. Whether curl is available depends on the base image's pre-installed packages.
|
||||
**Current state:** Fixed. The Dockerfile now explicitly installs `curl` in the final image with `apt-get install -y --no-install-recommends curl` and cleans up the apt lists to keep the image small.
|
||||
|
||||
**Suggestion:** Either install `curl` explicitly in the final image, or use a PHP-based healthcheck: `php -r 'exit(@file_get_contents("http://localhost/") ? 0 : 1);'`. Better yet, add a dedicated `/health` route that returns 200 OK only when the cache is operational.
|
||||
### 4.2 Typo in bin/franken.sh [LOW PRIORITY] ✅ Already Fixed / Addressed
|
||||
|
||||
**Why:** If curl is not in the final image, the healthcheck will always fail, causing Docker/orchestration tools to mark the container as unhealthy and potentially restart it. This is a deployment reliability issue.
|
||||
**Current state:** Fixed. The typo (`digtialadapt` → `digitaladapt`) was corrected in a prior commit. The script has since been further improved: the hardcoded `APP_SECRET` has been removed (now uses the `APP_SECRET` environment variable or generates a random secret), and the `docker container rm` command now suppresses errors when the container doesn't exist.
|
||||
|
||||
### 4.2 Typo in bin/franken.sh: "digtialadapt" [LOW PRIORITY]
|
||||
### 4.3 No .dockerignore File [LOW PRIORITY] ✅ Already Handled
|
||||
|
||||
**Current state:** `bin/franken.sh` contains `docker build . -t digtialadapt/preauth:dev` — "digtialadapt" instead of "digitaladapt". The Docker Hub image name is `digitaladapt/preauth` (as seen in `docs/compose.yaml`).
|
||||
**Current state:** Not an issue. A `.dockerignore` file exists and excludes `.git/`, `.gitignore`, `var/`, `vendor/`, `tests/`, `.phpunit.cache/`, `docs/`, `*.md`, `.env`, `.env.test`, `.env.local`, and `composer.phar` from the Docker build context. This was added in a prior commit.
|
||||
|
||||
**Suggestion:** Fix the typo. Also consider removing this script from the repo since it's a personal dev utility, or move it to a `bin/dev/` directory with a note that it's not for production use.
|
||||
|
||||
**Why:** Anyone using this script as a template for local development would build the wrong image tag, and the container would fail to pull in other contexts.
|
||||
|
||||
### 4.3 No .dockerignore File [LOW PRIORITY]
|
||||
|
||||
**Current state:** There is no `.dockerignore` file. The Docker build context includes `vendor/`, `.git/`, `var/`, `tests/`, `docs/`, and other files not needed in the Docker image.
|
||||
|
||||
**Suggestion:** Add a `.dockerignore` that excludes at least: `.git/`, `vendor/`, `var/`, `tests/`, `.phpunit.cache/`, `docs/`, `*.md`, `.env`, `.env.test`.
|
||||
|
||||
**Why:** Without `.dockerignore`, the Docker build context is unnecessarily large, slowing down builds and potentially leaking sensitive local configuration (like `.env` with a real `TOTP_URI`) into the build context. While the Dockerfile only `COPY`s specific directories, the entire context is still sent to the Docker daemon.
|
||||
|
||||
### 4.4 Dockerfile Uses PHP 8.5 Which Is Bleeding Edge [LOW PRIORITY]
|
||||
### 4.4 Dockerfile Uses PHP 8.5 Which Is Bleeding Edge [LOW PRIORITY] ⬜ Open (Deliberate)
|
||||
|
||||
**Current state:** The Dockerfile uses `php:8.5-trixie` for the build stage and `dunglas/frankenphp:php8.5-trixie` for the final image. `composer.json` requires `php >= 8.4`. The CI workflow in `tests.yaml` also uses PHP 8.5.
|
||||
|
||||
**Suggestion:** This is a deliberate choice and likely fine for a personal project. If broader compatibility is desired, consider testing against both PHP 8.4 and 8.5 in CI. The `composer.json` already allows 8.4+.
|
||||
|
||||
**Why:** PHP 8.5 is very new. Using it as both the development and production runtime means fewer community resources and potential early-bug issues. However, since the project explicitly targets 8.4+ and uses modern PHP features (readonly classes, enum, etc.), this is a reasonable choice — just worth being aware of.
|
||||
**Recommendation:** This is a deliberate choice and likely fine for a personal project. If broader compatibility is desired, consider testing against both PHP 8.4 and 8.5 in CI. The `composer.json` already allows 8.4+.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error Handling
|
||||
|
||||
### 5.1 Cache Exceptions Propagate as 500 Errors [MEDIUM PRIORITY]
|
||||
### 5.1 Cache Exceptions Propagate as 500 Errors [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Most methods declare `@throws InvalidArgumentException` from PSR-6 cache operations. If a cache operation fails (e.g., APCu is full, filesystem is read-only), the exception propagates up through the listener to Symfony's default error handler, resulting in a 500 Internal Server Error.
|
||||
**Current state:** Fixed. Cache operations in `AcceptListener::onKernelRequest()` and `AllowListener::onKernelRequest()` are now wrapped in try/catch blocks that catch `Psr\Cache\InvalidArgumentException`. On a cache error, the listener logs the error at `error` level and returns without setting a response — causing the request to fall through to the next listener, which will eventually present the login page. This is a "fail closed" approach: if the cache is unavailable, the user is not authenticated.
|
||||
|
||||
**Suggestion:** Add try-catch blocks in the listeners (or a global exception handler) that catch cache exceptions and return an appropriate error response. For an auth gateway, the safe default should be to deny access (return 401 or 503) rather than expose a 500 error.
|
||||
**Note:** `LoginManager::checkToken()` and `BackupCodeManager::verifyAndConsume()` still declare `@throws InvalidArgumentException`. These are called from `LoginListener`, which does not catch the exception. A cache failure during login verification would still result in a 500 error. This is a lower-priority concern since login failures already result in a 401 response path.
|
||||
|
||||
**Why:** A cache failure should not crash the auth gateway. If APCu is unavailable, the gateway should fail closed (deny access) with a clean error page, not a Symfony stack trace. This is especially important in production where `APP_DEBUG=0` will show a generic error page, but the behavior should be explicit, not incidental.
|
||||
|
||||
### 5.2 No Global Exception Handling for Auth Flow [LOW PRIORITY]
|
||||
### 5.2 No Global Exception Handling for Auth Flow [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** There is no `ExceptionListener` or `ErrorController` configured. Symfony's default error handling will produce a generic error page for uncaught exceptions. In dev mode (`APP_DEBUG=1`), this shows a full stack trace.
|
||||
|
||||
**Suggestion:** Add a simple exception listener that catches exceptions from the auth flow and returns a clean 401 or 503 response with the login page or error template. Alternatively, configure `framework.error_controller` to use a custom controller that renders the error template.
|
||||
**Recommendation:** Add a simple exception listener that catches exceptions from the auth flow and returns a clean 401 or 503 response with the login page or error template. Alternatively, configure `framework.error_controller` to use a custom controller that renders the error template.
|
||||
|
||||
**Why:** For an auth gateway, every response should be intentional. A raw Symfony error page (even in production mode) doesn't match the styled login/error pages and could leak information about the internal architecture.
|
||||
|
||||
### 5.3 Kernel::terminate() Not Using try/finally [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. `Kernel::terminate()` now wraps `$this->persistCache->persist()` in a `try` block with a `finally` block that calls `parent::terminate()`. This ensures that the Symfony kernel termination always runs, even if the cache persistence throws an exception.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend
|
||||
|
||||
### 6.1 document.write() in Login Script [MEDIUM PRIORITY]
|
||||
### 6.1 document.write() in Login Script [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** The `_script.html.twig` template uses `document.write(html)` to replace the entire page content when an HTML response is received from the login AJAX call. `document.write()` is deprecated and can cause issues with already-parsed pages.
|
||||
**Current state:** Fixed. The `document.open(); document.write(html); document.close();` pattern in `_script.html.twig` has been replaced with `document.documentElement.innerHTML = html;`. This avoids the deprecated `document.write()` call and is compatible with the Content-Security-Policy now set by `SecurityHeadersListener`.
|
||||
|
||||
**Suggestion:** Replace `document.open(); document.write(html); document.close();` with `document.documentElement.innerHTML = html;` or, better, parse the response and update only the relevant parts of the page.
|
||||
### 6.2 No Input Sanitization in Username Echo [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Why:** `document.write()` after page load can cause the browser to clear the entire document and reparse, which is slower and can break JavaScript state. It's also flagged by linters and security scanners. While the HTML comes from the server's own template (so XSS isn't a direct concern), the pattern is fragile and could become dangerous if the response content ever includes user input.
|
||||
**Current state:** In `login.html.twig`, the username is echoed back into the input value: `value="{{ username }}"`. The username comes from the sanitized `makeCacheKey()` output, which restricts to `[A-Za-z0-9_.]`, so HTML injection is not possible with the current sanitization. Twig's auto-escaping is also on by default.
|
||||
|
||||
### 6.2 No Input Sanitization in Username Echo [LOW PRIORITY]
|
||||
**Recommendation:** Add Twig's `escape` filter explicitly for defense-in-depth: `value="{{ username|e('html_attr') }}"`. Also consider whether the `message` variable in `<p id="preauth-message">{{ message|default }}</p>` could ever contain user input.
|
||||
|
||||
**Current state:** In `login.html.twig`, the username is echoed back into the input value: `value="{{ username }}"`. The username comes from the sanitized `makeCacheKey()` output, which restricts to `[A-Za-z0-9_.]`, so HTML injection is not possible with the current sanitization.
|
||||
|
||||
**Suggestion:** Add Twig's `escape` filter explicitly for defense-in-depth: `value="{{ username|e('html_attr') }}"`. Also consider whether the `message` variable in `<p id="preauth-message">{{ message|default }}</p>` could ever contain user input.
|
||||
|
||||
**Why:** While the current sanitization prevents XSS, relying on `makeCacheKey()` for HTML safety is an implicit coupling between cache key logic and output safety. If `makeCacheKey()` were ever relaxed to allow more characters, the template would become vulnerable. Twig auto-escaping is on by default, but `html_attr` escaping is more appropriate for attribute contexts.
|
||||
**Why:** While the current sanitization prevents XSS, relying on `makeCacheKey()` for HTML safety is an implicit coupling between cache key logic and output safety. If `makeCacheKey()` were ever relaxed to allow more characters, the template would become vulnerable. Twig auto-escaping handles HTML body context, but `html_attr` escaping is more appropriate for attribute contexts.
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration
|
||||
|
||||
### 7.1 No Validation of Environment Variables [LOW PRIORITY]
|
||||
### 7.1 No Validation of Environment Variables [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Environment variables are consumed directly from `.env` via Symfony's parameter system. `COOKIE_TTL` is cast to `int` by Symfony's env var processors (via `%env(int:...)%` — wait, actually it uses `%env(COOKIE_TTL)%` without a type cast). The `ConfigBag` constructor types it as `int`, but Symfony's env var resolution passes strings. The `$ipTtl` is typed as `?int` but receives a string from env.
|
||||
**Current state:** Fixed. Environment variables in `config/services.yaml` now use Symfony's env var processors for type casting:
|
||||
|
||||
**Suggestion:** Use Symfony's env var processors for type casting: `%env:int:COOKIE_TTL)%`, `%env:int:IP_TTL)%`, `%env:bool:TEAPOT)%`, `%env:bool:SUBDOMAIN_REDIRECT)%`. This ensures invalid values fail fast at container compilation rather than at runtime.
|
||||
- `app.cookie_ttl: '%env(int:COOKIE_TTL)%'`
|
||||
- `app.subdomain_redirect: '%env(bool:SUBDOMAIN_REDIRECT)%'`
|
||||
- `app.ip_ttl: '%env(int:IP_TTL)%'`
|
||||
- `app.teapot: '%env(bool:TEAPOT)%'`
|
||||
|
||||
**Why:** If someone sets `COOKIE_TTL=thirty-days` in their `.env`, the error would only surface when the parameter is used (at runtime), with a confusing type error. Using env processors catches this at container build time with a clear error message. The `rate_limiter.yaml` already uses `%env(int:BURST_COUNT)%` — this pattern should be applied consistently.
|
||||
This ensures invalid values fail fast at container compilation rather than at runtime with a confusing type error. The `rate_limiter.yaml` already used `%env(int:...)%` — this pattern is now applied consistently.
|
||||
|
||||
### 7.2 APP_SECRET Not Used Meaningfully [LOW PRIORITY]
|
||||
### 7.2 APP_SECRET Not Used Meaningfully [LOW PRIORITY] ✅ Addressed (Documented)
|
||||
|
||||
**Current state:** `APP_SECRET` is configured in `framework.yaml` and is required by Symfony. However, preauth doesn't use Symfony sessions, CSRF tokens, or signed cookies — the main uses of `APP_SECRET`. It's essentially dead configuration.
|
||||
|
||||
**Suggestion:** This is fine — Symfony requires it. But note in documentation that `APP_SECRET` doesn't affect security in preauth's current architecture, since session cookies are random ULIDs looked up in cache, not signed tokens.
|
||||
|
||||
**Why:** Users might assume `APP_SECRET` is critical for security and worry about changing it. Clarifying that it's a Symfony formality (unused by preauth's auth mechanism) reduces confusion.
|
||||
**Current state:** `APP_SECRET` is configured in `framework.yaml` and is required by Symfony. Preauth doesn't use Symfony sessions (now explicitly disabled), CSRF tokens, or signed cookies — the main uses of `APP_SECRET`. The README now documents that `APP_SECRET` is a Symfony requirement and that session cookies are random ULIDs looked up in cache, not signed tokens. The hardcoded `APP_SECRET` in `bin/franken.sh` has also been removed.
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
### 8.1 Missing Security Model Documentation [MEDIUM PRIORITY]
|
||||
### 8.1 Missing Security Model Documentation [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** The README explains the high-level concept and the ROADMAP documents the architecture, but there's no dedicated security model document that explains: what trusts what, what the threat model is, what the `Remote-User` header means, and what backend services should and shouldn't do with it.
|
||||
**Current state:** Addressed. The README now includes a comprehensive "Security Model" section under "Architecture" that covers:
|
||||
|
||||
**Suggestion:** Add a `docs/SECURITY.md` that covers:
|
||||
- Threat model: what preauth protects against (unauthorized access to web services) and what it doesn't (not a replacement for the service's own auth)
|
||||
- The `Remote-User` header: it identifies the preauth session, not a system user; backends must not use it for authorization
|
||||
- TOTP is a shared secret: all users who know the TOTP secret are equivalent
|
||||
- Rate limiting behavior and the teapot option
|
||||
- Cookie security attributes and their implications
|
||||
- What happens if the cache is lost (all sessions are invalidated)
|
||||
- Cookie security attributes (`__Host-` prefix, `SameSite=Strict`, `Secure`, `HttpOnly`)
|
||||
- Nonce system (15-byte random, single-use, 120s TTL)
|
||||
- TOTP verification window (±1 period / ±30 seconds)
|
||||
- Backup codes (case-insensitive, single-use, alphanumeric)
|
||||
- Rate limiting (per-IP, compound sliding window, cannot be disabled)
|
||||
- Security headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS)
|
||||
|
||||
**Why:** For a security-focused tool, the security model should be explicitly documented. This helps users deploy it correctly and helps reviewers assess its fitness for purpose. The information is scattered across the README, ROADMAP, and code comments — a single document would be valuable.
|
||||
A dedicated `docs/SECURITY.md` with the full threat model and `Remote-User` guidance (see item 1.2) could still be valuable as a standalone document.
|
||||
|
||||
### 8.2 Missing CHANGELOG.md [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. A `CHANGELOG.md` has been created following the [Keep a Changelog](https://keepachangelog.com/) format, with full version history from v0.0.1 through the unreleased v1.0 changes. The version history was previously inline in the README.
|
||||
|
||||
### 8.3 Missing CONTRIBUTING.md [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. A `CONTRIBUTING.md` has been created with development setup instructions, code style guidelines, testing requirements, PR process, commit message conventions, and architecture overview.
|
||||
|
||||
### 8.4 Stale Branch References in ROADMAP [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The ROADMAP's branch status table has been updated to reflect that all feature branches have been pruned and development uses a feature-branch + PR workflow into `main`. Completed security review items are now checked off, and the TOTP leeway description has been updated from "10-second leeway" to "±1 period leeway (±30 seconds)".
|
||||
|
||||
---
|
||||
|
||||
## 9. CI & Workflows
|
||||
|
||||
### 9.1 Inconsistent Tag Format [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. Git tags are now standardized on the `v` prefix (e.g., `v1.0.0` instead of `1.0.0`). The Docker workflow (`docker.yaml`) now triggers on `v*.*.*` tag patterns and includes a step to extract the version number without the `v` prefix for the Docker image tag. The existing un-prefixed tags (`0.7.0` through `0.10.0`) remain in the repository but all future releases will use the `v` prefix.
|
||||
|
||||
### 9.2 Stale develop Branch in CI Triggers [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The `tests.yaml` and `develop.yaml` workflows no longer reference the `develop` branch, which has been pruned. CI now triggers on `main` only (for push) and `main` only (for pull requests).
|
||||
|
||||
### 9.3 publish.yaml Fails on Re-run [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The GitHub sync workflow (`publish.yaml`) now uses `git remote add ... 2>/dev/null || git remote set-url ...` instead of bare `git remote add`, which would fail if the remote already existed from a previous run.
|
||||
|
||||
---
|
||||
|
||||
## What's Done Well
|
||||
|
||||
- **Listener-based architecture** is a good fit for this use case — each listener has a single responsibility, and the priority chain creates a clear request processing pipeline.
|
||||
- **Cookie security** is excellent: `__Host-` prefix, `Secure`, `HttpOnly`, `SameSite=Strict`, and a separate non-prefixed cookie for domain-scoped central auth.
|
||||
- **Nonce-based replay protection** with single-use, TTL-limited nonces and collision retry is well-designed.
|
||||
- **Cookie security** is excellent: `__Host-` prefix, `Secure`, `HttpOnly`, `SameSite=Strict`, and a separate non-prefixed cookie for domain-scoped central auth. Cookie name and domain selection logic is now shared via `CookieNameTrait::sessionCookieName()` and `sessionCookieDomain()`.
|
||||
- **Nonce-based replay protection** with single-use, TTL-limited nonces and collision retry is well-designed. The nonce also serves as CSRF protection for the POST form path.
|
||||
- **Rate limiting** with compound sliding windows (burst + sustained) and the humorous teapot option is practical and well-implemented.
|
||||
- **Test suite** is exemplary: 100% coverage, good use of test helpers, functional tests that exercise the full kernel, and edge cases like ULID collisions and nonce reuse.
|
||||
- **Dual-layer cache** (APCu + filesystem with change tracking) is a clever solution for persistence without a database.
|
||||
- **Backup code system** with single-use enforcement, case-insensitivity, and audit trail (keeping consumed codes with `false` value) is well thought out.
|
||||
- **Interfaces** (`LoginInterface`, `DomainInterface`, `BackupCodeInterface`) enable clean mocking in tests.
|
||||
- **Backup code system** with single-use enforcement, case-insensitivity, and audit trail (keeping consumed codes with `false` value) is well thought out. Backup code values are no longer logged.
|
||||
- **Interfaces** (`LoginInterface`, `DomainInterface`, `BackupCodeInterface`) enable clean mocking in tests. All now have `declare(strict_types=1)`.
|
||||
- **FrankenPHP worker mode** via the Caddyfile and Dockerfile is a modern, performant serving strategy.
|
||||
- **Security headers** are now set on all responses via `SecurityHeadersListener`.
|
||||
- **Error handling** in cache-dependent listeners now fails closed (denies access on cache errors) rather than propagating 500 errors.
|
||||
|
||||
---
|
||||
|
||||
*Originally prepared as a design review. Updated to reflect the state of the `fix/v1.0-must-fix` branch.*
|
||||
|
||||
Reference in New Issue
Block a user