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.
#1680 fixed the redirect rendered from an exception and said plainly
what it did not cover: EnsureSetupIsComplete, EnsureAccountIsActive and
EnforceTwoFactor answer before HandleInertiaRequests is ever entered, so
a response they return never unwinds through Inertia's 302 to 303
upgrade either. Same 405, reached a different way — an account
deactivated while its owner was part-way through a form, or one being
made to enrol in two-factor.
The rule now lives in one place rather than four. Three copies of "if
the method is PUT, PATCH or DELETE" is how the fourth caller gets it
wrong, and WriteSafeRedirect can carry the explanation of why 303 —
which is worth more than the three lines it replaces, because nothing
about a bare setStatusCode call says what a browser does with a 302.
PUT /timezone is the route the setup test uses: it is one of only two
writes a guest can reach and the only one that middleware does not
exempt, so the case is real rather than defensive. All three new tests
were run against the unfixed middleware and fail there.
Extends the work of @denkfabrik-li, who found the gap and wrote it down.
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-to-303 upgrade cannot reach it. Browsers
follow a 302 by replaying the request method on the redirect target
(only POST is downgraded to GET), so a widget save whose session just
died replays as PUT /login and fails with a 405 that hides the real
"please sign in again" (#1673).
Repeat the upgrade in the exception pipeline: any 302 answered to a
PUT, PATCH or DELETE becomes a 303. Reads keep their 302, POST needs
nothing - browsers already downgrade it.
The value was read with env() inside the withMiddleware closure in
bootstrap/app.php. That closure runs when the HTTP kernel is resolved,
which is before the dotenv bootstrapper reads .env — so on every web
request env() returned null for anything set in .env, and the proxy was
never trusted. It worked when the value came from a real environment
variable, which is why the Docker compose path was fine and the manual
install described in INSTALL.md, where we tell people to put it in .env,
was not. Artisan bootstraps in the other order, so a check from the
command line reported the setting as working the whole time.
Behind a TLS-terminating proxy the consequence is not subtle. Laravel
falls back to the connecting address and the plain scheme, builds every
link and redirect with http:// while the browser is on https://, and
marks the session cookie non-secure. The browser then declines to send
that cookie to what it reads as a different, less secure origin, the
session arrives empty, and the first write fails with a 419 that reads as
"your session expired" — most often on the create-your-admin form, which
is the first thing a new install submits. Afterwards each redirect leaves
and re-enters over the wrong scheme, which is the random bounce back to
the login screen people report as flakiness.
Moved to config/trustedproxy.php, the key the framework's TrustProxies
middleware already falls back to on its own. Config files load after
dotenv, so the value is there whether it comes from .env or from the
environment.
This was also the only env() read outside config/, which means
config:cache is no longer dangerous on this application.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from one report of intermittent 502s behind Nginx Proxy
Manager, all of them ours.
Stop sending the Link: preload header. AddLinkHeadersForPreloadedAssets
copied every Vite preload into a response header, duplicating tags the
document already carried in its head — twenty on the login page. nginx
buffers a response's headers into a single block defaulting to 4 KB, so
/files, at 6060 bytes of headers, was refused with "upstream sent too big
header" and the proxy answered 502. Which pages went over depended on how
many assets they loaded, which is why it read as intermittent rather than
as a header that is always too big: the login screen fitted, the
application did not. Removing it takes /files to 1247 bytes and
/dashboard from 4544 to 1247. Nothing is lost — the browser reads the
tags in the document, and we send no 103 Early Hints.
Send nginx's logs to the container's streams. supervisord captures what
each program writes to its own stdout, but nginx opens the files named in
the package's nginx.conf as soon as it reads its config, so access and
error logs went to /var/log/nginx/ inside the container. That is where
the reason for every 502 and every 403 was written, and docker logs never
showed it — so a proxy problem presented as no logs on either side, which
is exactly how it was reported.
Document the thing neither guide covered. DOCKER.md had no reverse-proxy
section at all: no mention of proxies, of 502s, or of TRUSTED_PROXIES,
which until now was explained only in a comment in the compose example.
It gains one, including that TRUSTED_PROXIES cannot cause a 502 and is
the wrong place to dig. INSTALL.md's nginx-in-front-of-Apache path gains
the proxy_* buffer settings its fastcgi_* equivalents already had.
Reported by @denkfabrik-li (#1664), who traced it to the middleware
independently, and separately by a user running Nginx Proxy Manager who
found the too-big-header line in the proxy's own log.
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.
This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.
Free software under the GNU General Public License v2, or (at your
option) any later version.