actix-csrf-middleware 0.6.0

CSRF protection middleware for Actix Web applications. Supports double submit cookie and synchronizer token patterns (with actix-session) out of the box. Flexible, easy to configure, and includes test coverage for common attacks and edge cases.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
mod common;

use common::*;

use actix_csrf_middleware::{
    generate_random_token, CsrfDoubleSubmitCookie, CsrfMiddlewareConfig, CsrfPattern,
    CSRF_PRE_SESSION_KEY, DEFAULT_CSRF_ANON_TOKEN_KEY, DEFAULT_CSRF_TOKEN_FIELD,
    DEFAULT_CSRF_TOKEN_HEADER, DEFAULT_CSRF_TOKEN_KEY, DEFAULT_SESSION_ID_KEY,
};
use actix_http::body::{BoxBody, EitherBody};
use actix_http::{Request, StatusCode};
use actix_web::cookie::{time, Cookie, SameSite};
use actix_web::dev::{Service, ServiceResponse};
use actix_web::http::header::ContentType;
use actix_web::test;
use hmac::{KeyInit, Mac};

fn get_secret_key() -> Vec<u8> {
    b"super-secret-super-secret-super-secret-xx".to_vec()
}

pub async fn token_cookie<S>(
    app: &S,
    session_id_cookie_name: Option<&str>,
    token_cookie_name: Option<&str>,
) -> (String, Cookie<'static>, Cookie<'static>)
where
    S: Service<Request, Response = ServiceResponse<EitherBody<BoxBody>>, Error = actix_web::Error>,
{
    let req = test::TestRequest::get().uri("/form").to_request();
    let resp = test::call_service(&app, req).await;

    let session_id_cookie_name = if let Some(session_id) = session_id_cookie_name {
        session_id
    } else {
        DEFAULT_SESSION_ID_KEY
    };

    let session_id_cookie = resp
        .response()
        .cookies()
        .find(|c| c.name() == session_id_cookie_name || c.name() == CSRF_PRE_SESSION_KEY)
        .map(|c| c.into_owned())
        .unwrap();

    // Prefer authorized cookie name; if not present (anon flow), fall back to anon cookie name
    let token_cookie = if let Some(name) = token_cookie_name {
        resp.response()
            .cookies()
            .find(|c| c.name() == name)
            .or_else(|| {
                resp.response().cookies().find(|c| {
                    c.name() == DEFAULT_CSRF_TOKEN_KEY || c.name() == DEFAULT_CSRF_ANON_TOKEN_KEY
                })
            })
            .map(|c| c.into_owned())
            .unwrap()
    } else {
        resp.response()
            .cookies()
            .find(|c| c.name() == DEFAULT_CSRF_TOKEN_KEY || c.name() == DEFAULT_CSRF_ANON_TOKEN_KEY)
            .map(|c| c.into_owned())
            .unwrap()
    };

    let body = test::read_body(resp).await;
    let token = String::from_utf8(body.to_vec()).unwrap();
    let token = token.strip_prefix("token:").unwrap().to_string();

    (token, token_cookie, session_id_cookie)
}

#[actix_web::test]
async fn test_double_submit_cookie_behavior() {
    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;
    let (initial_token, initial_token_cookie, session_id_cookie) =
        token_cookie(&app, None, None).await;

    // Verify no token change on a non-mutating GET
    let req = test::TestRequest::get()
        .uri("/form")
        .cookie(initial_token_cookie.clone())
        .cookie(session_id_cookie.clone())
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());

    // Check that the cookie wasn't changed in the response
    let token_cookie_after_get = resp
        .response()
        .cookies()
        .find(|c| c.name() == DEFAULT_CSRF_TOKEN_KEY);

    // If there's no new cookie set, the token remains the same
    assert!(
        token_cookie_after_get.is_none(),
        "Token cookie should not be set on GET request when token already exists"
    );

    // Verify token change on a mutating POST
    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header((DEFAULT_CSRF_TOKEN_HEADER, initial_token.clone()))
        .cookie(initial_token_cookie)
        .cookie(session_id_cookie)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::OK);

    // Check that a new cookie was set in the response after mutation
    let new_token_cookie = resp
        .response()
        .cookies()
        .find(|c| c.name() == DEFAULT_CSRF_TOKEN_KEY || c.name() == DEFAULT_CSRF_ANON_TOKEN_KEY)
        .expect("New token cookie should be set after POST mutation");

    let new_token = new_token_cookie.value();
    assert_ne!(
        initial_token, new_token,
        "Token should refresh on POST mutation"
    );
}

