aris 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +251 -0
  3. data/README.md +18 -0
  4. data/docs/ADAPTERS.md +478 -0
  5. data/docs/ARCHITECTURE.md +222 -0
  6. data/docs/CONTENT.md +967 -0
  7. data/docs/PERFORMANCE.md +492 -0
  8. data/docs/PLUGIN_DEVELOPMENT.md +688 -0
  9. data/docs/USAGE.md +4998 -0
  10. data/docs/plugins/API_KEY_AUTH.md +232 -0
  11. data/docs/plugins/BASIC_AUTH.md +582 -0
  12. data/docs/plugins/BEARER_AUTH.md +394 -0
  13. data/docs/plugins/CACHE.md +369 -0
  14. data/docs/plugins/COMPRESSION.md +216 -0
  15. data/docs/plugins/COOKIES.md +30 -0
  16. data/docs/plugins/CORS.md +283 -0
  17. data/docs/plugins/CSRF.md +751 -0
  18. data/docs/plugins/ETAG.md +308 -0
  19. data/docs/plugins/FORM_PARSER.md +193 -0
  20. data/docs/plugins/HEALTH_CHECK.md +469 -0
  21. data/docs/plugins/JSON.md +291 -0
  22. data/docs/plugins/MULTIPART.md +427 -0
  23. data/docs/plugins/RATE_LIMITER.md +368 -0
  24. data/docs/plugins/REQUEST_ID.md +369 -0
  25. data/docs/plugins/REQUEST_LOGGER.md +151 -0
  26. data/docs/plugins/SECURITY.md +193 -0
  27. data/docs/plugins/SESSION.md +98 -0
  28. data/lib/aris/adapters/rack/adapter.rb +17 -2
  29. data/lib/aris/adapters/rack/request.rb +29 -11
  30. data/lib/aris/plugins/basic_auth.rb +3 -1
  31. data/lib/aris/plugins/cookies.rb +4 -32
  32. data/lib/aris/plugins/cors.rb +8 -1
  33. data/lib/aris/plugins/csrf.rb +63 -22
  34. data/lib/aris/plugins/flash.rb +3 -1
  35. data/lib/aris/plugins/form_parser.rb +52 -31
  36. data/lib/aris/plugins/multipart.rb +22 -2
  37. data/lib/aris/plugins/request_logger.rb +8 -1
  38. data/lib/aris/plugins/security_headers.rb +8 -1
  39. data/lib/aris/plugins/session.rb +150 -99
  40. data/lib/aris/response_helpers.rb +41 -0
  41. data/lib/aris/version.rb +2 -2
  42. metadata +31 -3
