max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |