Skip to content

Commit 0b97220

Browse files
committed
feat: add CSRF protection for authenticated endpoints
- Implemented CSRF token generation and validation for state-changing operations - Added CSRF middleware to protected routes (logout, comments, admin operations) - Restricted comment creation and deletion to admin users only in UI
1 parent ae125f9 commit 0b97220

6 files changed

Lines changed: 173 additions & 106 deletions

File tree

backend/src/handlers/auth.rs

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{auth, db::DbPool, models::*};
1+
use crate::{auth, csrf, db::DbPool, models::*};
22
use axum::{
33
extract::State,
44
http::{HeaderMap, StatusCode},
@@ -39,6 +39,20 @@ fn parse_rfc3339_opt(value: &Option<String>) -> Option<DateTime<Utc>> {
3939
value.as_ref().and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(timestamp).ok()).map(|dt| dt.with_timezone(&Utc))
4040
}
4141

42+
fn dummy_bcrypt_hash() -> &'static str {
43+
static DUMMY_HASH: OnceLock<String> = OnceLock::new();
44+
45+
DUMMY_HASH.get_or_init(|| {
46+
match bcrypt::hash("dummy", bcrypt::DEFAULT_COST) {
47+
Ok(hash) => hash,
48+
Err(err) => {
49+
tracing::error!("Failed to generate dummy hash: {}", err);
50+
"$2b$12$eImiTXuWVxfM37uY4JANjQPzMzXZjQDzqzQpMv0xoGrTplPPNaE3W".to_string()
51+
}
52+
}
53+
})
54+
}
55+
4256
// Input validation
4357
fn validate_username(username: &str) -> Result<(), String> {
4458
if username.is_empty() {
@@ -158,17 +172,8 @@ pub async fn login(
158172
// Timing attack prevention: Always verify password even if user doesn't exist
159173
// Use a dummy hash that matches DEFAULT_COST to ensure consistent timing
160174
// This hash was generated with bcrypt::DEFAULT_COST for the password "dummy"
161-
let dummy_hash = match bcrypt::hash("dummy", bcrypt::DEFAULT_COST) {
162-
Ok(hash) => hash,
163-
Err(err) => {
164-
tracing::error!("Failed to generate dummy hash: {}", err);
165-
// Fallback to cost-12 dummy hash to avoid panicking
166-
"$2b$12$eImiTXuWVxfM37uY4JANjQPzMzXZjQDzqzQpMv0xoGrTplPPNaE3W".to_string()
167-
}
168-
};
169-
170-
let hash_to_verify_owned = user.as_ref().map(|u| u.password_hash.clone()).unwrap_or(dummy_hash);
171-
let hash_to_verify = hash_to_verify_owned.as_str();
175+
let hash_to_verify_owned = user.as_ref().map(|u| u.password_hash.clone());
176+
let hash_to_verify = hash_to_verify_owned.as_deref().unwrap_or(dummy_bcrypt_hash());
172177

173178
// Always perform verification regardless of whether user exists
174179
let verification_result = bcrypt::verify(&payload.password, hash_to_verify);
@@ -254,6 +259,18 @@ pub async fn login(
254259
let mut headers = HeaderMap::new();
255260
auth::append_auth_cookie(&mut headers, auth::build_auth_cookie(&token));
256261

262+
if let Ok(csrf_token) = csrf::issue_csrf_token(&user_record.username) {
263+
csrf::append_csrf_cookie(&mut headers, &csrf_token);
264+
} else {
265+
tracing::error!("Failed to issue CSRF token for user {}", user_record.username);
266+
return Err((
267+
StatusCode::INTERNAL_SERVER_ERROR,
268+
Json(ErrorResponse {
269+
error: "Failed to create token".to_string(),
270+
}),
271+
));
272+
}
273+
257274
Ok((
258275
headers,
259276
Json(LoginResponse {
@@ -291,8 +308,12 @@ pub async fn me(
291308
/// # Returns
292309
///
293310
/// An HTTP `204 No Content` response with a `Set-Cookie` header to clear the auth cookie.
294-
pub async fn logout() -> (StatusCode, HeaderMap) {
311+
pub async fn logout(
312+
claims: auth::Claims,
313+
) -> (StatusCode, HeaderMap) {
295314
let mut headers = HeaderMap::new();
296315
auth::append_auth_cookie(&mut headers, auth::build_cookie_removal());
316+
csrf::append_csrf_removal(&mut headers);
317+
tracing::info!(user = %claims.sub, "User logged out");
297318
(StatusCode::NO_CONTENT, headers)
298319
}

backend/src/main.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod auth;
2+
mod csrf;
23
mod db;
34
mod handlers;
45
mod models;
@@ -265,6 +266,10 @@ async fn main() {
265266
auth::init_jwt_secret().expect("Failed to initialize JWT secret");
266267
tracing::info!("JWT secret initialized successfully");
267268

269+
// Initialize CSRF secret
270+
csrf::init_csrf_secret().expect("Failed to initialize CSRF secret");
271+
tracing::info!("CSRF secret initialized successfully");
272+
268273
// Initialize database
269274
let pool = db::create_pool().await.expect("Failed to create database pool");
270275

@@ -322,7 +327,12 @@ async fn main() {
322327
// Login route with rate limiting
323328
let login_router = Router::new()
324329
.route("/api/auth/login", post(handlers::auth::login))
325-
.route("/api/auth/logout", post(handlers::auth::logout))
330+
.route(
331+
"/api/auth/logout",
332+
post(handlers::auth::logout)
333+
.layer(middleware::from_extractor::<csrf::CsrfGuard>())
334+
.layer(middleware::from_extractor::<auth::Claims>()),
335+
)
326336
.layer(RequestBodyLimitLayer::new(LOGIN_BODY_LIMIT))
327337
.layer(GovernorLayer::new(rate_limit_config));
328338

@@ -381,6 +391,7 @@ async fn main() {
381391
"/api/comments/{id}",
382392
delete(handlers::comments::delete_comment),
383393
)
394+
.route_layer(middleware::from_extractor::<csrf::CsrfGuard>())
384395
.route_layer(middleware::from_extractor::<auth::Claims>())
385396
.layer(RequestBodyLimitLayer::new(ADMIN_BODY_LIMIT))
386397
.layer(GovernorLayer::new(admin_rate_limit_config.clone()));

src/api/client.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ const isBinaryBody = (body) => {
7070
return false
7171
}
7272

73+
const CSRF_COOKIE_NAME = 'ltcms_csrf'
74+
const CSRF_HEADER_NAME = 'x-csrf-token'
75+
76+
const getCsrfToken = () => {
77+
if (typeof document === 'undefined' || !document.cookie) {
78+
return null
79+
}
80+
81+
return document.cookie
82+
.split(';')
83+
.map((cookie) => cookie.trim())
84+
.find((cookie) => cookie.startsWith(`${CSRF_COOKIE_NAME}=`))
85+
?.split('=')[1] ?? null
86+
}
87+
7388
/**
7489
* Manages all communication with the backend API.
7590
* Provides methods for authentication, tutorials, site content, and more.
@@ -222,6 +237,14 @@ class ApiClient {
222237
config.body = JSON.stringify(bodyCandidate)
223238
}
224239

240+
const requiresCsrf = !['GET', 'HEAD', 'OPTIONS'].includes(method)
241+
if (requiresCsrf && !headers.has(CSRF_HEADER_NAME)) {
242+
const csrfToken = getCsrfToken()
243+
if (csrfToken) {
244+
headers.set(CSRF_HEADER_NAME, csrfToken)
245+
}
246+
}
247+
225248
try {
226249
const response = await fetch(url, config)
227250

src/components/Comments.jsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const Comments = ({ tutorialId }) => {
1616
const [comments, setComments] = useState([]);
1717
const [newComment, setNewComment] = useState('');
1818
const [isLoading, setIsLoading] = useState(false);
19-
const { isAuthenticated } = useAuth();
19+
const { isAuthenticated, user } = useAuth();
20+
const isAdmin = Boolean(user && user.role === 'admin');
2021

2122
useEffect(() => {
2223
loadComments();
@@ -34,7 +35,7 @@ const Comments = ({ tutorialId }) => {
3435

3536
const handleSubmit = async (e) => {
3637
e.preventDefault();
37-
if (!newComment.trim() || !isAuthenticated) return;
38+
if (!newComment.trim() || !isAuthenticated || !isAdmin) return;
3839

3940
setIsLoading(true);
4041
try {
@@ -49,6 +50,7 @@ const Comments = ({ tutorialId }) => {
4950
};
5051

5152
const handleDelete = async (commentId) => {
53+
if (!isAdmin) return;
5254
if (!confirm('Kommentar wirklich löschen?')) return;
5355

5456
try {
@@ -67,7 +69,7 @@ const Comments = ({ tutorialId }) => {
6769
</h2>
6870

6971
{/* Comment Form */}
70-
{isAuthenticated && (
72+
{isAdmin && (
7173
<form onSubmit={handleSubmit} className="mb-8">
7274
<textarea
7375
value={newComment}
@@ -101,6 +103,14 @@ const Comments = ({ tutorialId }) => {
101103
</div>
102104
)}
103105

106+
{isAuthenticated && !isAdmin && (
107+
<div className="mb-8 p-4 bg-gray-50 dark:bg-gray-800 rounded-xl text-center">
108+
<p className="text-gray-600 dark:text-gray-400">
109+
Nur Administratoren können Kommentare hinzufügen oder löschen.
110+
</p>
111+
</div>
112+
)}
113+
104114
{/* Comments List */}
105115
<div className="space-y-4">
106116
{comments.length === 0 ? (
@@ -122,7 +132,7 @@ const Comments = ({ tutorialId }) => {
122132
{new Date(comment.created_at).toLocaleDateString('de-DE')}
123133
</span>
124134
</div>
125-
{isAuthenticated && (
135+
{isAdmin && (
126136
<button
127137
onClick={() => handleDelete(comment.id)}
128138
className="p-1 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"

0 commit comments

Comments
 (0)