From 88d9a66f44f7cfbc1c99103bc2d16153704e59f1 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 12:24:53 +0200 Subject: [PATCH] Sign the impersonation link against the portal's own host, not the console's --- .../Controllers/ImpersonationController.php | 43 +++++++++++++++---- tests/Feature/ImpersonationTest.php | 36 ++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/ImpersonationController.php b/app/Http/Controllers/ImpersonationController.php index bd97458..148e6c2 100644 --- a/app/Http/Controllers/ImpersonationController.php +++ b/app/Http/Controllers/ImpersonationController.php @@ -31,14 +31,41 @@ class ImpersonationController extends Controller $operator = Auth::guard('operator')->user(); - // Addressed by CUSTOMER uuid, not user: `users` has no uuid column, and - // naming the integer key in a URL is what R11 forbids. The portal end - // resolves the account the same way this end would have. - return redirect()->away(URL::temporarySignedRoute( - 'impersonate.enter', - now()->addSeconds(self::WINDOW), - ['customer' => $customer->uuid, 'operator' => $operator->uuid], - )); + // impersonate.enter carries no domain of its own — a portal route, + // reached wherever the portal already answers. Its SIGNATURE is + // computed over the full absolute URL: UrlGenerator::signedRoute() + // hashes the generated URL string, host included, and + // hasCorrectSignature() re-hashes $request->url() — the ACTUAL request + // URL — on the way back. Left alone, "the generated URL" means + // whichever host issued THIS request: the console's, once it has an + // exclusive hostname of its own. RestrictAdminHost then 404s every + // non-console route reached through the console's exclusive host, + // including this one — so the link would die before the customer ever + // saw it, the exact cross-host failure this controller exists to fix. + // + // Forced to the portal's own configured root instead, derived from + // config('app.url') rather than hardcoded, and restored immediately + // after: nothing else generated later in this request should inherit + // the override. + $portalRoot = rtrim((string) config('app.url'), '/'); + + URL::forceRootUrl($portalRoot); + + try { + $signed = URL::temporarySignedRoute( + 'impersonate.enter', + now()->addSeconds(self::WINDOW), + // Addressed by CUSTOMER uuid, not user: `users` has no uuid + // column, and naming the integer key in a URL is what R11 + // forbids. The portal end resolves the account the same way + // this end would have. + ['customer' => $customer->uuid, 'operator' => $operator->uuid], + ); + } finally { + URL::forceRootUrl(null); + } + + return redirect()->away($signed); } /** The portal end of the handover. Signed, and good exactly once. */ diff --git a/tests/Feature/ImpersonationTest.php b/tests/Feature/ImpersonationTest.php index 491cdf7..5c56b08 100644 --- a/tests/Feature/ImpersonationTest.php +++ b/tests/Feature/ImpersonationTest.php @@ -77,3 +77,39 @@ it('leaves the operator session untouched, so leaving returns to the console', f expect(Auth::guard('web')->check())->toBeFalse() ->and(Auth::guard('operator')->check())->toBeTrue(); }); + +it('signs the link against the portal host, not the console host it was issued from', function () { + // Exclusive mode is where this bites: the console has a hostname to + // itself, and RestrictAdminHost 404s any non-console route reached + // through it. impersonate.enter carries no domain of its own, so a link + // built and signed against the console's root would die the instant it + // was followed — precisely the cross-host failure this task exists to + // fix. A test that only checks `signature=` is present does not catch + // this: on dev it passes anyway, because exclusive mode is off there. + config()->set('admin_access.hosts', ['admin.example.test']); + config()->set('admin_access.exclusive', true); + config()->set('app.url', 'https://app.example.test'); + + $operator = Operator::factory()->role('Owner')->create(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + + // Issued exactly as a real operator's click would arrive: on the + // console's own exclusive host. (Routes are compiled once at boot, under + // this suite's default non-exclusive ADMIN_HOSTS="" — so the path still + // carries the fallback /admin prefix; only RestrictAdminHost's own checks + // read admin_access.* live, per request, which is what is under test.) + $response = $this->actingAs($operator, 'operator') + ->post('https://admin.example.test/admin/impersonate/'.$customer->uuid); + + $location = $response->headers->get('Location'); + + // The link has to carry the PORTAL's host, not the console's. + expect(parse_url($location, PHP_URL_HOST))->toBe('app.example.test'); + + // And it must still genuinely work when followed: valid signature, signs + // the customer in. + $this->get($location)->assertRedirect(route('dashboard')); + + expect(Auth::guard('web')->id())->toBe($user->id); +});