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.
feat/operator-identity
nexxo 2026-07-28 15:00:46 +02:00
parent e8bd8268fd
commit a5db914696
2 changed files with 73 additions and 7 deletions

View File

@ -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}";
}
/**

View File

@ -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);
});