@@ -0,0 +1,751 @@
1
+ # CSRF Protection Plugin
2
+
3
+ Cross-Site Request Forgery (CSRF) protection using token validation. Protects form submissions and state-changing requests from malicious sites.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ # lib/aris.rb already includes this
9
+ require_relative 'aris/plugins/csrf'
10
+ ```
11
+
12
+ ## How It Works
13
+
14
+ CSRF protection is a **two-phase, session-backed system** (synchronizer token pattern):
15
+
16
+ 1. **Token Generation** (`CsrfTokenGenerator`) — makes sure the visitor's session holds a random token, and exposes it as `request.csrf_token`. The session is encrypted, so the token is never readable from the cookie itself.
17
+ 2. **Token Validation** (`CsrfProtection`) — on POST/PUT/PATCH/DELETE, compares the token the client sent (form field `_csrf` or header `X-CSRF-Token`) with the one in the session, using a constant-time comparison. Mismatch → `403`.
18
+
19
+ Both plugins are registered together under the `:csrf` symbol and execute in order. **The Session plugin must come before them, and the FormParser plugin before them if the token arrives as a form field:**
20
+
21
+ ```ruby
22
+ use: [:session, :form_parser, :csrf]
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Basic Usage
28
+
29
+ ### Simple Form Protection
30
+
31
+ ```ruby
32
+ # config/routes.rb
33
+ Aris.configure { |c| c.secret_key_base = ENV.fetch('SECRET_KEY_BASE') }
34
+
35
+ Aris.routes({
36
+ "example.com": {
37
+ use: [:session, :form_parser, :csrf],
38
+
39
+ "/form": {
40
+ get: { to: FormHandler }, # token available as request.csrf_token
41
+ post: { to: FormSubmitHandler } # validated before the handler runs
42
+ }
43
+ }
44
+ })
45
+ ```
46
+
47
+ **In your form (the GET handler renders the token):**
48
+
49
+ ```ruby
50
+ class FormHandler
51
+ def self.call(request, params)
52
+ <<~HTML
53
+ <form action="/form" method="POST">
54
+ <input type="hidden" name="_csrf" value="#{request.csrf_token}">
55
+ <input type="text" name="username">
56
+ <button type="submit">Submit</button>
57
+ </form>
58
+ HTML
59
+ end
60
+ end
61
+ ```
62
+
63
+ **In your form handler (POST):**
64
+
65
+ ```ruby
66
+ class FormSubmitHandler
67
+ def self.call(request, params)
68
+ # If we get here, the token was valid.
69
+ username = request.form_params['username']
70
+ "Form submitted successfully for #{username}!"
71
+ end
72
+ end
73
+ ```
74
+
75
+ With joys the hidden field is one line: `input type: "hidden", name: "_csrf", value: request.csrf_token`.
76
+
77
+ ---
78
+
79
+ ## AJAX/Fetch Requests
80
+
81
+ ### Using Headers (Recommended)
82
+
83
+ ```javascript
84
+ // Get token from the page (set during GET request)
85
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
86
+
87
+ // Send with every state-changing request
88
+ fetch('/api/users', {
89
+ method: 'POST',
90
+ headers: {
91
+ 'content-type': 'application/json',
92
+ 'X-CSRF-Token': csrfToken // Plugin checks this header
93
+ },
94
+ body: JSON.stringify({ name: 'Alice' })
95
+ });
96
+ ```
97
+
98
+ **In your HTML template:**
99
+
100
+ ```html
101
+ <!DOCTYPE html>
102
+ <html>
103
+ <head>
104
+ <meta name="csrf-token" content="<%= csrf_token %>">
105
+ </head>
106
+ <body>
107
+ <!-- Your app -->
108
+ </body>
109
+ </html>
110
+ ```
111
+
112
+ **Helper to get the token:**
113
+
114
+ ```ruby
115
+ class LayoutHelper
116
+ def self.csrf_token
117
+ request.csrf_token
118
+ end
119
+ end
120
+ ```
121
+
122
+ ---
123
+
124
+ ### Using Request Body
125
+
126
+ ```javascript
127
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
128
+
129
+ fetch('/api/users', {
130
+ method: 'POST',
131
+ headers: { 'content-type': 'application/json' },
132
+ body: JSON.stringify({
133
+ csrf_token: csrfToken, // Include in body
134
+ name: 'Alice'
135
+ })
136
+ });
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Single Page Applications (SPA)
142
+
143
+ ### Initial Token on Page Load
144
+
145
+ ```ruby
146
+ class AppHandler
147
+ def self.call(request, params)
148
+ token = request.csrf_token
149
+
150
+ <<~HTML
151
+ <!DOCTYPE html>
152
+ <html>
153
+ <head>
154
+ <meta name="csrf-token" content="#{token}">
155
+ </head>
156
+ <body>
157
+ <div id="app"></div>
158
+ <script>
159
+ // Make token available to your SPA
160
+ window.CSRF_TOKEN = '#{token}';
161
+ </script>
162
+ <script src="/app.js"></script>
163
+ </body>
164
+ </html>
165
+ HTML
166
+ end
167
+ end
168
+ ```
169
+
170
+ ### React Example
171
+
172
+ ```jsx
173
+ // App.js
174
+ import React, { useState, useEffect } from 'react';
175
+
176
+ function App() {
177
+ const [csrfToken] = useState(window.CSRF_TOKEN);
178
+
179
+ const handleSubmit = async (data) => {
180
+ const response = await fetch('/api/data', {
181
+ method: 'POST',
182
+ headers: {
183
+ 'content-type': 'application/json',
184
+ 'X-CSRF-Token': csrfToken
185
+ },
186
+ body: JSON.stringify(data)
187
+ });
188
+
189
+ return response.json();
190
+ };
191
+
192
+ return <YourComponent onSubmit={handleSubmit} />;
193
+ }
194
+ ```
195
+
196
+ ### Vue Example
197
+
198
+ ```javascript
199
+ // main.js
200
+ import { createApp } from 'vue';
201
+ import axios from 'axios';
202
+
203
+ // Set CSRF token globally for all axios requests
204
+ axios.defaults.headers.common['X-CSRF-Token'] = window.CSRF_TOKEN;
205
+
206
+ const app = createApp(App);
207
+ app.config.globalProperties.$http = axios;
208
+ app.mount('#app');
209
+ ```
210
+
211
+ ```vue
212
+ <!-- Component.vue -->
213
+ <script>
214
+ export default {
215
+ methods: {
216
+ async submitForm(data) {
217
+ // Token automatically included via axios defaults
218
+ const response = await this.$http.post('/api/data', data);
219
+ return response.data;
220
+ }
221
+ }
222
+ }
223
+ </script>
224
+ ```
225
+
226
+ ---
227
+
228
+ ## API-Only Routes (Skip CSRF)
229
+
230
+ APIs using Bearer tokens don't need CSRF protection:
231
+
232
+ ```ruby
233
+ bearer_auth = Aris::Plugins::BearerAuth.build(
234
+ validator: ->(token) { ApiKey.valid?(token) }
235
+ )
236
+
237
+ Aris.routes({
238
+ "example.com": {
239
+ "/web": {
240
+ use: [:csrf], # Web routes need CSRF
241
+ "/form": {
242
+ get: { to: FormHandler },
243
+ post: { to: FormSubmitHandler }
244
+ }
245
+ },
246
+
247
+ "/api": {
248
+ use: [bearer_auth], # API uses Bearer tokens, no CSRF needed
249
+ "/data": {
250
+ post: { to: ApiDataHandler }
251
+ }
252
+ }
253
+ }
254
+ })
255
+ ```
256
+
257
+ ---
258
+
259
+ ## Route-Level Control
260
+
261
+ ### Public Forms (No CSRF)
262
+
263
+ ```ruby
264
+ Aris.routes({
265
+ "example.com": {
266
+ use: [:csrf], # Domain-level CSRF
267
+
268
+ "/admin": {
269
+ # Protected forms
270
+ get: { to: AdminFormHandler },
271
+ post: { to: AdminSubmitHandler }
272
+ },
273
+
274
+ "/contact": {
275
+ use: nil, # Clear CSRF for public contact form
276
+ get: { to: ContactFormHandler },
277
+ post: { to: ContactSubmitHandler }
278
+ }
279
+ }
280
+ })
281
+ ```
282
+
283
+ **Warning:** Only disable CSRF for truly public, read-only, or idempotent operations.
284
+
285
+ ---
286
+
287
+ ## Combining with Authentication
288
+
289
+ Always put CSRF **after** authentication:
290
+
291
+ ```ruby
292
+ basic_auth = Aris::Plugins::BasicAuth.build(
293
+ username: ENV['ADMIN_USER'],
294
+ password: ENV['ADMIN_PASS']
295
+ )
296
+
297
+ Aris.routes({
298
+ "admin.example.com": {
299
+ use: [basic_auth, :csrf], # Auth first, then CSRF
300
+
301
+ "/dashboard": { get: { to: DashboardHandler } },
302
+ "/users": {
303
+ post: { to: CreateUserHandler },
304
+ delete: { to: DeleteUserHandler }
305
+ }
306
+ }
307
+ })
308
+ ```
309
+
310
+ **Why this order?**
311
+ 1. Unauthenticated requests fail at auth (fast)
312
+ 2. Authenticated requests proceed to CSRF validation
313
+ 3. Valid CSRF tokens reach the handler
314
+
315
+ ---
316
+
317
+ ## Token Storage
318
+
319
+ The token lives in the visitor's **session** under `:_csrf_token`, so it is:
320
+
321
+ - ✅ Bound to that visitor (a token from one session is rejected in another)
322
+ - ✅ Stable across requests, so multi-tab and back-button flows keep working
323
+ - ✅ Encrypted in transit inside the session cookie — never readable or forgeable client-side
324
+ - ✅ Gone when the session is destroyed (`request.session.destroy` on logout)
325
+
326
+ Read it in handlers or templates with `request.csrf_token`.
327
+
328
+ ---
329
+
330
+ ## Error Responses
331
+
332
+ **403 Forbidden (Invalid Token):**
333
+
334
+ ```
335
+ HTTP/1.1 403 Forbidden
336
+ content-type: text/plain
337
+
338
+ CSRF Token Invalid
339
+ ```
340
+
341
+ **Common causes:**
342
+ - Token not included in request
343
+ - Token mismatch (form was loaded in one session, submitted in another — e.g. the session cookie expired)
344
+ - The `:session` plugin is missing or listed after `:csrf` (the generator raises a clear error)
345
+ - Double-submit of same form
346
+
347
+ ---
348
+
349
+ ## Testing
350
+
351
+ ```ruby
352
+ # test/integration/csrf_test.rb
353
+ class CsrfIntegrationTest < Minitest::Test
354
+ def test_get_request_generates_token
355
+ Aris.routes({
356
+ "example.com": {
357
+ use: [:csrf],
358
+ "/form": { get: { to: FormHandler } }
359
+ }
360
+ })
361
+
362
+ app = Aris::Adapters::RackApp.new
363
+ env = {
364
+ 'REQUEST_METHOD' => 'GET',
365
+ 'PATH_INFO' => '/form',
366
+ 'HTTP_HOST' => 'example.com',
367
+ 'rack.input' => StringIO.new('')
368
+ }
369
+
370
+ app.call(env)
371
+
372
+ # Token should be set
373
+ token = request.csrf_token
374
+ assert token
375
+ assert token.length > 20
376
+ end
377
+
378
+ def test_post_without_token_fails
379
+ Aris.routes({
380
+ "example.com": {
381
+ use: [:csrf],
382
+ "/form": { post: { to: FormSubmitHandler } }
383
+ }
384
+ })
385
+
386
+ app = Aris::Adapters::RackApp.new
387
+ env = {
388
+ 'REQUEST_METHOD' => 'POST',
389
+ 'PATH_INFO' => '/form',
390
+ 'HTTP_HOST' => 'example.com',
391
+ 'rack.input' => StringIO.new('')
392
+ }
393
+
394
+ status, _, body = app.call(env)
395
+
396
+ assert_equal 403, status
397
+ assert_match /CSRF Token Invalid/, body.first
398
+ end
399
+
400
+ def test_post_with_valid_token_succeeds
401
+ Aris.routes({
402
+ "example.com": {
403
+ use: [:csrf],
404
+ "/form": {
405
+ get: { to: FormHandler },
406
+ post: { to: FormSubmitHandler }
407
+ }
408
+ }
409
+ })
410
+
411
+ app = Aris::Adapters::RackApp.new
412
+
413
+ # First, GET to generate token
414
+ get_env = {
415
+ 'REQUEST_METHOD' => 'GET',
416
+ 'PATH_INFO' => '/form',
417
+ 'HTTP_HOST' => 'example.com',
418
+ 'rack.input' => StringIO.new('')
419
+ }
420
+ app.call(get_env)
421
+
422
+ # Get the generated token
423
+ token = request.csrf_token
424
+
425
+ # Now POST with the token
426
+ post_env = {
427
+ 'REQUEST_METHOD' => 'POST',
428
+ 'PATH_INFO' => '/form',
429
+ 'HTTP_HOST' => 'example.com',
430
+ 'HTTP_X_CSRF_TOKEN' => token,
431
+ 'rack.input' => StringIO.new('')
432
+ }
433
+
434
+ status, _, body = app.call(post_env)
435
+
436
+ assert_equal 200, status
437
+ end
438
+ end
439
+ ```
440
+
441
+ ---
442
+
443
+ ## Production Tips
444
+
445
+ ### 1. Use Meta Tags for Token Distribution
446
+
447
+ ```html
448
+ <!DOCTYPE html>
449
+ <html>
450
+ <head>
451
+ <meta name="csrf-token" content="<%= csrf_token %>">
452
+ </head>
453
+ <body>
454
+ <!-- Your app -->
455
+ </body>
456
+ </html>
457
+ ```
458
+
459
+ ```javascript
460
+ // Global setup - runs once
461
+ const token = document.querySelector('meta[name="csrf-token"]').content;
462
+
463
+ // Use in all AJAX libraries
464
+ $.ajaxSetup({
465
+ headers: { 'X-CSRF-Token': token }
466
+ });
467
+
468
+ // Or with axios
469
+ axios.defaults.headers.common['X-CSRF-Token'] = token;
470
+
471
+ // Or with fetch wrapper
472
+ window.fetchWithCSRF = (url, options = {}) => {
473
+ options.headers = options.headers || {};
474
+ options.headers['X-CSRF-Token'] = token;
475
+ return fetch(url, options);
476
+ };
477
+ ```
478
+
479
+ ### 2. Token Rotation Strategy
480
+
481
+ For high-security applications, rotate tokens periodically:
482
+
483
+ ```ruby
484
+ class EnhancedCsrfTokenGenerator
485
+ def self.call(request, response)
486
+ if request.method == 'GET' || request.method == 'HEAD'
487
+ # Generate new token with timestamp
488
+ token = "#{SecureRandom.urlsafe_base64(32)}:#{Time.now.to_i}"
489
+ request.csrf_token = token
490
+ end
491
+ nil
492
+ end
493
+ end
494
+
495
+ class EnhancedCsrfProtection
496
+ TOKEN_EXPIRY = 3600 # 1 hour
497
+
498
+ def self.call(request, response)
499
+ return nil unless ['POST', 'PUT', 'PATCH', 'DELETE'].include?(request.method)
500
+
501
+ expected = request.csrf_token
502
+ provided = request.headers['HTTP_X_CSRF_TOKEN'] (X-CSRF-Token)
503
+
504
+ # Check token exists
505
+ return halt_forbidden(response) unless expected && provided
506
+
507
+ # Parse timestamp
508
+ token, timestamp = provided.split(':')
509
+ return halt_forbidden(response) unless timestamp
510
+
511
+ # Check expiry
512
+ if Time.now.to_i - timestamp.to_i > TOKEN_EXPIRY
513
+ return halt_forbidden(response, 'CSRF token expired')
514
+ end
515
+
516
+ # Validate token
517
+ return halt_forbidden(response) unless provided == expected
518
+
519
+ nil
520
+ end
521
+
522
+ def self.halt_forbidden(response, message = 'CSRF Token Invalid')
523
+ response.status = 403
524
+ response.body = [message]
525
+ response
526
+ end
527
+ end
528
+ ```
529
+
530
+ ### 3. Double-Submit Cookie Pattern (Alternative)
531
+
532
+ For stateless CSRF protection:
533
+
534
+ ```ruby
535
+ class CookieBasedCsrf
536
+ def self.call(request, response)
537
+ if request.method == 'GET' || request.method == 'HEAD'
538
+ # Generate token
539
+ token = SecureRandom.urlsafe_base64(32)
540
+
541
+ # Set as cookie
542
+ response.headers['Set-Cookie'] = "csrf_token=#{token}; HttpOnly; Secure; SameSite=Strict"
543
+
544
+ # Also store for comparison
545
+ Thread.current[:csrf_cookie] = token
546
+ elsif ['POST', 'PUT', 'PATCH', 'DELETE'].include?(request.method)
547
+ # Get token from cookie
548
+ cookie_token = parse_cookie(request.headers['HTTP_COOKIE'])
549
+
550
+ # Get token from header
551
+ header_token = request.headers['HTTP_X_CSRF_TOKEN'] (X-CSRF-Token)
552
+
553
+ # Both must exist and match
554
+ unless cookie_token && header_token && cookie_token == header_token
555
+ response.status = 403
556
+ response.body = ['CSRF Token Invalid']
557
+ return response
558
+ end
559
+ end
560
+
561
+ nil
562
+ end
563
+
564
+ def self.parse_cookie(cookie_string)
565
+ return nil unless cookie_string
566
+ cookies = cookie_string.split(';').map { |c| c.strip.split('=', 2) }.to_h
567
+ cookies['csrf_token']
568
+ end
569
+ end
570
+ ```
571
+
572
+ ### 4. Idempotent Endpoints (Skip CSRF)
573
+
574
+ Some POST endpoints are idempotent and safe from CSRF:
575
+
576
+ ```ruby
577
+ Aris.routes({
578
+ "example.com": {
579
+ use: [:csrf],
580
+
581
+ "/search": {
582
+ use: nil, # Search is idempotent, no CSRF needed
583
+ post: { to: SearchHandler }
584
+ },
585
+
586
+ "/user": {
587
+ # Mutations need CSRF
588
+ post: { to: CreateUserHandler },
589
+ delete: { to: DeleteUserHandler }
590
+ }
591
+ }
592
+ })
593
+ ```
594
+
595
+ ---
596
+
597
+ ## Common Patterns
598
+
599
+ ### Multi-Step Forms
600
+
601
+ ```ruby
602
+ class StepOneHandler
603
+ def self.call(request, params)
604
+ token = request.csrf_token
605
+
606
+ # Store token in session/database for next step
607
+ SessionStore.set(params[:session_id], csrf_token: token)
608
+
609
+ # Render form with hidden token
610
+ render_form(token)
611
+ end
612
+ end
613
+
614
+ class StepTwoHandler
615
+ def self.call(request, params)
616
+ # Token validated by plugin
617
+ # Continue to next step
618
+ end
619
+ end
620
+ ```
621
+
622
+ ### File Uploads
623
+
624
+ ```html
625
+ <form action="/upload" method="POST" enctype="multipart/form-data">
626
+ <input type="hidden" name="_csrf" value="<%= token %>">
627
+ <input type="file" name="file">
628
+ <button type="submit">Upload</button>
629
+ </form>
630
+ ```
631
+
632
+ ### GraphQL
633
+
634
+ ```javascript
635
+ const client = new ApolloClient({
636
+ uri: '/graphql',
637
+ headers: {
638
+ 'X-CSRF-Token': window.CSRF_TOKEN
639
+ }
640
+ });
641
+ ```
642
+
643
+ ---
644
+
645
+ ## Security Notes
646
+
647
+ ### What CSRF Protects Against
648
+
649
+ ✅ Malicious sites submitting forms to your application
650
+ ✅ Malicious AJAX requests from other origins
651
+ ✅ Clickjacking combined with form submissions
652
+ ✅ Cross-site request attacks
653
+
654
+ ### What CSRF Does NOT Protect Against
655
+
656
+ ❌ XSS (Cross-Site Scripting) - Use Content Security Policy
657
+ ❌ SQL Injection - Use parameterized queries
658
+ ❌ Authentication bypass - Use proper auth plugins
659
+ ❌ Same-origin attacks - User's own browser extensions can still attack
660
+
661
+ ### Best Practices
662
+
663
+ - ✅ Use CSRF for all state-changing operations (POST/PUT/PATCH/DELETE)
664
+ - ✅ Combine with SameSite cookies for defense-in-depth
665
+ - ✅ Use HTTPS to prevent token interception
666
+ - ✅ Regenerate tokens after authentication
667
+ - ✅ Set short expiration times for high-security applications
668
+ - ✅ Use Content Security Policy headers
669
+ - ❌ Never skip CSRF for authenticated routes
670
+ - ❌ Never expose tokens in URLs (use headers or body)
671
+ - ❌ Never log CSRF tokens
672
+
673
+ ---
674
+
675
+ ## When to Use CSRF Protection
676
+
677
+ **✅ Always use for:**
678
+ - Form submissions
679
+ - State-changing API endpoints
680
+ - Admin panels
681
+ - User account modifications
682
+ - Financial transactions
683
+ - Any POST/PUT/PATCH/DELETE requests
684
+
685
+ **❌ Not needed for:**
686
+ - Public read-only APIs
687
+ - Bearer token authenticated APIs (different attack vector)
688
+ - GET requests (should be idempotent anyway)
689
+ - Webhook endpoints (use signature verification instead)
690
+
691
+ ---
692
+
693
+ ## Troubleshooting
694
+
695
+ ### "CSRF Token Invalid" on valid requests
696
+
697
+ **Cause:** Token not being sent with request
698
+
699
+ **Fix:** Check that token is included in `X-CSRF-Token` header
700
+
701
+ ```javascript
702
+ // Verify token is being sent
703
+ console.log('Token:', document.querySelector('meta[name="csrf-token"]').content);
704
+
705
+ fetch('/api/data', {
706
+ method: 'POST',
707
+ headers: {
708
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
709
+ },
710
+ body: JSON.stringify(data)
711
+ });
712
+ ```
713
+
714
+ ### Token works in browser but not in tests
715
+
716
+ **Cause:** Thread-local storage not persisting between requests in tests
717
+
718
+ **Fix:** Set token in the same thread as the request
719
+
720
+ ```ruby
721
+ def test_with_csrf
722
+ app = Aris::Adapters::RackApp.new
723
+
724
+ # GET to generate token (happens in this thread)
725
+ app.call(get_env)
726
+ token = request.csrf_token
727
+
728
+ # POST with token (same thread, token still available)
729
+ post_env['HTTP_X_CSRF_TOKEN'] = token
730
+ app.call(post_env)
731
+ end
732
+ ```
733
+
734
+ ### SPA token expires
735
+
736
+ **Cause:** Long-lived single-page app, token generated on initial page load
737
+
738
+ **Fix:** Refresh token periodically or after inactivity
739
+
740
+ ```javascript
741
+ // Refresh token every 30 minutes
742
+ setInterval(async () => {
743
+ const response = await fetch('/refresh-csrf');
744
+ const { token } = await response.json();
745
+ document.querySelector('meta[name="csrf-token"]').content = token;
746
+ }, 30 * 60 * 1000);
747
+ ```
748
+
749
+ ---
750
+
751
+ Need help? Check out the [full plugin development guide](../docs/plugin-development.md).