feat: implement CSRF protection across forms and routes
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
{% if ctx.is_current_user(entry.review().user_id().value()) %}
|
||||
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
|
||||
<input type="hidden" name="redirect_after" value="/?offset={{ current_offset }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
{% if let Some(uid) = ctx.user_id %}
|
||||
{% if *uid == entry.review().user_id().value() %}
|
||||
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="{{ actor.url }}" target="_blank" rel="noopener noreferrer">{{ actor.url }}</a>
|
||||
<form method="POST" action="/users/{{ user_id }}/followers/remove" style="display:inline">
|
||||
<input type="hidden" name="actor_url" value="{{ actor.url }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Remove</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="{{ actor.url }}" target="_blank" rel="noopener noreferrer">{{ actor.url }}</a>
|
||||
<form method="POST" action="/users/{{ user_id }}/unfollow" style="display:inline">
|
||||
<input type="hidden" name="actor_url" value="{{ actor.url }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Unfollow</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
Password<br>
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
Comment<br>
|
||||
<textarea name="comment"></textarea>
|
||||
</label>
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Log Review</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<h3>Follow remote user</h3>
|
||||
<form method="POST" action="/users/{{ profile_user_id }}/follow">
|
||||
<input type="text" name="handle" placeholder="user@instance.example.com" required>
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Follow</button>
|
||||
</form>
|
||||
{% if let Some(err) = error %}
|
||||
@@ -47,10 +48,12 @@
|
||||
<a href="{{ actor.url }}" class="pending-url" target="_blank" rel="noopener noreferrer">{{ actor.url }}</a>
|
||||
<form method="POST" action="/users/{{ profile_user_id }}/followers/accept" class="inline-form">
|
||||
<input type="hidden" name="actor_url" value="{{ actor.url }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-accept">Accept</button>
|
||||
</form>
|
||||
<form method="POST" action="/users/{{ profile_user_id }}/followers/reject" class="inline-form">
|
||||
<input type="hidden" name="actor_url" value="{{ actor.url }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-reject">Reject</button>
|
||||
</form>
|
||||
</li>
|
||||
@@ -183,6 +186,7 @@
|
||||
{% if ctx.is_current_user(entry.review().user_id().value()) %}
|
||||
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
|
||||
<input type="hidden" name="redirect_after" value="/users/{{ profile_user_id }}?view={{ view }}&offset={{ current_offset }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
Password<br>
|
||||
<input type="password" name="password" required autocomplete="new-password">
|
||||
</label>
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct HtmlPageContext {
|
||||
pub rss_url: String,
|
||||
pub page_title: String,
|
||||
pub canonical_url: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
impl HtmlPageContext {
|
||||
|
||||
58
crates/presentation/src/csrf.rs
Normal file
58
crates/presentation/src/csrf.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderValue, header},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CsrfToken(pub String);
|
||||
|
||||
pub fn extract_from_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies
|
||||
.split(';')
|
||||
.find_map(|c| c.trim().strip_prefix("csrf=").map(str::to_string))
|
||||
})
|
||||
}
|
||||
|
||||
fn secure_flag() -> &'static str {
|
||||
if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn csrf_middleware(mut req: Request, next: Next) -> Response {
|
||||
let existing = extract_from_cookie(req.headers());
|
||||
let (token, needs_set) = match existing {
|
||||
Some(t) => (t, false),
|
||||
None => (uuid::Uuid::new_v4().to_string(), true),
|
||||
};
|
||||
|
||||
req.extensions_mut().insert(CsrfToken(token.clone()));
|
||||
|
||||
let mut response = next.run(req).await;
|
||||
|
||||
if needs_set {
|
||||
let cookie = format!(
|
||||
"csrf={}; HttpOnly; Path=/; SameSite=Strict{}",
|
||||
token,
|
||||
secure_flag()
|
||||
);
|
||||
if let Ok(val) = HeaderValue::from_str(&cookie) {
|
||||
response.headers_mut().append(header::SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Returns true if the form token does not match the cookie token.
|
||||
pub fn mismatch(token: &CsrfToken, form_value: &str) -> bool {
|
||||
token.0 != form_value || form_value.is_empty()
|
||||
}
|
||||
@@ -41,12 +41,16 @@ pub struct LogReviewForm {
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -54,6 +58,8 @@ pub struct RegisterForm {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -65,6 +71,8 @@ pub struct ErrorQuery {
|
||||
pub struct DeleteRedirectForm {
|
||||
#[serde(default)]
|
||||
pub redirect_after: Option<String>,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
@@ -240,16 +248,22 @@ impl From<DiaryQueryParams> for GetDiaryQuery {
|
||||
#[derive(Deserialize)]
|
||||
pub struct FollowForm {
|
||||
pub handle: String,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UnfollowForm {
|
||||
pub actor_url: String,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FollowerActionForm {
|
||||
pub actor_url: String,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
@@ -410,6 +424,7 @@ mod tests {
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: watched_at.to_string(),
|
||||
csrf_token: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ pub mod html {
|
||||
|
||||
use axum::{
|
||||
Form,
|
||||
extract::{Path, Query, State},
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::{HeaderValue, StatusCode, header::SET_COOKIE},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
};
|
||||
@@ -28,6 +28,7 @@ pub mod html {
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
dtos::{
|
||||
DiaryQueryParams, ErrorQuery, FollowForm, FollowerActionForm, LogReviewData,
|
||||
LogReviewForm, LoginForm, RegisterForm, UnfollowForm,
|
||||
@@ -36,7 +37,11 @@ pub mod html {
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
async fn build_page_context(state: &AppState, user_id: Option<UserId>) -> HtmlPageContext {
|
||||
async fn build_page_context(
|
||||
state: &AppState,
|
||||
user_id: Option<UserId>,
|
||||
csrf_token: String,
|
||||
) -> HtmlPageContext {
|
||||
let uuid = user_id.as_ref().map(|u| u.value());
|
||||
let user_email = if let Some(ref id) = user_id {
|
||||
state
|
||||
@@ -57,6 +62,7 @@ pub mod html {
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Movies Diary".to_string(),
|
||||
canonical_url: state.app_ctx.config.base_url.clone(),
|
||||
csrf_token,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +95,7 @@ pub mod html {
|
||||
pub async fn get_login_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = HtmlPageContext {
|
||||
user_email: None,
|
||||
@@ -97,6 +104,7 @@ pub mod html {
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Login — Movies Diary".to_string(),
|
||||
canonical_url: format!("{}/login", state.app_ctx.config.base_url),
|
||||
csrf_token: csrf.0,
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
@@ -110,8 +118,12 @@ pub mod html {
|
||||
|
||||
pub async fn post_login(
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match login_uc::execute(
|
||||
&state.app_ctx,
|
||||
LoginCommand {
|
||||
@@ -145,6 +157,7 @@ pub mod html {
|
||||
pub async fn get_register_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
if !state.app_ctx.config.allow_registration {
|
||||
return Redirect::to("/").into_response();
|
||||
@@ -156,6 +169,7 @@ pub mod html {
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Register — Movies Diary".to_string(),
|
||||
canonical_url: format!("{}/register", state.app_ctx.config.base_url),
|
||||
csrf_token: csrf.0,
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
@@ -169,11 +183,15 @@ pub mod html {
|
||||
|
||||
pub async fn post_register(
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<RegisterForm>,
|
||||
) -> impl IntoResponse {
|
||||
if !state.app_ctx.config.allow_registration {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let email = form.email.clone();
|
||||
let password = form.password.clone();
|
||||
match register_uc::execute(
|
||||
@@ -205,8 +223,9 @@ pub mod html {
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, Some(user_id)).await;
|
||||
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
ctx.page_title = "Log a Review — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url);
|
||||
let html = state
|
||||
@@ -222,8 +241,12 @@ pub mod html {
|
||||
pub async fn post_review(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<LogReviewForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let data = match LogReviewData::try_from(form) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
@@ -243,9 +266,13 @@ pub mod html {
|
||||
pub async fn post_delete_review(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Path(review_id): Path<Uuid>,
|
||||
Form(form): Form<crate::dtos::DeleteRedirectForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
@@ -312,8 +339,9 @@ pub mod html {
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DiaryQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id).await;
|
||||
let ctx = build_page_context(&state, user_id, csrf.0).await;
|
||||
let query = application::queries::GetActivityFeedQuery {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
@@ -342,8 +370,9 @@ pub mod html {
|
||||
pub async fn get_users_list(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, user_id).await;
|
||||
let mut ctx = build_page_context(&state, user_id, csrf.0).await;
|
||||
ctx.page_title = "Members — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
|
||||
match application::use_cases::get_users::execute(
|
||||
@@ -369,6 +398,7 @@ pub mod html {
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<crate::dtos::ProfileQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
// Content negotiation: AP clients request application/activity+json
|
||||
let accept = headers
|
||||
@@ -393,7 +423,7 @@ pub mod html {
|
||||
};
|
||||
}
|
||||
|
||||
let mut ctx = build_page_context(&state, user_id.clone()).await;
|
||||
let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let view_str = params.view.as_deref().unwrap_or("recent");
|
||||
let profile_view = match application::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
@@ -520,11 +550,15 @@ pub mod html {
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state.ap_service.follow(user_id.value(), &form.handle).await {
|
||||
Ok(()) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
Err(e) => {
|
||||
@@ -539,11 +573,15 @@ pub mod html {
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<UnfollowForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.unfollow(user_id.value(), &form.actor_url)
|
||||
@@ -566,11 +604,15 @@ pub mod html {
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowerActionForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.accept_follower(user_id.value(), &form.actor_url)
|
||||
@@ -588,11 +630,15 @@ pub mod html {
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowerActionForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.reject_follower(user_id.value(), &form.actor_url)
|
||||
@@ -611,11 +657,12 @@ pub mod html {
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Query(params): Query<crate::dtos::ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone())).await;
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Following — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}/following-list",
|
||||
@@ -658,11 +705,12 @@ pub mod html {
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Query(params): Query<crate::dtos::ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone())).await;
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Followers — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}/followers-list",
|
||||
@@ -708,11 +756,15 @@ pub mod html {
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowerActionForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.remove_follower(user_id.value(), &form.actor_url)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod csrf;
|
||||
pub mod dtos;
|
||||
pub mod errors;
|
||||
pub mod event_handlers;
|
||||
|
||||
@@ -140,6 +140,7 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/users/{id}/feed.rss",
|
||||
routing::get(handlers::rss::get_user_feed),
|
||||
)
|
||||
.layer(middleware::from_fn(crate::csrf::csrf_middleware))
|
||||
}
|
||||
|
||||
fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
|
||||
Reference in New Issue
Block a user