Repository navigation
Security/phase 0 hardening #1200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: production
Are you sure you want to change the base?
Changes from all commits
d7b04d4
c1699a8
846ce35
593f694
2f85aeb
a6d588f
4acc269
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Http\Middleware; | ||
|
|
||
| use Closure; | ||
| use Illuminate\Http\Request; | ||
| use Symfony\Component\HttpFoundation\Response; | ||
|
|
||
| /** | ||
| * The headers a browser reads before it trusts anything on the page. "PreventFraming" keeps its own concern, | ||
| * so this only adds what it does not already cover. | ||
| */ | ||
| final readonly class SecurityHeaders | ||
| { | ||
| /** | ||
| * Only over HTTPS: browsers ignore the header on plain HTTP, and sending it there would pin local | ||
| * development to a scheme it does not serve. "includeSubDomains" and "preload" are left off on purpose, | ||
| * since either one breaks any subdomain that is not HTTPS-only yet. | ||
| */ | ||
| private const string HSTS = 'max-age=31536000'; | ||
|
|
||
| /** | ||
| * Recording a voice note is the one capability the app asks for, so the microphone stays available to the | ||
| * page itself and to nobody else. Everything it never asks for is denied outright. | ||
| */ | ||
| private const string PERMISSIONS = 'camera=(self), microphone=(self), geolocation=(), payment=(), usb=()'; | ||
|
|
||
| /** | ||
| * @param Closure(Request): (Response) $next | ||
| */ | ||
| public function handle(Request $request, Closure $next): Response | ||
| { | ||
| $response = $next($request); | ||
| $headers = $response->headers; | ||
|
|
||
| if ($request->isSecure()) { | ||
| $headers->set('Strict-Transport-Security', self::HSTS); | ||
| } | ||
|
|
||
| $headers->set('X-Content-Type-Options', 'nosniff'); | ||
| $headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); | ||
| $headers->set('Permissions-Policy', self::PERMISSIONS); | ||
|
|
||
| return $response; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,6 +18,7 @@ | |||||||||||||||||||||||
| use Illuminate\Support\ServiceProvider; | ||||||||||||||||||||||||
| use Inertia\ExceptionResponse; | ||||||||||||||||||||||||
| use Inertia\Inertia; | ||||||||||||||||||||||||
| use LogicException; | ||||||||||||||||||||||||
| use Tempest\Highlight\Highlighter; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| final class AppServiceProvider extends ServiceProvider | ||||||||||||||||||||||||
|
|
@@ -30,11 +31,27 @@ public function register(): void | |||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| public function boot(): void | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| $this->ensureProductionIsNotDebugging(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| $this->bootRateLimitingDefaults(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| $this->bootExceptionHandling(); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * A production build that still has "APP_DEBUG" on answers every error with a stack trace, the environment | ||||||||||||||||||||||||
| * and the queries that led there. That is worse than being down, so the app refuses to start instead, and | ||||||||||||||||||||||||
| * the deploy fails on it rather than the first visitor. | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| private function ensureProductionIsNotDebugging(): void | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| throw_if( | ||||||||||||||||||||||||
| $this->app->isProduction() && config('app.debug'), | ||||||||||||||||||||||||
|
Comment on lines
+48
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sensitive Data Exposure Reachability: External Disable debug mode before the production guard throws. If production runs with Proposed fix for app/Providers/AppServiceProvider.php
Committable suggestion
|
||||||||||||||||||||||||
| LogicException::class, | ||||||||||||||||||||||||
| 'APP_DEBUG must be false when APP_ENV is "production", as it exposes stack traces, environment variables and queries.', | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| private function bootExceptionHandling(): void | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| Inertia::handleExceptionsUsing(function (ExceptionResponse $response): ExceptionResponse|RedirectResponse|null { | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |||||
| use App\Http\Middleware\HandleInertiaRequests; | ||||||
| use App\Http\Middleware\PreventFraming; | ||||||
| use App\Http\Middleware\RedirectBannedUsers; | ||||||
| use App\Http\Middleware\SecurityHeaders; | ||||||
| use App\Http\Middleware\SerializePushSessions; | ||||||
| use App\Http\Middleware\StripNullBytes; | ||||||
| use App\Http\Middleware\UpdateLastSeenAt; | ||||||
|
|
@@ -42,6 +43,7 @@ | |||||
| // Before the strings are trimmed, so a value that was only NUL bytes ends up empty like any other blank one. | ||||||
| $middleware->prepend(StripNullBytes::class); | ||||||
| $middleware->append(PreventFraming::class); | ||||||
| $middleware->append(SecurityHeaders::class); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain
Security Misconfiguration Reachability: External Prepend Laravel 13 places appended middleware after Apply the middleware ordering and test the early response
Committable suggestion
|
||||||
|
|
||||||
| $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'who_to_follow', 'who_to_follow_round', 'lane_dimming', 'replies_per_page']); | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -35,9 +35,11 @@ | |||||||||||||||||||||
| | Application Admins | ||||||||||||||||||||||
| |-------------------------------------------------------------------------- | ||||||||||||||||||||||
| | | ||||||||||||||||||||||
| | The GitHub usernames listed here are admins of the application, which | ||||||||||||||||||||||
| | allows them to moderate commits. Set them as a comma separated list | ||||||||||||||||||||||
| | in your ".env" file, for example: "ADMINS=nunomaduro,taylorotwell". | ||||||||||||||||||||||
| | The GitHub accounts listed here are admins of the application, which | ||||||||||||||||||||||
| | allows them to moderate commits. Prefer "admin_github_ids" below: an | ||||||||||||||||||||||
| | account's numeric ID never changes, while its username can be renamed | ||||||||||||||||||||||
| | away and then claimed by somebody else. This list is the fallback for | ||||||||||||||||||||||
| | as long as no IDs are configured. | ||||||||||||||||||||||
| | | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -46,6 +48,22 @@ | |||||||||||||||||||||
| explode(',', (string) env('ADMINS', '')), | ||||||||||||||||||||||
| ))), | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /* | ||||||||||||||||||||||
| |-------------------------------------------------------------------------- | ||||||||||||||||||||||
| | Application Admins, by GitHub ID | ||||||||||||||||||||||
| |-------------------------------------------------------------------------- | ||||||||||||||||||||||
| | | ||||||||||||||||||||||
| | The same admins, identified by the numeric GitHub ID that never changes. | ||||||||||||||||||||||
| | Set them as a comma separated list in your ".env" file, for example: | ||||||||||||||||||||||
| | "ADMIN_GITHUB_IDS=1,2". Once this list is set, "admins" above is ignored. | ||||||||||||||||||||||
| | | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 'admin_github_ids' => array_values(array_filter(array_map( | ||||||||||||||||||||||
| trim(...), | ||||||||||||||||||||||
| explode(',', (string) env('ADMIN_GITHUB_IDS', '')), | ||||||||||||||||||||||
| ))), | ||||||||||||||||||||||
|
Comment on lines
+62
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain
Keep handle-based admin access off when Security Misconfiguration Reachability: External If Enable the handle fallback only when Add a configured flag in config/app.php
Check the configured flag in User::isAdmin()
Committable suggestion
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /* | ||||||||||||||||||||||
| |-------------------------------------------------------------------------- | ||||||||||||||||||||||
| | Application Debug Mode | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| return [ | ||
|
|
||
| /* | ||
| |-------------------------------------------------------------------------- | ||
| | Trusted Proxies | ||
| |-------------------------------------------------------------------------- | ||
| | | ||
| | The addresses of the load balancers or proxies in front of the app, as a | ||
| | comma separated list of IPs or CIDR ranges in your ".env" file, or "*" | ||
| | when the app is only reachable through them. | ||
| | | ||
| | Abuse limits key on the client's address, so this decides what "the | ||
| | client's address" means. Left empty, no proxy is trusted and the | ||
| | connection's own address is used, which is right for a server that | ||
| | terminates TLS itself. Behind a proxy that is left unset, every client | ||
| | shares one address, so one of them can exhaust a limit for all of them. | ||
| | | ||
| | Trusting an address that is reachable from the internet lets whoever is | ||
| | there forge its own, so list only proxies you control. | ||
| | | ||
| | An empty list, never null: given null, the framework trusts whichever | ||
| | address connected whenever the app runs on Laravel Cloud, Forge or | ||
| | Vapor, and then every caller can set its own address through | ||
| | "X-Forwarded-For" and spend someone else's rate limit. | ||
| | | ||
| */ | ||
|
|
||
| 'proxies' => env('TRUSTED_PROXIES') ?: [], | ||
|
|
||
| ]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use App\Models\User; | ||
|
|
||
| it('matches an admin by GitHub ID rather than by handle', function (): void { | ||
| config(['app.admin_github_ids' => ['42'], 'app.admins' => []]); | ||
|
|
||
| expect(User::factory()->create(['github_id' => '42', 'handle' => 'renamed-away'])->isAdmin())->toBeTrue() | ||
| ->and(User::factory()->create(['github_id' => '43', 'handle' => 'admin'])->isAdmin())->toBeFalse(); | ||
| }); | ||
|
|
||
| it('gives nothing to whoever claims the handle a former admin renamed away from', function (): void { | ||
| config(['app.admin_github_ids' => ['42'], 'app.admins' => ['admin']]); | ||
|
|
||
| expect(User::factory()->create(['github_id' => '43', 'handle' => 'admin'])->isAdmin())->toBeFalse(); | ||
| }); | ||
|
|
||
| it('falls back to handles while no IDs are configured', function (): void { | ||
| config(['app.admin_github_ids' => [], 'app.admins' => ['admin']]); | ||
|
|
||
| expect(User::factory()->create(['handle' => 'admin'])->isAdmin())->toBeTrue() | ||
| ->and(User::factory()->create(['handle' => 'someone'])->isAdmin())->toBeFalse(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Illuminate\Support\Facades\Route; | ||
|
|
||
| beforeEach(function (): void { | ||
| Route::get('_test/headers', fn (): string => 'ok'); | ||
| }); | ||
|
|
||
| it('sends the headers a browser reads before it trusts the page', function (): void { | ||
| $this->get('_test/headers') | ||
| ->assertOk() | ||
| ->assertHeader('X-Content-Type-Options', 'nosniff') | ||
| ->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin') | ||
| ->assertHeader('Permissions-Policy', 'camera=(self), microphone=(self), geolocation=(), payment=(), usb=()') | ||
| ->assertHeader('X-Frame-Options', 'DENY') | ||
| ->assertHeader('Content-Security-Policy', "frame-ancestors 'none'"); | ||
| }); | ||
|
|
||
| it('pins HTTPS only when the request came over it', function (): void { | ||
| $this->get('https://localhost/_test/headers') | ||
| ->assertHeader('Strict-Transport-Security', 'max-age=31536000'); | ||
|
|
||
| $this->get('http://localhost/_test/headers') | ||
| ->assertHeaderMissing('Strict-Transport-Security'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Illuminate\Http\Request; | ||
| use Illuminate\Support\Facades\Route; | ||
|
|
||
| beforeEach(function (): void { | ||
| Route::get('_test/ip', fn (Request $request): string => (string) $request->ip()); | ||
| }); | ||
|
|
||
| it('believes the forwarded address of a proxy it trusts', function (): void { | ||
| config(['trustedproxy.proxies' => '10.0.0.0/8']); | ||
|
|
||
| $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.3']) | ||
| ->withHeaders(['X-Forwarded-For' => '203.0.113.9']) | ||
| ->get('_test/ip') | ||
| ->assertSee('203.0.113.9'); | ||
| }); | ||
|
|
||
| it('ignores a forwarded address from anywhere else', function (): void { | ||
| config(['trustedproxy.proxies' => []]); | ||
|
|
||
| $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.7']) | ||
| ->withHeaders(['X-Forwarded-For' => '203.0.113.9']) | ||
| ->get('_test/ip') | ||
| ->assertSee('198.51.100.7'); | ||
| }); | ||
|
|
||
| it('ignores a forwarded address on a host the framework would otherwise trust', function (): void { | ||
| // Given null instead of an empty list, the framework trusts whatever connected on these hosts, and a caller | ||
| // reaching the app directly picks its own address. | ||
| config(['trustedproxy.proxies' => []]); | ||
|
|
||
| $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.7']) | ||
| ->withHeaders(['X-Forwarded-For' => '203.0.113.9']) | ||
| ->get('http://app.on-forge.com/_test/ip') | ||
| ->assertSee('198.51.100.7'); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Script executed:
Repository: dev-null-sh/dev-null.sh
Length of output: 7666
Script executed:
Repository: dev-null-sh/dev-null.sh
Length of output: 2444
Security Misconfiguration
Exploitability: Difficult
CWE: CWE-693
View Security blast radius
Deny camera access because the application only requests microphone access.
camera=(self)allows same-origin code to request camera access. The voice-note flow requests audio only. Change the policy and its test assertion tocamera=().Update the policy and assertion