Files
projectsend/bootstrap/app.php
T
denkfabrik-li 35d68a792b Stop a rejected settings form flashing the credential it carried
When validation fails, Laravel flashes the request's input into the
session so the form can be repopulated. Its exclusion list is
current_password, password and password_confirmation -- written for the
login and password screens, and covering none of the credentials the
system settings screens take. `dontFlash` did not appear anywhere in this
repository.

So every one of these went into the session in clear the moment its form
was rejected:

    secret          ExternalStorageSettingsController   (S3 secret access key)
    key_file        ExternalStorageSettingsController   (GCS service account JSON)
    bind_password   LdapSettingsController
    client_secret   SocialLoginSettingsController, EmailSettingsController
    secret_key      CaptchaSettingsController

Each is stored with an `encrypted` cast, and config/session.php puts
sessions in the database with `encrypt => false` -- so the rejected save
wrote in clear into the same database the cast exists to protect.

The sharpest one is key_file. serviceAccountKeyRule() exists to catch a
paste that lost its last line, which makes "the request carrying a
service account private key" and "the request that fails validation" the
same request more often than not.

dontFlash() merges rather than replaces, so the framework's three stay.

The cost is that these five come back blank after a failed save. That is
already what they do after a successful one -- every screen here treats
them as write-only, and a blank means "keep what is stored" -- so the
behaviour is now the same either way instead of only on success.

Tests: one per field, each submitting a form that fails validation while
carrying a secret, then reading the old input back the way the form
would. All five fail against the unmodified bootstrap/app.php. A sixth
pins that the framework's own three are still excluded, and each
assertion checks a neighbouring non-secret field still comes back, so
this cannot pass by flashing nothing at all.

Note for the record: this is testable in the existing harness after all.
phpunit.xml sets SESSION_DRIVER=array, but old input is written to the
session whatever the driver backs it, so getOldInput() sees exactly what
a database session would have stored.
2026-08-29 00:02:21 +02:00

156 lines
7.9 KiB
PHP

