Skip to content

Answer redirected writes with 303 so expired sessions show the login page, not a 405 - #1680

Merged
ignacionelson merged 1 commit into
projectsend:mainfrom
denkfabrik-li:fix/303-after-exception-redirects
Aug 24, 2026
Merged

Answer redirected writes with 303 so expired sessions show the login page, not a 405#1680
ignacionelson merged 1 commit into
projectsend:mainfrom
denkfabrik-li:fix/303-after-exception-redirects

Conversation

@denkfabrik-li

Copy link
Copy Markdown
Contributor

Fixes #1673

What the reporter saw

"PUT /index.php" 302
"PUT /index.php" 405

Browsers follow a 302 by replaying the request method on the redirect target — only POST is downgraded to GET. So when a widget save (PUT /dashboard/widgets) runs into an expired session and gets redirected to the login page, the browser replays PUT /login, which only accepts GET/POST → 405, and Inertia shows the error modal instead of the login screen. The PATCH … 405 in the original report is the same failure on a PATCH-based settings save. It only strikes when a write request gets redirected, which is why it looked so random — the reporter's sessions were being killed constantly by the untrusted-proxy problem (#1672, addressed by #1674).

Why Inertia's own 303 upgrade didn't cover this

Inertia\Middleware upgrades 302→303 for PUT/PATCH/DELETE, but a redirect created during exception handling — the guest redirect after AuthenticationException, above all — never travels back through the middleware stack, so the upgrade never runs. Reproduced directly: an unauthenticated PUT /dashboard/widgets with a valid CSRF token answers 302 Location: /login.

The fix

A respond hook in bootstrap/app.php: any 302 rendered from an exception in reply to a PUT/PATCH/DELETE becomes a 303 ("see other" — follow with GET). Reads keep their 302; POST needs nothing.

Tests

  • unauthenticated PUT (widget save) → 303 to the login page (was 302, failing before the fix)
  • unauthenticated DELETE → 303 to the login page
  • unauthenticated GET → still a plain 302

Not covered on purpose

The direct (non-exception) redirects issued by EnsureSetupIsComplete, EnsureAccountIsActive and EnforceTwoFactor sit outside HandleInertiaRequests in the web group, so a write request caught by one of those can still produce the same 405 pattern (e.g. an account deactivated mid-session saving a form). Much rarer, and each wants its own decision about the right status — happy to follow up if wanted.

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" (projectsend#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.
@ignacionelson
ignacionelson merged commit 30f08fd into projectsend:main Aug 24, 2026
2 checks passed
ignacionelson added a commit that referenced this pull request Aug 24, 2026
#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.
@denkfabrik-li
denkfabrik-li deleted the fix/303-after-exception-redirects branch August 24, 2026 23:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Projectsend 2: Setting Widget Columns throws error

2 participants