From fbce4cbce4b6ac7bca27094f5d911aac07c8631e Mon Sep 17 00:00:00 2001 From: Abi Renhart Date: Tue, 8 Sep 2026 16:18:36 -0700 Subject: [PATCH] fix(roles): degrade instead of 500 when the roles header is absent The view composer reads $_SERVER[AUTH_ROLES_HTTP_HEADER] unguarded, so any request that reaches Heimdall without passing through the reverse proxy returns a 500 rather than a dashboard. That covers a container healthcheck, a probe on the published port, and the window before the proxy is configured. Every view goes through this composer, so no page renders at all: ErrorException: Undefined array key "HTTP_X_TOKEN_USER_ROLES" at app/Providers/AppServiceProvider.php:95 Coalesce the missing header to an empty string, which fails closed on the admin check that follows. Do the same in ItemController::dash(), where the header is passed straight to explode() and null has been deprecated as a string argument since PHP 8.1. --- app/Http/Controllers/ItemController.php | 5 +- app/Providers/AppServiceProvider.php | 12 ++- tests/Feature/RolesHeaderMissingTest.php | 95 ++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/RolesHeaderMissingTest.php diff --git a/app/Http/Controllers/ItemController.php b/app/Http/Controllers/ItemController.php index ade1c5de5..dceb86355 100644 --- a/app/Http/Controllers/ItemController.php +++ b/app/Http/Controllers/ItemController.php @@ -41,7 +41,10 @@ public function dash(Request $request): View $data["treat_tags_as"] = $treat_tags_as; if (config('app.auth_roles_enable')) { - $roles = explode(config('app.auth_roles_delimiter'), $request->header(config('app.auth_roles_header'))); + // Coalesce before exploding: the header is absent on any request + // that did not come through the proxy, and passing null to + // explode() is deprecated in PHP 8.1 and removed later. + $roles = explode(config('app.auth_roles_delimiter'), $request->header(config('app.auth_roles_header')) ?? ''); if ($treat_tags_as == 'categories') { $data['categories'] = Item::whereHas('children')->with('children', function ($query) { $query->pinned()->orderBy('order', 'asc'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 186f85170..7c562d59b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -92,7 +92,17 @@ public function boot(): void $view->with('allusers', $allusers); $view->with('current_user', $current_user); if (config('app.auth_roles_enable')) { - $view->with('enable_auth_admin_controls', in_array(config('app.auth_roles_admin'), explode(config('app.auth_roles_delimiter'), $_SERVER[config('app.auth_roles_http_header')]))); + // Anything that reaches Heimdall without passing through the + // proxy arrives with no roles header: a container healthcheck, + // a probe on the published port, a proxy that is not set up + // yet. Reading the key unguarded turns each of those into a + // 500 for the whole view. Treat it as holding no roles, which + // fails closed on the admin check below. + $roles = $_SERVER[config('app.auth_roles_http_header')] ?? ''; + $view->with( + 'enable_auth_admin_controls', + in_array(config('app.auth_roles_admin'), explode(config('app.auth_roles_delimiter'), $roles)) + ); } else { $view->with('enable_auth_admin_controls', true); } diff --git a/tests/Feature/RolesHeaderMissingTest.php b/tests/Feature/RolesHeaderMissingTest.php new file mode 100644 index 000000000..fefad0141 --- /dev/null +++ b/tests/Feature/RolesHeaderMissingTest.php @@ -0,0 +1,95 @@ + true, + 'app.auth_roles_header' => self::ROLES_HEADER, + 'app.auth_roles_http_header' => self::ROLES_SERVER_KEY, + 'app.auth_roles_admin' => 'admin', + 'app.auth_roles_delimiter' => ',', + ]); + + // Nothing set the header: this is the state the bug is about. + unset($_SERVER[self::ROLES_SERVER_KEY]); + } + + public function test_dash_still_renders_when_the_roles_header_is_absent(): void + { + $this->seed(); + + $response = $this->get('/'); + + $response->assertStatus(200); + } + + public function test_a_request_without_the_roles_header_gets_no_admin_controls(): void + { + $this->seed(); + + $response = $this->get('/'); + + $response->assertStatus(200); + // Degrading has to fail closed. An absent header is no roles at all, + // never the admin role. + $response->assertDontSee('id="config-buttons"', false); + } + + public function test_a_request_without_the_roles_header_gets_no_dashboard_tiles(): void + { + $this->seed(); + + $item = Item::factory()->create([ + 'title' => 'Unifi Controller', + 'role' => 'admin', + 'pinned' => 1, + ]); + ItemTag::factory()->create(['item_id' => $item->id, 'tag_id' => 0]); + + $response = $this->get('/'); + + $response->assertStatus(200); + // No roles means no tiles, rather than an error page or every tile. + $response->assertDontSee('data-name="Unifi Controller"', false); + } + + public function test_the_admin_role_is_still_honoured_when_the_header_is_present(): void + { + $this->seed(); + + $_SERVER[self::ROLES_SERVER_KEY] = 'admin'; + + $response = $this->get('/', [self::ROLES_HEADER => 'admin']); + + $response->assertStatus(200); + $response->assertSee('id="config-buttons"', false); + + unset($_SERVER[self::ROLES_SERVER_KEY]); + } +}