#[actix_web::test]
async fn double_submit_cookie_token_rotation() {
    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;
    let (initial_token, initial_token_cookie, initial_session_cookie) =
        token_cookie(&app, None, None).await;

    // Perform a POST request with the initial token
    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header((DEFAULT_CSRF_TOKEN_HEADER, initial_token.clone()))
        .cookie(initial_token_cookie)
        .cookie(initial_session_cookie)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::OK);

    // Get a new token after the mutation
    let (new_token, _new_token_cookie, _new_session_id_cookie) =
        token_cookie(&app, None, None).await;
    assert_ne!(
        initial_token, new_token,
        "CSRF token should be refreshed after mutation"
    );

    // Verify the new token is still in correct HMAC format
    assert!(
        new_token.contains('.'),
        "New token should maintain HMAC format"
    );
    let parts: Vec<&str> = new_token.split('.').collect();
    assert_eq!(
        parts.len(),
        2,
        "New token should have HMAC format with 2 parts"
    );
}

#[actix_web::test]
async fn instant_rotation_on_login() {
    use actix_web::cookie::{time, Cookie};

    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;

    // Initial anonymous token
    let (_anon_token, anon_token_cookie, pre_session_cookie) = token_cookie(&app, None, None).await;

    // Simulate login by sending a GET with a fresh session id cookie while still having anon cookie
    let session_cookie = Cookie::build(DEFAULT_SESSION_ID_KEY, "SID-123").finish();

    let req = test::TestRequest::get()
        .uri("/form")
        .cookie(anon_token_cookie.clone())
        .cookie(pre_session_cookie.clone())
        .cookie(session_cookie)
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());

    // Expect pre-session to be expired and a new CSRF cookie to be set
    let mut saw_expired_pre_session = false;
    let mut saw_new_auth = false;

    for c in resp.response().cookies() {
        if c.name() == CSRF_PRE_SESSION_KEY {
            // Should be expired (Max-Age=0)
            if let Some(ma) = c.max_age() {
                assert_eq!(
                    ma,
                    time::Duration::seconds(0),
                    "pre-session cookie should be expired"
                );
                saw_expired_pre_session = true;
            }
        }

        if c.name() == DEFAULT_CSRF_TOKEN_KEY {
            assert!(!c.value().is_empty(), "new authorized token must be set");
            saw_new_auth = true;
        }
    }

    assert!(
        saw_expired_pre_session,
        "Expected pre-session cookie to be expired after login"
    );
    assert!(saw_new_auth, "Expected new CSRF token after login");
}

#[actix_web::test]
async fn authorized_endpoint_rejects_anon_token() {
    use actix_web::cookie::Cookie;

    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;

    // Anonymous context first: get anon token and cookies
    let (anon_token, anon_token_cookie, pre_session_cookie) = token_cookie(&app, None, None).await;

    // Now simulate an authorized POST using the anon token (should be rejected)
    let session_cookie = Cookie::build(DEFAULT_SESSION_ID_KEY, "SID-456").finish();

    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header((DEFAULT_CSRF_TOKEN_HEADER, anon_token))
        .cookie(anon_token_cookie)
        .cookie(pre_session_cookie)
        .cookie(session_cookie)
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert_eq!(
        resp.status(),
        StatusCode::BAD_REQUEST,
        "anon token must not be accepted on authorized endpoints"
    );
}

#[actix_web::test]
async fn token_refresh_on_successful_mutation() {
    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;
    let (token1, token1_cookie, session1_cookie) = { token_cookie(&app, None, None).await };

    // POST with valid token
    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header((DEFAULT_CSRF_TOKEN_HEADER, token1.clone()))
        .cookie(token1_cookie)
        .cookie(session1_cookie)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::OK);

    // GET new token (should be refreshed)
    let (new_token, _token_cookie, _session_id_cookie) = { token_cookie(&app, None, None).await };
    assert_ne!(token1, new_token, "Token should be refreshed after POST");
}

