From a5db9146963f7dfd02ac1ceba3cbc061dd364287 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 15:00:46 +0200 Subject: [PATCH] Key the password-confirmation marker by guard and identity confirmPassword() already checked the right account per guard, but the session marker it stamped, auth.password_confirmed_at, was one flat key shared by every guard. In shared-host mode the portal and console guards keep their login in the same session, so confirming a customer's portal password left the marker in place for an operator-only gate too. One private method now computes the key for all three callers so they cannot drift apart again. --- app/Livewire/Concerns/ConfirmsPassword.php | 41 +++++++++++++++---- .../Admin/PasswordConfirmationGuardTest.php | 39 ++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/app/Livewire/Concerns/ConfirmsPassword.php b/app/Livewire/Concerns/ConfirmsPassword.php index 6d545a1..ed4f48d 100644 --- a/app/Livewire/Concerns/ConfirmsPassword.php +++ b/app/Livewire/Concerns/ConfirmsPassword.php @@ -14,10 +14,20 @@ use Illuminate\Support\Str; * credentials. The threat is not a stranger — it is a colleague, or anyone, at * an unlocked machine. * - * Stored in the SAME session key Laravel's own `password.confirm` middleware - * uses, so a confirmation made here also satisfies any framework route behind - * that middleware, and vice versa. Reinventing the marker would have produced - * two independent notions of "recently confirmed", one of which would drift. + * The marker is keyed on the confirming GUARD and that guard's own ACCOUNT id + * (passwordConfirmationSessionKey()) — it used to be one flat key shared by + * every guard, the same one Laravel's own `password.confirm` middleware + * uses. That worked only as long as one session ever held one identity. In + * shared-host mode (this VM's own mode) the portal and console guards + * coexist in the SAME session, so a customer confirming their portal + * password and an operator confirming an operator-only action both wrote and + * read that identical flat key — confirming as one identity silently + * satisfied the gate for the other (Codex R15, P1a). Scoping to guard AND + * identity closes both halves at once: a marker cannot cross a guard + * boundary, and cannot survive switching to a different account on the same + * guard either. No framework route in this app runs Laravel's own + * password.confirm middleware (checked) — if that changes, it needs the same + * scoping or this gap reopens. * * Rate-limited, because a confirmation form is a password oracle: without a * limit it is an unauthenticated-feeling place to guess at a known account. @@ -34,7 +44,7 @@ trait ConfirmsPassword public function passwordRecentlyConfirmed(): bool { - $at = (int) session('auth.password_confirmed_at', 0); + $at = (int) session($this->passwordConfirmationSessionKey(), 0); return $at > 0 && (time() - $at) < $this->confirmationWindow(); } @@ -80,13 +90,30 @@ trait ConfirmsPassword // a session id that was already known to someone else must not be the // one that gets the extra authority. session()->regenerate(); - session(['auth.password_confirmed_at' => time()]); + session([$this->passwordConfirmationSessionKey() => time()]); } /** Give up the confirmation early — leaving a page should not keep it open. */ public function forgetPasswordConfirmation(): void { - session()->forget('auth.password_confirmed_at'); + session()->forget($this->passwordConfirmationSessionKey()); + } + + /** + * The one place this marker's session key is computed. + * + * passwordRecentlyConfirmed(), confirmPassword() and + * forgetPasswordConfirmation() all go through this rather than each + * building the string themselves, so they cannot drift apart from one + * another — which is exactly how the guard-agnostic key this replaces + * came to be read by one guard and written by another. + */ + private function passwordConfirmationSessionKey(): string + { + $guard = $this->confirmationGuard(); + $identity = auth()->guard($guard)->user()?->getAuthIdentifier() ?? 'guest'; + + return "auth.password_confirmed_at.{$guard}.{$identity}"; } /** diff --git a/tests/Feature/Admin/PasswordConfirmationGuardTest.php b/tests/Feature/Admin/PasswordConfirmationGuardTest.php index 256a611..8a02996 100644 --- a/tests/Feature/Admin/PasswordConfirmationGuardTest.php +++ b/tests/Feature/Admin/PasswordConfirmationGuardTest.php @@ -86,3 +86,42 @@ it('resolves the operator explicitly by guard, not by whichever guard happens to ->call('confirmPassword') ->assertHasNoErrors(); }); + +it("does not let a portal customer's password confirmation satisfy the console's own gate", function () { + // Codex R15 P1a: confirmPassword() always checked the RIGHT account per + // guard (the tests above), but until now it stamped a single + // guard-agnostic session marker — auth.password_confirmed_at, no guard or + // identity in the key — that ANY confirmationGuard() then accepted. In + // shared-host mode (this suite's default, and this VM's own mode) the + // portal and console guards keep their login key in the SAME session, so + // signing into both and confirming the CUSTOMER's password on the portal + // settings page left the marker in place for the OPERATOR-only Secrets + // page too. This is the escalation itself, not a proxy for it. + $operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']); + $customer = User::factory()->create(['password' => 'kunden-passwort']); + + // Signed into both guards, one shared session — the login() calls write + // real session data, unlike actingAs() (in-memory user only), so the + // marker written by the first call below is genuinely there for the + // second to find, exactly as it would be for a real browser carrying + // both identities. + Auth::guard('web')->login($customer); + Auth::guard('operator')->login($operator); + + // Confirm the CUSTOMER's password on the portal settings page. + // App\Livewire\Settings::confirmationGuard() defaults to 'web', resolved + // explicitly by guard — no actingAs needed to reach the right account, + // the login() call above already put it in the session. + Livewire::test(App\Livewire\Settings::class) + ->set('confirmablePassword', 'kunden-passwort') + ->call('confirmPassword') + ->assertHasNoErrors(); + + // Walk into the console as the OPERATOR with that confirmation still + // sitting in the session. actingAs() only sets the AMBIENT default guard + // Gate reads for Secrets::mount()'s authorize() call — it does not touch + // the 'web' guard's own session entry set above, so this is the real + // shape of the attack: one browser, two identities, one session. + Livewire::actingAs($operator, 'operator')->test(Secrets::class) + ->assertViewHas('unlocked', false); +});