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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +251 -0
- data/README.md +18 -0
- data/docs/ADAPTERS.md +478 -0
- data/docs/ARCHITECTURE.md +222 -0
- data/docs/CONTENT.md +967 -0
- data/docs/PERFORMANCE.md +492 -0
- data/docs/PLUGIN_DEVELOPMENT.md +688 -0
- data/docs/USAGE.md +4998 -0
- data/docs/plugins/API_KEY_AUTH.md +232 -0
- data/docs/plugins/BASIC_AUTH.md +582 -0
- data/docs/plugins/BEARER_AUTH.md +394 -0
- data/docs/plugins/CACHE.md +369 -0
- data/docs/plugins/COMPRESSION.md +216 -0
- data/docs/plugins/COOKIES.md +30 -0
- data/docs/plugins/CORS.md +283 -0
- data/docs/plugins/CSRF.md +751 -0
- data/docs/plugins/ETAG.md +308 -0
- data/docs/plugins/FORM_PARSER.md +193 -0
- data/docs/plugins/HEALTH_CHECK.md +469 -0
- data/docs/plugins/JSON.md +291 -0
- data/docs/plugins/MULTIPART.md +427 -0
- data/docs/plugins/RATE_LIMITER.md +368 -0
- data/docs/plugins/REQUEST_ID.md +369 -0
- data/docs/plugins/REQUEST_LOGGER.md +151 -0
- data/docs/plugins/SECURITY.md +193 -0
- data/docs/plugins/SESSION.md +98 -0
- data/lib/aris/adapters/rack/adapter.rb +17 -2
- data/lib/aris/adapters/rack/request.rb +29 -11
- data/lib/aris/plugins/basic_auth.rb +3 -1
- data/lib/aris/plugins/cookies.rb +4 -32
- data/lib/aris/plugins/cors.rb +8 -1
- data/lib/aris/plugins/csrf.rb +63 -22
- data/lib/aris/plugins/flash.rb +3 -1
- data/lib/aris/plugins/form_parser.rb +52 -31
- data/lib/aris/plugins/multipart.rb +22 -2
- data/lib/aris/plugins/request_logger.rb +8 -1
- data/lib/aris/plugins/security_headers.rb +8 -1
- data/lib/aris/plugins/session.rb +150 -99
- data/lib/aris/response_helpers.rb +41 -0
- data/lib/aris/version.rb +2 -2
- 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).
|