#[actix_web::test]
async fn custom_config_header_name() {
    const HEADER_NAME: &str = "custom-header";

    let cfg = CsrfMiddlewareConfig {
        pattern: CsrfPattern::DoubleSubmitCookie,
        manual_multipart: false,
        session_id_cookie_name: DEFAULT_SESSION_ID_KEY.to_string(),
        token_cookie_name: DEFAULT_CSRF_TOKEN_KEY.to_string(),
        anon_token_cookie_name: DEFAULT_CSRF_ANON_TOKEN_KEY.to_string(),
        token_form_field: "myfield".to_string(),
        token_header_name: HEADER_NAME.to_string(),
        #[cfg(feature = "actix-session")]
        anon_session_key_name: format!("{}-anon", DEFAULT_CSRF_TOKEN_KEY),
        token_cookie_config: Some(CsrfDoubleSubmitCookie {
            http_only: false,
            same_site: SameSite::Lax,
        }),
        secret_key: get_secret_key().into(),
        skip_for: vec![],
        secure: true,
        enforce_origin: false,
        allowed_origins: vec![],
        max_body_bytes: 2 * 1024 * 1024,
    };

    let app = build_app(cfg).await;
    let (token, token_cookie, session_id_cookie) = { token_cookie(&app, None, None).await };

    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header((HEADER_NAME, token))
        .cookie(token_cookie)
        .cookie(session_id_cookie)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());
}

#[actix_web::test]
async fn custom_config_cookie_name() {
    const COOKIE_NAME: &str = "custom-cookie";

    let cfg = CsrfMiddlewareConfig {
        pattern: CsrfPattern::DoubleSubmitCookie,
        manual_multipart: false,
        session_id_cookie_name: DEFAULT_SESSION_ID_KEY.to_string(),
        token_cookie_name: COOKIE_NAME.to_string(),
        anon_token_cookie_name: DEFAULT_CSRF_ANON_TOKEN_KEY.to_string(),
        token_form_field: DEFAULT_CSRF_TOKEN_FIELD.to_string(),
        token_header_name: DEFAULT_CSRF_TOKEN_HEADER.to_string(),
        #[cfg(feature = "actix-session")]
        anon_session_key_name: format!("{}-anon", DEFAULT_CSRF_TOKEN_KEY),
        token_cookie_config: Some(CsrfDoubleSubmitCookie {
            http_only: false,
            same_site: SameSite::Lax,
        }),
        secret_key: get_secret_key().into(),
        skip_for: vec![],
        secure: true,
        enforce_origin: false,
        allowed_origins: vec![],
        max_body_bytes: 2 * 1024 * 1024,
    };
    let app = build_app(cfg).await;
    let (token, token_cookie, session_id_cookie) =
        { token_cookie(&app, None, Some(COOKIE_NAME)).await };

    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header((DEFAULT_CSRF_TOKEN_HEADER, token))
        .cookie(token_cookie)
        .cookie(session_id_cookie)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());
}

#[actix_web::test]
async fn custom_config_form_field_name() {
    const FIELD_NAME: &str = "custom-cookie";

    let cfg = CsrfMiddlewareConfig {
        pattern: CsrfPattern::DoubleSubmitCookie,
        manual_multipart: false,
        session_id_cookie_name: DEFAULT_SESSION_ID_KEY.to_string(),
        token_cookie_name: DEFAULT_CSRF_TOKEN_KEY.to_string(),
        anon_token_cookie_name: DEFAULT_CSRF_ANON_TOKEN_KEY.to_string(),
        token_form_field: FIELD_NAME.to_string(),
        token_header_name: "myheader".to_string(),
        #[cfg(feature = "actix-session")]
        anon_session_key_name: format!("{}-anon", DEFAULT_CSRF_TOKEN_KEY),
        token_cookie_config: Some(CsrfDoubleSubmitCookie {
            http_only: false,
            same_site: SameSite::Lax,
        }),
        secret_key: get_secret_key().into(),
        skip_for: vec![],
        secure: true,
        enforce_origin: false,
        allowed_origins: vec![],
        max_body_bytes: 2 * 1024 * 1024,
    };
    let app = build_app(cfg).await;
    let (token, token_cookie, session_id_cookie) = { token_cookie(&app, None, None).await };

    let form = format!("{}={}", FIELD_NAME, &token);
    let req = test::TestRequest::post()
        .uri("/submit")
        .cookie(token_cookie)
        .cookie(session_id_cookie)
        .insert_header(ContentType::form_url_encoded())
        .set_payload(form)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());
}

