Skip to content

Commit 1e4ce8f

Browse files
authored
Allow requests to the compat login endpoint without a Content-Type header (#4369)
2 parents 6469dbc + 7f4e975 commit 1e4ce8f

File tree

2 files changed

+154
-37
lines changed

2 files changed

+154
-37
lines changed

crates/handlers/src/compat/login.rs

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,8 @@
66

77
use std::sync::{Arc, LazyLock};
88

9-
use axum::{
10-
Json,
11-
extract::{State, rejection::JsonRejection},
12-
response::IntoResponse,
13-
};
14-
use axum_extra::{extract::WithRejection, typed_header::TypedHeader};
9+
use axum::{Json, extract::State, response::IntoResponse};
10+
use axum_extra::typed_header::TypedHeader;
1511
use chrono::Duration;
1612
use hyper::StatusCode;
1713
use mas_axum_utils::sentry::SentryEventID;
@@ -34,7 +30,7 @@ use serde_with::{DurationMilliSeconds, serde_as, skip_serializing_none};
3430
use thiserror::Error;
3531
use zeroize::Zeroizing;
3632

37-
use super::MatrixError;
33+
use super::{MatrixError, MatrixJsonBody};
3834
use crate::{
3935
BoundActivityTracker, Limiter, METER, RequesterFingerprint, impl_from_error_for_route,
4036
passwords::PasswordManager, rate_limit::PasswordCheckLimitedError,
@@ -206,9 +202,6 @@ pub enum RouteError {
206202
#[error("invalid login token")]
207203
InvalidLoginToken,
208204

209-
#[error(transparent)]
210-
InvalidJsonBody(#[from] JsonRejection),
211-
212205
#[error("failed to provision device")]
213206
ProvisionDeviceFailed(#[source] anyhow::Error),
214207
}
@@ -230,26 +223,6 @@ impl IntoResponse for RouteError {
230223
error: "Too many login attempts",
231224
status: StatusCode::TOO_MANY_REQUESTS,
232225
},
233-
Self::InvalidJsonBody(JsonRejection::MissingJsonContentType(_)) => MatrixError {
234-
errcode: "M_NOT_JSON",
235-
error: "Invalid Content-Type header: expected application/json",
236-
status: StatusCode::BAD_REQUEST,
237-
},
238-
Self::InvalidJsonBody(JsonRejection::JsonSyntaxError(_)) => MatrixError {
239-
errcode: "M_NOT_JSON",
240-
error: "Body is not a valid JSON document",
241-
status: StatusCode::BAD_REQUEST,
242-
},
243-
Self::InvalidJsonBody(JsonRejection::JsonDataError(_)) => MatrixError {
244-
errcode: "M_BAD_JSON",
245-
error: "JSON fields are not valid",
246-
status: StatusCode::BAD_REQUEST,
247-
},
248-
Self::InvalidJsonBody(_) => MatrixError {
249-
errcode: "M_UNKNOWN",
250-
error: "Unknown error while parsing JSON body",
251-
status: StatusCode::BAD_REQUEST,
252-
},
253226
Self::Unsupported => MatrixError {
254227
errcode: "M_UNKNOWN",
255228
error: "Invalid login type",
@@ -300,7 +273,7 @@ pub(crate) async fn post(
300273
State(limiter): State<Limiter>,
301274
requester: RequesterFingerprint,
302275
user_agent: Option<TypedHeader<headers::UserAgent>>,
303-
WithRejection(Json(input), _): WithRejection<Json<RequestBody>, RouteError>,
276+
MatrixJsonBody(input): MatrixJsonBody<RequestBody>,
304277
) -> Result<impl IntoResponse, RouteError> {
305278
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
306279
let login_type = input.credentials.login_type();
@@ -662,12 +635,12 @@ mod tests {
662635
response.assert_status(StatusCode::BAD_REQUEST);
663636
let body: serde_json::Value = response.json();
664637

665-
insta::assert_json_snapshot!(body, @r###"
638+
insta::assert_json_snapshot!(body, @r#"
666639
{
667640
"errcode": "M_NOT_JSON",
668-
"error": "Invalid Content-Type header: expected application/json"
641+
"error": "Body is not a valid JSON document"
669642
}
670-
"###);
643+
"#);
671644

672645
// Missing keys in body
673646
let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({}));
@@ -902,6 +875,37 @@ mod tests {
902875
assert_eq!(body, old_body);
903876
}
904877

878+
/// Test that we can send a login request without a Content-Type header
879+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
880+
async fn test_no_content_type(pool: PgPool) {
881+
setup();
882+
let state = TestState::from_pool(pool).await.unwrap();
883+
884+
user_with_password(&state, "alice", "password").await;
885+
// Try without a Content-Type header
886+
let mut request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({
887+
"type": "m.login.password",
888+
"identifier": {
889+
"type": "m.id.user",
890+
"user": "alice",
891+
},
892+
"password": "password",
893+
}));
894+
request.headers_mut().remove(hyper::header::CONTENT_TYPE);
895+
896+
let response = state.request(request).await;
897+
response.assert_status(StatusCode::OK);
898+
899+
let body: serde_json::Value = response.json();
900+
insta::assert_json_snapshot!(body, @r###"
901+
{
902+
"access_token": "mct_16tugBE5Ta9LIWoSJaAEHHq2g3fx8S_alcBB4",
903+
"device_id": "ZGpSvYQqlq",
904+
"user_id": "@alice:example.com"
905+
}
906+
"###);
907+
}
908+
905909
/// Test that a user can login with a password using the Matrix
906910
/// compatibility API, using a MXID as identifier
907911
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]

crates/handlers/src/compat/mod.rs

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,19 @@
44
// SPDX-License-Identifier: AGPL-3.0-only
55
// Please see LICENSE in the repository root for full details.
66

7-
use axum::{Json, response::IntoResponse};
8-
use hyper::StatusCode;
9-
use serde::Serialize;
7+
use axum::{
8+
Json,
9+
body::Bytes,
10+
extract::{
11+
Request,
12+
rejection::{BytesRejection, FailedToBufferBody},
13+
},
14+
response::IntoResponse,
15+
};
16+
use hyper::{StatusCode, header};
17+
use mas_axum_utils::sentry::SentryEventID;
18+
use serde::{Serialize, de::DeserializeOwned};
19+
use thiserror::Error;
1020

1121
pub(crate) mod login;
1222
pub(crate) mod login_sso_complete;
@@ -27,3 +37,106 @@ impl IntoResponse for MatrixError {
2737
(self.status, Json(self)).into_response()
2838
}
2939
}
40+
41+
#[derive(Debug, Clone, Copy, Default)]
42+
#[must_use]
43+
pub struct MatrixJsonBody<T>(pub T);
44+
45+
#[derive(Debug, Error)]
46+
pub enum MatrixJsonBodyRejection {
47+
#[error("Invalid Content-Type header: expected application/json")]
48+
InvalidContentType,
49+
50+
#[error("Invalid Content-Type header: expected application/json, got {0}")]
51+
ContentTypeNotJson(mime::Mime),
52+
53+
#[error("Failed to read request body")]
54+
BytesRejection(#[from] BytesRejection),
55+
56+
#[error("Invalid JSON document")]
57+
Json(#[from] serde_json::Error),
58+
}
59+
60+
impl IntoResponse for MatrixJsonBodyRejection {
61+
fn into_response(self) -> axum::response::Response {
62+
let event_id = sentry::capture_error(&self);
63+
let response = match self {
64+
Self::InvalidContentType | Self::ContentTypeNotJson(_) => MatrixError {
65+
errcode: "M_NOT_JSON",
66+
error: "Invalid Content-Type header: expected application/json",
67+
status: StatusCode::BAD_REQUEST,
68+
},
69+
70+
Self::BytesRejection(BytesRejection::FailedToBufferBody(
71+
FailedToBufferBody::LengthLimitError(_),
72+
)) => MatrixError {
73+
errcode: "M_TOO_LARGE",
74+
error: "Request body too large",
75+
status: StatusCode::PAYLOAD_TOO_LARGE,
76+
},
77+
78+
Self::BytesRejection(BytesRejection::FailedToBufferBody(
79+
FailedToBufferBody::UnknownBodyError(_),
80+
)) => MatrixError {
81+
errcode: "M_UNKNOWN",
82+
error: "Failed to read request body",
83+
status: StatusCode::BAD_REQUEST,
84+
},
85+
86+
Self::BytesRejection(_) => MatrixError {
87+
errcode: "M_UNKNOWN",
88+
error: "Unknown error while reading request body",
89+
status: StatusCode::BAD_REQUEST,
90+
},
91+
92+
Self::Json(err) if err.is_data() => MatrixError {
93+
errcode: "M_BAD_JSON",
94+
error: "JSON fields are not valid",
95+
status: StatusCode::BAD_REQUEST,
96+
},
97+
98+
Self::Json(_) => MatrixError {
99+
errcode: "M_NOT_JSON",
100+
error: "Body is not a valid JSON document",
101+
status: StatusCode::BAD_REQUEST,
102+
},
103+
};
104+
105+
(SentryEventID::from(event_id), response).into_response()
106+
}
107+
}
108+
109+
impl<T, S> axum::extract::FromRequest<S> for MatrixJsonBody<T>
110+
where
111+
T: DeserializeOwned,
112+
S: Send + Sync,
113+
{
114+
type Rejection = MatrixJsonBodyRejection;
115+
116+
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
117+
// Matrix spec says it's optional to send a Content-Type header, so we
118+
// only check it if it's present
119+
if let Some(content_type) = req.headers().get(header::CONTENT_TYPE) {
120+
let Ok(content_type) = content_type.to_str() else {
121+
return Err(MatrixJsonBodyRejection::InvalidContentType);
122+
};
123+
124+
let Ok(mime) = content_type.parse::<mime::Mime>() else {
125+
return Err(MatrixJsonBodyRejection::InvalidContentType);
126+
};
127+
128+
let is_json_content_type = mime.type_() == "application"
129+
&& (mime.subtype() == "json" || mime.suffix().is_some_and(|name| name == "json"));
130+
131+
if !is_json_content_type {
132+
return Err(MatrixJsonBodyRejection::ContentTypeNotJson(mime));
133+
}
134+
}
135+
136+
let bytes = Bytes::from_request(req, state).await?;
137+
138+
let value: T = serde_json::from_slice(&bytes)?;
139+
140+
Ok(Self(value))
141+
}
142+
}

0 commit comments

Comments
 (0)