Skip to content

Commit e238395

Browse files
committed
Allow requests to the compat login endpoint without a Content-Type header
Fixes #4340
1 parent 6469dbc commit e238395

File tree

2 files changed

+160
-37
lines changed

2 files changed

+160
-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: 122 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,112 @@ 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("Body is not a valid JSON document")]
57+
NotJson(#[source] serde_json::Error),
58+
59+
#[error("Request body has invalid parameters")]
60+
BadJson(#[source] serde_json::Error),
61+
}
62+
63+
impl IntoResponse for MatrixJsonBodyRejection {
64+
fn into_response(self) -> axum::response::Response {
65+
let event_id = sentry::capture_error(&self);
66+
let response = match self {
67+
Self::InvalidContentType | Self::ContentTypeNotJson(_) => MatrixError {
68+
errcode: "M_NOT_JSON",
69+
error: "Invalid Content-Type header: expected application/json",
70+
status: StatusCode::BAD_REQUEST,
71+
},
72+
73+
Self::BytesRejection(BytesRejection::FailedToBufferBody(
74+
FailedToBufferBody::LengthLimitError(_),
75+
)) => MatrixError {
76+
errcode: "M_TOO_LARGE",
77+
error: "Request body too large",
78+
status: StatusCode::PAYLOAD_TOO_LARGE,
79+
},
80+
81+
Self::BytesRejection(BytesRejection::FailedToBufferBody(
82+
FailedToBufferBody::UnknownBodyError(_),
83+
)) => MatrixError {
84+
errcode: "M_UNKNOWN",
85+
error: "Failed to read request body",
86+
status: StatusCode::BAD_REQUEST,
87+
},
88+
89+
Self::BytesRejection(_) => MatrixError {
90+
errcode: "M_UNKNOWN",
91+
error: "Unknown error while reading request body",
92+
status: StatusCode::BAD_REQUEST,
93+
},
94+
95+
Self::NotJson(_) => MatrixError {
96+
errcode: "M_NOT_JSON",
97+
error: "Body is not a valid JSON document",
98+
status: StatusCode::BAD_REQUEST,
99+
},
100+
101+
Self::BadJson(_) => MatrixError {
102+
errcode: "M_BAD_JSON",
103+
error: "JSON fields are not valid",
104+
status: StatusCode::BAD_REQUEST,
105+
},
106+
};
107+
108+
(SentryEventID::from(event_id), response).into_response()
109+
}
110+
}
111+
112+
impl<T, S> axum::extract::FromRequest<S> for MatrixJsonBody<T>
113+
where
114+
T: DeserializeOwned,
115+
S: Send + Sync,
116+
{
117+
type Rejection = MatrixJsonBodyRejection;
118+
119+
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
120+
// Matrix spec says it's optional to send a Content-Type header, so we
121+
// only check it if it's present
122+
if let Some(content_type) = req.headers().get(header::CONTENT_TYPE) {
123+
let Ok(content_type) = content_type.to_str() else {
124+
return Err(MatrixJsonBodyRejection::InvalidContentType);
125+
};
126+
127+
let Ok(mime) = content_type.parse::<mime::Mime>() else {
128+
return Err(MatrixJsonBodyRejection::InvalidContentType);
129+
};
130+
131+
let is_json_content_type = mime.type_() == "application"
132+
&& (mime.subtype() == "json" || mime.suffix().is_some_and(|name| name == "json"));
133+
134+
if !is_json_content_type {
135+
return Err(MatrixJsonBodyRejection::ContentTypeNotJson(mime));
136+
}
137+
}
138+
139+
let bytes = Bytes::from_request(req, state).await?;
140+
141+
// We first parse it as a JSON value so that we can distinguish between
142+
// invalid JSON documents and invalid requests
143+
let value: serde_json::Value =
144+
serde_json::from_slice(&bytes).map_err(MatrixJsonBodyRejection::NotJson)?;
145+
let value = serde_json::from_value(value).map_err(MatrixJsonBodyRejection::BadJson)?;
146+
Ok(Self(value))
147+
}
148+
}

0 commit comments

Comments
 (0)