<?php
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\ValidateCsrfToken;
use App\Modules\Api\Http\Middleware\EnsureApiAccountIsActive;
use App\Modules\Api\Http\Middleware\EnsureStaffToken;
use App\Modules\Api\Http\Middleware\EnsureTokenCan;
use App\Modules\Api\Http\Middleware\RecordApiRequest;
use App\Modules\Api\Http\Middleware\SetApiLocale;
use App\Modules\Api\Support\ProblemDetails;
use App\Modules\Identity\Http\Middleware\EnforceTwoFactor;
use App\Modules\Identity\Http\Middleware\EnsureAccountIsActive;
use App\Modules\Identity\Http\Middleware\EnsureSetupIsComplete;
use App\Modules\Identity\Http\Middleware\EnsureStaff;
use App\Modules\Platform\Http\Middleware\EnsureCapability;
use App\Modules\Platform\Http\Middleware\SetLocale;
use App\Support\WriteSafeRedirect;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Session\Middleware\AuthenticateSession;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
// Versioned at the prefix, not with a header or a query parameter:
// /api/v1 is a frozen contract, and a future /api/v2 gets its own
// route file rather than branching inside these controllers.
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api/v1',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Trusted proxies are configured in config/trustedproxy.php, NOT
// here. This closure runs when the HTTP kernel is resolved, which is
// before the dotenv bootstrapper has read .env, so env() returns null
// here for anything that is not already a real environment variable —
// silently, and only on web requests (artisan bootstraps in the other
// order, so a CLI check reports the setting as working). The framework's
// TrustProxies middleware is in the global stack either way and falls
// back to that config key on its own.
$middleware->web(append: [
// Binds every session to the password hash it was created under,
// so changing a password (or a reset) actually terminates the
// account's other sessions instead of leaving a stolen one live.
// Required for Auth::logoutOtherDevices() to have any effect.
AuthenticateSession::class,
EnsureSetupIsComplete::class,
EnsureAccountIsActive::class,
EnforceTwoFactor::class,
SetLocale::class,
HandleInertiaRequests::class,
// Deliberately NOT here: AddLinkHeadersForPreloadedAssets. It
// copies every Vite preload into a `Link:` response header,
// and the head of the document already carries the identical
// tags — twenty of them on the login page, more on a heavier
// one. The copy is what a browser never reads and a proxy has
// to buffer: it pushed /files past 6 KB of headers, where the
// 4 KB proxy_buffer_size that nginx, and therefore Nginx Proxy
// Manager, defaults to answers 502. Some pages fit and some do
// not, so it reads as an intermittent fault rather than a
// header that is always too big (#1664). Nothing is lost but
// 103 Early Hints, which this application does not send.
]);
// The API group gets none of the web stack above — no session, no
// CSRF, no Inertia. Locale is the one thing worth carrying over,
// since validation messages are written for a human to read; the
// web SetLocale can't be reused because it reads the session.
$middleware->api(append: [
SetApiLocale::class,
// Applied to the group rather than per route, so an endpoint
// added later is measured without anyone opting in.
RecordApiRequest::class,
]);
$middleware->throttleApi();
$middleware->validateCsrfTokens(except: [
'uploads/*/parts/*',
]);
// Swapped for the subclass only to name the CSRF cookie after this
// installation rather than after the framework — see that class for
// what sharing `XSRF-TOKEN` with a neighbouring app does.
//
// `web(replace:)` rather than the bare `replace()`: the latter only
// reaches the global stack, and CSRF lives in the web group, so it
// silently does nothing here.
$middleware->web(replace: [
Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class => ValidateCsrfToken::class,
]);
$middleware->alias([
'capability' => EnsureCapability::class,
'staff' => EnsureStaff::class,
'staff-token' => EnsureStaffToken::class,
'token-can' => EnsureTokenCan::class,
'api-active' => EnsureApiAccountIsActive::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
// Every credential this application stores encrypted, named again
// here so a failed validation does not write it back out in clear.
//
// A ValidationException flashes the request's input into the
// session so the form can be repopulated, minus this list. The
// framework's own three cover the login and password forms; none
// of the settings screens' credentials were on it, and
// config/session.php stores sessions in the database by default
// with `encrypt => false`. So a mistyped storage form put the
// secret access key in clear into the same database whose dump the
// `encrypted` cast exists to survive — and a service account key
// file, which is most likely to fail validation exactly when it
// was pasted incompletely, put a private key there.
//
// Merged with the framework's defaults rather than replacing them.
// The cost is that these fields come back blank after a failed
// save, which is what every one of these screens already does on a
// successful one: they are write-only, and a blank means "keep
// what is stored".
$exceptions->dontFlash([
'secret', // ExternalStorageSettingsController (S3)
'key_file', // ExternalStorageSettingsController (GCS)
'bind_password', // LdapSettingsController
'client_secret', // SocialLoginSettingsController, EmailSettingsController
'secret_key', // CaptchaSettingsController
]);
// RFC 7807 for /api/* only. Everything else — web pages, Inertia
// requests, the public share links — keeps Laravel's own handling
// untouched, which is why this is scoped by path rather than by
// whether the request happens to accept JSON (Inertia requests do).
$exceptions->render(function (Throwable $e, Request $request) {
$problems = app(ProblemDetails::class);
return $problems->shouldHandle($request)
? $problems->render($request, $e)
: null;
});
// A redirect born in exception handling — the guest redirect after
// an expired login, above all — never travels back through the
// middleware stack, so Inertia's usual 302→303 upgrade cannot reach
// it. WriteSafeRedirect explains why that matters and holds the
// rule; the same three middleware that answer before Inertia's is
// reached apply it too.
$exceptions->respond(
fn (SymfonyResponse $response, Throwable $e, Request $request): SymfonyResponse => WriteSafeRedirect::apply($request, $response)
);
})->create();