Skip to main content

max / makenotwork

Render the branded error page on a method mismatch Axum answers a request whose path matches a route but whose method does not with a bodiless 405, which Chrome replaces with its own network-error screen. GET /logout is the case seen in the wild. Add AppError::MethodNotAllowed and hang it off the router's method_not_allowed_fallback, registered last so it reaches every route. The route's method set is untouched: logout stays POST plus CSRF, and the test asserts the GET does not end the session.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 21:10 UTC
Signed with PGP, not checked
Commit: e2906d3c26226b1308799c85aee88d06d5f31411
Parent: aa62c05
3 files changed, +68 insertions, -0 deletions
@@ -103,6 +103,9 @@
103 103
104 104 #[error("Payment required: {0}")]
105 105 PaymentRequired(String),
106 +
107 + #[error("Method not allowed")]
108 + MethodNotAllowed,
106 109 }
107 110
108 111 impl AppError {
@@ -128,6 +131,7 @@
128 131 AppError::ServiceUnavailable(_) => "service_unavailable",
129 132 AppError::Conflict(_) => "conflict",
130 133 AppError::PaymentRequired(_) => "payment_required",
134 + AppError::MethodNotAllowed => "method_not_allowed",
131 135 }
132 136 }
133 137
@@ -148,6 +152,7 @@
148 152 AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
149 153 AppError::Conflict(_) => StatusCode::CONFLICT,
150 154 AppError::PaymentRequired(_) => StatusCode::PAYMENT_REQUIRED,
155 + AppError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
151 156 }
152 157 }
153 158
@@ -168,6 +173,9 @@
168 173 AppError::ServiceUnavailable(msg) => msg.clone(),
169 174 AppError::Conflict(msg) => msg.clone(),
170 175 AppError::PaymentRequired(msg) => msg.clone(),
176 + AppError::MethodNotAllowed => {
177 + "That address exists, but it does not accept this kind of request.".to_string()
178 + }
171 179 AppError::Database(_) | AppError::Internal(_) | AppError::Storage(_) => {
172 180 "Something went wrong. Please try again later.".to_string()
173 181 }
@@ -235,6 +243,17 @@
235 243 }
236 244 }
237 245
246 + /// Router-wide handler for a request whose path matches a route but whose
247 + /// method does not. Axum's built-in answer is a bare 405 with no body, which a
248 + /// browser renders as its own network-error page: a GET of `/logout` (typed,
249 + /// bookmarked or prefetched) showed the visitor nothing from the product. This
250 + /// renders the branded error page instead. It changes only what the mismatch
251 + /// renders; the route's own method set is untouched, so `/logout` stays
252 + /// POST-plus-CSRF.
253 + pub async fn method_not_allowed() -> Response {
254 + AppError::MethodNotAllowed.into_response()
255 + }
256 +
238 257 /// Reduce a user message to a single-line, visible-ASCII header value (header
239 258 /// values reject control bytes and non-ASCII). Truncated so a long validation
240 259 /// message can't bloat the response headers.
@@ -653,6 +653,12 @@
653 653 ))
654 654 .service(ServeDir::new("static")),
655 655 )
656 + // Last, so it reaches every route registered above: a method mismatch
657 + // on a path that does exist renders the branded error page rather than
658 + // axum's bodiless 405, which browsers replace with their own
659 + // network-error screen. It rewrites the method routers in place, so
660 + // anything added after this line would not get it.
661 + .method_not_allowed_fallback(error::method_not_allowed)
656 662 .fallback(routes::custom_domain::custom_domain_fallback)
657 663 .with_state(state.clone());
658 664
@@ -688,3 +688,46 @@
688 688 .expect("count");
689 689 assert_eq!(mailable, 1, "could not opt back in");
690 690 }
691 +
692 + /// A method mismatch on a path that exists renders the branded error page.
693 + ///
694 + /// GET /logout is the case that showed up in the wild: axum answered with a
695 + /// bodiless 405, and the browser replaced it with its own network-error screen.
696 + /// The route itself stays POST-plus-CSRF, so the GET must not log anyone out.
697 + #[tokio::test]
698 + async fn method_mismatch_renders_error_page() {
699 + let mut h = TestHarness::new().await;
700 +
701 + let user_id = h
702 + .signup("methodmix", "methodmix@example.com", "password123")
703 + .await;
704 + assert!(!user_id.is_nil());
705 +
706 + let resp = h.client.get("/logout").await;
707 + assert_eq!(resp.status, 405, "GET /logout should be 405");
708 + assert!(
709 + resp.text.contains("error-page"),
710 + "405 should render the error template, got: {}",
711 + resp.text
712 + );
713 + assert!(
714 + resp.text.contains("405"),
715 + "error page should name the status, got: {}",
716 + resp.text
717 + );
718 +
719 + // Still logged in: the 405 must not have performed the logout.
720 + let resp = h.client.get("/dashboard").await;
721 + assert_eq!(resp.status, 200, "GET /logout must not end the session");
722 +
723 + // The same holds for any other method mismatch.
724 + let resp = h.client.post_form("/login", "").await;
725 + assert_ne!(resp.status, 405, "/login accepts POST");
726 + let resp = h.client.delete("/pricing").await;
727 + assert_eq!(resp.status, 405, "DELETE /pricing should be 405");
728 + assert!(
729 + resp.text.contains("error-page"),
730 + "405 should render the error template, got: {}",
731 + resp.text
732 + );
733 + }