#[actix_web::test]
async fn handles_large_chunked_body() {
    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;
    let (token, token_cookie, session_id_cookie) = { token_cookie(&app, None, None).await };

    let large = "a".repeat(1024 * 1024);
    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header((DEFAULT_CSRF_TOKEN_HEADER, token))
        .insert_header(ContentType::form_url_encoded())
        .cookie(token_cookie)
        .cookie(session_id_cookie)
        .set_payload(large)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::OK);
}

#[actix_web::test]
async fn handles_malformed_json_body() {
    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;
    let (_token, token_cookie, session_cookie) = { token_cookie(&app, None, None).await };

    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header(ContentType::json())
        .cookie(token_cookie)
        .cookie(session_cookie)
        .set_payload("{not: valid json")
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert!(
        resp.status().is_client_error(),
        "malformed json body cannot be passed"
    )
}

#[actix_web::test]
async fn token_should_be_unforgeable() {
    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;
    let (_token, token_cookie, _session_cookie) = token_cookie(&app, None, None).await;

    let tok = generate_random_token();
    let mut mac = HmacSha256::new_from_slice(HMAC_SECRET).expect("HMAC can take key of any size");
    let message = format!("auth|HOW-TO-GET-SESSION-ID?=)|{tok}");
    mac.update(message.as_bytes());

    let hmac_hex = hex::encode(mac.finalize().into_bytes());
    let forged_token = format!("{hmac_hex}.{tok}");

    let req = test::TestRequest::post()
        .uri("/submit")
        .cookie(token_cookie)
        .insert_header((DEFAULT_CSRF_TOKEN_HEADER, forged_token))
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[actix_web::test]
async fn auth_transition_expires_anon_token_cookie() {
    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;

    let (_anon_token, anon_token_cookie, pre_session_cookie) = token_cookie(&app, None, None).await;

    let session_cookie = Cookie::build(DEFAULT_SESSION_ID_KEY, "SID-789").finish();

    let req = test::TestRequest::get()
        .uri("/form")
        .cookie(anon_token_cookie)
        .cookie(pre_session_cookie)
        .cookie(session_cookie)
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());

    let expired_pre_session = resp.response().cookies().any(|c| {
        c.name() == CSRF_PRE_SESSION_KEY && c.max_age() == Some(time::Duration::seconds(0))
    });
    let expired_anon = resp.response().cookies().any(|c| {
        c.name() == DEFAULT_CSRF_ANON_TOKEN_KEY && c.max_age() == Some(time::Duration::seconds(0))
    });

    assert!(
        expired_pre_session,
        "pre-session cookie must be expired on auth transition"
    );
    assert!(
        expired_anon,
        "anon token cookie must be expired in lockstep with pre-session on auth transition"
    );
}

#[actix_web::test]
async fn regenerates_anon_token_when_pre_session_reminted() {
    let app = build_app(CsrfMiddlewareConfig::double_submit_cookie(&get_secret_key())).await;

    let (stale_token, stale_anon_cookie, _stale_pre_session) = token_cookie(&app, None, None).await;

    // Orphaned anon token: cookie survives but its pre-session is gone. The next anonymous
    // GET mints a new pre-session id, so the anon token must be regenerated to match it.
    let req = test::TestRequest::get()
        .uri("/form")
        .cookie(stale_anon_cookie)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());

    let new_anon_cookie = resp
        .response()
        .cookies()
        .find(|c| c.name() == DEFAULT_CSRF_ANON_TOKEN_KEY)
        .map(|c| c.into_owned())
        .expect("anon token cookie must be reissued when pre-session is reminted");
    let new_pre_session = resp
        .response()
        .cookies()
        .find(|c| c.name() == CSRF_PRE_SESSION_KEY)
        .map(|c| c.into_owned())
        .expect("new pre-session cookie must be set");

    let new_token = new_anon_cookie.value().to_string();
    assert_ne!(
        stale_token, new_token,
        "anon token must be regenerated, not the stale value reused"
    );

    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header((DEFAULT_CSRF_TOKEN_HEADER, new_token))
        .cookie(new_anon_cookie)
        .cookie(new_pre_session.clone())
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "regenerated anon token must validate against the new pre-session"
    );

    let req = test::TestRequest::post()
        .uri("/submit")
        .insert_header((DEFAULT_CSRF_TOKEN_HEADER, stale_token))
        .cookie(new_pre_session)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert_eq!(
        resp.status(),
        StatusCode::BAD_REQUEST,
        "stale anon token bound to the old pre-session must be rejected against the new one"
    );
}